package usecase import ( "context" "errors" "strings" "testing" "apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/repository" ) // 大部分 watch 行為都要求服務檔案已存在,所以測試從「已建檔」起跑。 func serviceWithProfile(t *testing.T, maxActive int) (*Service, context.Context) { t.Helper() svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: 30} ctx := context.Background() if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { t.Fatalf("seed profile: %v", err) } return svc, ctx } func watchInput() WatchInput { return WatchInput{Terms: []string{"婚攝 推薦"}, Enabled: true} } func TestWatchTermsAreNormalized(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) w, err := svc.CreateWatch(ctx, 42, WatchInput{ Terms: []string{" Wedding Photo ", "wedding photo", "婚攝 推薦"}, ExcludeTerms: []string{"徵才", "徵才"}, Regions: []string{"tpe", "TPE"}, Enabled: true, }) if err != nil { t.Fatalf("create: %v", err) } // 大小寫與多餘空白必須收斂,否則同一個詞會在關鍵字統計裡拆成好幾列。 if len(w.Terms) != 2 || w.Terms[0] != "wedding photo" { t.Fatalf("terms = %v, want deduped lower-case", w.Terms) } if w.Terms[1] != "婚攝 推薦" { t.Fatalf("full-width space not collapsed: %q", w.Terms[1]) } if len(w.ExcludeTerms) != 1 || len(w.Regions) != 1 || w.Regions[0] != "TPE" { t.Fatalf("exclude/regions not normalized: %+v", w) } } func TestCreateWatchRejectsBadInput(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) cases := map[string]WatchInput{ "no terms": {Terms: nil, Enabled: true}, "blank terms": {Terms: []string{" ", ""}, Enabled: true}, "term too short": {Terms: []string{"a"}, Enabled: true}, "unknown region": {Terms: []string{"婚攝"}, Regions: []string{"台北"}, Enabled: true}, "term also excluded": {Terms: []string{"婚攝"}, ExcludeTerms: []string{"婚攝"}, Enabled: true}, } for name, in := range cases { t.Run(name, func(t *testing.T) { if _, err := svc.CreateWatch(ctx, 42, in); !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } }) } } func TestWatchPauseResumeRoundTrip(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) w, err := svc.CreateWatch(ctx, 42, watchInput()) if err != nil { t.Fatalf("create: %v", err) } if w.Status != domain.WatchActive { t.Fatalf("status = %q, want active", w.Status) } paused, err := svc.PauseWatch(ctx, 42, w.ID) if err != nil { t.Fatalf("pause: %v", err) } if paused.Status != domain.WatchPaused { t.Fatalf("status = %q, want paused", paused.Status) } // RW-02:暫停後不再排入每日巡。 active, err := svc.ListActiveWatches(ctx, 42) if err != nil { t.Fatalf("list active: %v", err) } if len(active) != 0 { t.Fatalf("paused watch still in active list: %+v", active) } resumed, err := svc.ResumeWatch(ctx, 42, w.ID) if err != nil { t.Fatalf("resume: %v", err) } if resumed.Status != domain.WatchActive { t.Fatalf("status = %q, want active", resumed.Status) } if active, _ = svc.ListActiveWatches(ctx, 42); len(active) != 1 { t.Fatalf("resumed watch missing from active list: %+v", active) } } // RW-04:archived 是終態,歷史資料留著但不能復活。 func TestArchivedWatchIsTerminal(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) w, err := svc.CreateWatch(ctx, 42, watchInput()) if err != nil { t.Fatalf("create: %v", err) } if err := svc.ArchiveWatch(ctx, 42, w.ID); err != nil { t.Fatalf("archive: %v", err) } if _, err := svc.ResumeWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) { t.Fatalf("resume after archive: err = %v, want ErrValidation", err) } if _, err := svc.PauseWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) { t.Fatalf("pause after archive: err = %v, want ErrValidation", err) } terms := []string{"改個詞"} if _, err := svc.UpdateWatch(ctx, 42, w.ID, WatchPatch{Terms: &terms}); !errors.Is(err, domain.ErrValidation) { t.Fatalf("edit after archive: err = %v, want ErrValidation", err) } active, err := svc.ListActiveWatches(ctx, 42) if err != nil || len(active) != 0 { t.Fatalf("archived watch still active: %+v (%v)", active, err) } // 軟刪:列表仍看得到,統計與歷史才有依據。 list, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{}) if err != nil || total != 1 || len(list) != 1 || list[0].Status != domain.WatchArchived { t.Fatalf("archived watch was hard-deleted: list=%+v total=%d err=%v", list, total, err) } } func TestUpdateWatchOnlyTouchesGivenFields(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) w, err := svc.CreateWatch(ctx, 42, WatchInput{ Terms: []string{"婚攝 推薦"}, ExcludeTerms: []string{"徵才"}, Regions: []string{"TPE"}, Enabled: true, }) if err != nil { t.Fatalf("create: %v", err) } regions := []string{"KHH"} updated, err := svc.UpdateWatch(ctx, 42, w.ID, WatchPatch{Regions: ®ions}) if err != nil { t.Fatalf("update: %v", err) } if len(updated.Terms) != 1 || updated.Terms[0] != "婚攝 推薦" { t.Fatalf("terms changed by a regions-only patch: %v", updated.Terms) } if len(updated.ExcludeTerms) != 1 { t.Fatalf("exclude_terms changed by a regions-only patch: %v", updated.ExcludeTerms) } if len(updated.Regions) != 1 || updated.Regions[0] != "KHH" { t.Fatalf("regions = %v, want [KHH]", updated.Regions) } // 狀態只走 pause/resume/archive,patch 不該碰它。 if updated.Status != domain.WatchActive { t.Fatalf("status = %q, want active", updated.Status) } } // SP-01:沒有服務檔案,判定沒有比對基準,所以不准有 active 訂閱。 func TestActiveWatchRequiresServiceProfile(t *testing.T) { svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30} ctx := context.Background() _, err := svc.CreateWatch(ctx, 42, watchInput()) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } if !strings.Contains(err.Error(), "service-profile") { t.Fatalf("error must point at the service profile, got %q", err) } // 但可以先建成 paused 把關鍵字備好。 paused, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}}) if err != nil { t.Fatalf("create paused without profile: %v", err) } if paused.Status != domain.WatchPaused { t.Fatalf("status = %q, want paused", paused.Status) } // 建檔後才能開起來。 if _, err := svc.ResumeWatch(ctx, 42, paused.ID); !errors.Is(err, domain.ErrValidation) { t.Fatalf("resume without profile: err = %v, want ErrValidation", err) } if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { t.Fatalf("upsert profile: %v", err) } if _, err := svc.ResumeWatch(ctx, 42, paused.ID); err != nil { t.Fatalf("resume after profile exists: %v", err) } } // RW-01:Free 只有 1 個 active,第二個要被明確拒絕且訊息帶上限。 func TestActiveWatchQuotaGate(t *testing.T) { svc, ctx := serviceWithProfile(t, 1) first, err := svc.CreateWatch(ctx, 42, watchInput()) if err != nil { t.Fatalf("first create: %v", err) } _, err = svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true}) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } if !strings.Contains(err.Error(), "1") || !strings.Contains(err.Error(), "upgrade") { t.Fatalf("error must carry the limit and an upgrade hint, got %q", err) } // 暫停第一個之後就有位置了。 if _, err := svc.PauseWatch(ctx, 42, first.ID); err != nil { t.Fatalf("pause: %v", err) } second, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true}) if err != nil { t.Fatalf("create after pause: %v", err) } // 而恢復第一個又會超限:暫停再恢復不能當成繞過上限的路。 if _, err := svc.ResumeWatch(ctx, 42, first.ID); !errors.Is(err, domain.ErrValidation) { t.Fatalf("resume over quota: err = %v, want ErrValidation", err) } if second.Status != domain.WatchActive { t.Fatalf("second watch status = %q", second.Status) } } // 方案降級後既有超額訂閱不強制降級,只擋新增(spec §3.1)。 func TestExistingOverQuotaWatchesAreNotDowngraded(t *testing.T) { svc, ctx := serviceWithProfile(t, 3) for _, term := range []string{"婚攝 推薦", "活動紀錄", "商品攝影"} { if _, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{term}, Enabled: true}); err != nil { t.Fatalf("create %s: %v", term, err) } } svc.Quota = FixedQuota{MaxActiveWatches: 1, MaxDailyOpportunities: 5} active, err := svc.ListActiveWatches(ctx, 42) if err != nil || len(active) != 3 { t.Fatalf("existing watches were downgraded: %d (%v)", len(active), err) } if _, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"新的詞"}, Enabled: true}); !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation for a new watch over quota", err) } } func TestWatchesAreIsolatedPerOwner(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) w, err := svc.CreateWatch(ctx, 42, watchInput()) if err != nil { t.Fatalf("create: %v", err) } if _, err := svc.GetWatch(ctx, 43, w.ID); !errors.Is(err, domain.ErrForbidden) { t.Fatalf("owner 43 read owner 42's watch: %v", err) } if _, err := svc.PauseWatch(ctx, 43, w.ID); !errors.Is(err, domain.ErrForbidden) { t.Fatalf("owner 43 paused owner 42's watch: %v", err) } if err := svc.ArchiveWatch(ctx, 43, w.ID); !errors.Is(err, domain.ErrForbidden) { t.Fatalf("owner 43 archived owner 42's watch: %v", err) } list, total, err := svc.ListWatches(ctx, 43, domain.WatchListFilter{}) if err != nil || total != 0 || len(list) != 0 { t.Fatalf("owner 43 listed owner 42's watches: %+v (%d, %v)", list, total, err) } } func TestListWatchesFilterAndPaging(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) for _, term := range []string{"婚攝 推薦", "活動紀錄", "商品攝影"} { if _, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{term}, Enabled: true}); err != nil { t.Fatalf("create %s: %v", term, err) } } list, _, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{}) if err != nil { t.Fatalf("list: %v", err) } if _, err := svc.PauseWatch(ctx, 42, list[0].ID); err != nil { t.Fatalf("pause: %v", err) } paused, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{Status: domain.WatchPaused}) if err != nil || total != 1 || len(paused) != 1 { t.Fatalf("status filter: list=%+v total=%d err=%v", paused, total, err) } if _, _, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{Status: "nope"}); !errors.Is(err, domain.ErrValidation) { t.Fatalf("unknown status filter should be rejected, got %v", err) } page1, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{Page: 1, PageSize: 2}) if err != nil || total != 3 || len(page1) != 2 { t.Fatalf("page 1: list=%d total=%d err=%v", len(page1), total, err) } page2, _, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{Page: 2, PageSize: 2}) if err != nil || len(page2) != 1 { t.Fatalf("page 2: list=%d err=%v", len(page2), err) } } func TestMarkWatchSweptAt(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) w, err := svc.CreateWatch(ctx, 42, watchInput()) if err != nil { t.Fatalf("create: %v", err) } if err := svc.MarkWatchSwept(ctx, w.ID, 0); err != nil { t.Fatalf("mark swept: %v", err) } got, err := svc.GetWatch(ctx, 42, w.ID) if err != nil { t.Fatalf("get: %v", err) } if got.LastSweptAt <= 0 { t.Fatalf("last_swept_at = %d, want a timestamp", got.LastSweptAt) } if err := svc.MarkWatchSwept(ctx, "missing", 0); !errors.Is(err, domain.ErrNotFound) { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestWatchTransitionMatrix(t *testing.T) { allowed := map[string][]string{ domain.WatchActive: {domain.WatchPaused, domain.WatchArchived}, domain.WatchPaused: {domain.WatchActive, domain.WatchArchived}, domain.WatchArchived: {}, } states := []string{domain.WatchActive, domain.WatchPaused, domain.WatchArchived} for _, from := range states { for _, to := range states { want := from == to for _, ok := range allowed[from] { if ok == to { want = true } } if got := domain.CanTransitionWatch(from, to); got != want { t.Fatalf("CanTransitionWatch(%s, %s) = %v, want %v", from, to, got, want) } } } }