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) } } // 新用戶建第一組 active 訂閱應立即排入首巡,不用等每日排程; // 第二組訂閱不該再重複觸發,避免每次新增都多燒一次巡檢成本。 func TestCreateWatch_FirstActiveWatchTriggersImmediateSweep(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) var scheduled []string svc.SweepJobs = SweepJobSchedulerFunc(func(_ context.Context, ownerUID int64, watchID string, _ int64) (string, error) { scheduled = append(scheduled, watchID) return "job-" + watchID, nil }) first, err := svc.CreateWatch(ctx, 42, watchInput()) if err != nil { t.Fatalf("create first: %v", err) } if !first.FirstSweepTriggered { t.Fatalf("first watch should report FirstSweepTriggered=true") } if len(scheduled) != 1 || scheduled[0] != first.ID { t.Fatalf("scheduled = %v, want exactly [%s]", scheduled, first.ID) } second, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true}) if err != nil { t.Fatalf("create second: %v", err) } if second.FirstSweepTriggered { t.Fatalf("second watch should not trigger another immediate sweep") } if len(scheduled) != 1 { t.Fatalf("scheduled = %v, want no additional sweep for the second watch", scheduled) } } // SweepJobs 未配置(例如測試環境)時不該讓建立失敗,也不該回報已觸發首巡。 func TestCreateWatch_FirstWatchWithoutSweepJobsDoesNotFail(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.FirstSweepTriggered { t.Fatalf("FirstSweepTriggered should be false when SweepJobs is nil") } } // 第一組訂閱先以 paused 建立時不算「第一組 active」,之後才建的 active 訂閱 // 不會觸發首巡:規則只看「使用者是否曾建過任何訂閱」,不特別追第一個 active。 func TestCreateWatch_FirstWatchPausedThenActiveSecondSkipsImmediateSweep(t *testing.T) { svc, ctx := serviceWithProfile(t, 5) var scheduled []string svc.SweepJobs = SweepJobSchedulerFunc(func(_ context.Context, _ int64, watchID string, _ int64) (string, error) { scheduled = append(scheduled, watchID) return "job-" + watchID, nil }) if _, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"婚攝 推薦"}, Enabled: false}); err != nil { t.Fatalf("create paused: %v", err) } active, err := svc.CreateWatch(ctx, 42, WatchInput{Terms: []string{"活動紀錄"}, Enabled: true}) if err != nil { t.Fatalf("create active: %v", err) } if active.FirstSweepTriggered || len(scheduled) != 0 { t.Fatalf("expected no immediate sweep after an earlier paused watch already existed, scheduled=%v", scheduled) } } 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) } } } }