thread-master/apps/backend/internal/logic/radar/watch_logic_test.go

229 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package radar
import (
"context"
"net/http"
"strings"
"testing"
"apps/backend/internal/middleware"
radarRepo "apps/backend/internal/module/radar/repository"
radarUC "apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
)
func watchCtx(t *testing.T, uid int64, maxActive int, withProfile bool) (context.Context, *svc.ServiceContext) {
t.Helper()
radar := radarUC.New(radarRepo.NewMemory())
radar.Quota = radarUC.FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: 30}
svcCtx := &svc.ServiceContext{Radar: radar}
ctx := middleware.WithUID(context.Background(), uid)
if withProfile {
if _, err := NewUpsertServiceProfileLogic(ctx, svcCtx).UpsertServiceProfile(validProfileReq()); err != nil {
t.Fatalf("seed profile: %v", err)
}
}
return ctx, svcCtx
}
func createWatch(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, term string, enabled bool) *types.RadarWatchPublic {
t.Helper()
w, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{term},
Enabled: enabled,
})
if err != nil {
t.Fatalf("create watch %q: %v", term, err)
}
return w
}
// SP-01沒建服務檔案就建 active 訂閱 → 400100訊息要指向服務檔案而不是只說「失敗」。
func TestCreateActiveWatchWithoutProfileIsRejected(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, false)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
Enabled: true,
})
if err == nil {
t.Fatal("active watch created without a service profile")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "service-profile") {
t.Fatalf("message must point at the service profile, got %q", env.Message)
}
}
// RW-01上限 1 時第二個 active 被拒,訊息要有目前上限與升級提示。
func TestCreateWatchOverQuotaCarriesLimitAndUpgradeHint(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 1, true)
createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
_, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"活動紀錄"},
Enabled: true,
})
if err == nil {
t.Fatal("second active watch accepted on a 1-watch plan")
}
env := assertStatus(t, err, http.StatusBadRequest, 400100)
if !strings.Contains(env.Message, "1") || !strings.Contains(env.Message, "upgrade") {
t.Fatalf("message = %q, want the current limit plus an upgrade hint", env.Message)
}
}
func TestListWatchesReportsQuotaAndProfileState(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
createWatch(t, ctx, svcCtx, "活動紀錄", false)
data, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(data.List) != 2 || data.Pagination.Total != 2 {
t.Fatalf("list = %d items, total = %d", len(data.List), data.Pagination.Total)
}
// 前端要靠這三個值在建立之前就決定顯示引導還是升級提示。
if data.ActiveCount != 1 || data.MaxActive != 5 || !data.ProfileExists {
t.Fatalf("quota fields = active %d / max %d / profile %v", data.ActiveCount, data.MaxActive, data.ProfileExists)
}
paused, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20, Status: "paused"})
if err != nil {
t.Fatalf("list paused: %v", err)
}
if len(paused.List) != 1 || paused.List[0].Status != "paused" {
t.Fatalf("status filter returned %+v", paused.List)
}
}
// RW-02RW-04pause 退出 active 清單archive 之後不能 resume。
func TestPauseResumeArchiveThroughHTTPLayer(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
w := createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
paused, err := NewPauseWatchLogic(ctx, svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || paused.Status != "paused" {
t.Fatalf("pause = %+v, err = %v", paused, err)
}
after, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil || after.ActiveCount != 0 {
t.Fatalf("active_count = %d after pause (err %v)", after.ActiveCount, err)
}
resumed, err := NewResumeWatchLogic(ctx, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || resumed.Status != "active" {
t.Fatalf("resume = %+v, err = %v", resumed, err)
}
ok, err := NewArchiveWatchLogic(ctx, svcCtx).ArchiveWatch(&types.WatchIdReq{Id: w.Id})
if err != nil || !ok.Ok {
t.Fatalf("archive = %+v, err = %v", ok, err)
}
if _, err := NewResumeWatchLogic(ctx, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id}); err == nil {
t.Fatal("archived watch was resumed")
} else {
assertStatus(t, err, http.StatusBadRequest, 400100)
}
// 軟刪:列表還看得到,只是不再是 active。
list, err := NewListWatchesLogic(ctx, svcCtx).ListWatches(&types.ListWatchesReq{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list.List) != 1 || list.List[0].Status != "archived" || list.ActiveCount != 0 {
t.Fatalf("archive should be a soft delete, got %+v", list.List)
}
}
func TestUpdateWatchLeavesOmittedFieldsAlone(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
created, err := NewCreateWatchLogic(ctx, svcCtx).CreateWatch(&types.CreateWatchReq{
Terms: []string{"婚攝 推薦"},
ExcludeTerms: []string{"徵才"},
Regions: []string{"TPE"},
Enabled: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
updated, err := NewUpdateWatchLogic(ctx, svcCtx).UpdateWatch(&types.UpdateWatchReq{
Id: created.Id,
Regions: []string{"KHH"},
})
if err != nil {
t.Fatalf("update: %v", err)
}
if len(updated.Terms) != 1 || len(updated.ExcludeTerms) != 1 {
t.Fatalf("omitted fields were cleared: %+v", updated)
}
if len(updated.Regions) != 1 || updated.Regions[0] != "KHH" {
t.Fatalf("regions = %v", updated.Regions)
}
}
func TestWatchEndpointsRejectOtherOwners(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
w := createWatch(t, ctx, svcCtx, "婚攝 推薦", true)
other := middleware.WithUID(context.Background(), 43)
for name, call := range map[string]func() error{
"get": func() error {
_, err := NewGetWatchLogic(other, svcCtx).GetWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"pause": func() error {
_, err := NewPauseWatchLogic(other, svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"resume": func() error {
_, err := NewResumeWatchLogic(other, svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"archive": func() error {
_, err := NewArchiveWatchLogic(other, svcCtx).ArchiveWatch(&types.WatchIdReq{Id: w.Id})
return err
},
"update": func() error {
_, err := NewUpdateWatchLogic(other, svcCtx).UpdateWatch(&types.UpdateWatchReq{Id: w.Id, Regions: []string{"KHH"}})
return err
},
} {
t.Run(name, func(t *testing.T) {
err := call()
if err == nil {
t.Fatalf("owner 43 could %s owner 42's watch", name)
}
assertStatus(t, err, http.StatusForbidden, 403003)
})
}
}
func TestWatchEndpointsRequireAuth(t *testing.T) {
_, svcCtx := watchCtx(t, 42, 5, true)
anon := context.Background()
if _, err := NewListWatchesLogic(anon, svcCtx).ListWatches(&types.ListWatchesReq{}); err == nil {
t.Fatal("list without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
if _, err := NewCreateWatchLogic(anon, svcCtx).CreateWatch(&types.CreateWatchReq{Terms: []string{"婚攝"}}); err == nil {
t.Fatal("create without JWT succeeded")
} else {
assertStatus(t, err, http.StatusUnauthorized, 401001)
}
}
func TestMissingWatchReportsNotFound(t *testing.T) {
ctx, svcCtx := watchCtx(t, 42, 5, true)
if _, err := NewGetWatchLogic(ctx, svcCtx).GetWatch(&types.WatchIdReq{Id: "does-not-exist"}); err == nil {
t.Fatal("missing watch returned success")
} else {
assertStatus(t, err, http.StatusNotFound, 404001)
}
}