thread-master/apps/backend/internal/module/radar/usecase/watch_test.go

492 lines
17 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 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 TestCreateWatchExpandsUnsearchableTerms(t *testing.T) {
svc, ctx := serviceWithProfile(t, 5)
w, err := svc.CreateWatch(ctx, 42, WatchInput{
Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"},
Enabled: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if len(w.Terms) == 0 {
t.Fatal("long sentence must expand into searchable terms")
}
for _, term := range w.Terms {
if !domain.IsThreadsSearchable(term) {
t.Fatalf("persisted term %q is not searchable", term)
}
}
}
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-04archived 是終態,歷史資料留著但不能復活。
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 TestDeleteArchivedWatchRemovesDefinitionKeepsHistoryBoundary(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.DeleteArchivedWatch(ctx, 42, w.ID); err != nil {
t.Fatalf("delete archived: %v", err)
}
if _, err := svc.GetWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("deleted watch lookup err = %v, want ErrNotFound", err)
}
list, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{})
if err != nil {
t.Fatalf("list after delete: %v", err)
}
if total != 0 || len(list) != 0 {
t.Fatalf("deleted watch still listed: list=%+v total=%d", list, total)
}
// Sweeps/opportunities are separate repository records and are intentionally
// not touched by deleting the watch definition.
}
func TestDeleteArchivedWatchRejectsNonArchived(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.DeleteArchivedWatch(ctx, 42, w.ID); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("delete active err = %v, want ErrValidation", err)
}
if _, err := svc.GetWatch(ctx, 42, w.ID); err != nil {
t.Fatalf("active watch disappeared after rejected delete: %v", 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: &regions})
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)
}
// 狀態只走 pauseresumearchivepatch 不該碰它。
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-01Free 只有 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)
}
}
}
}