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

363 lines
12 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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-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 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)
}
}
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)
}
}
}
}