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

363 lines
12 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 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)
}
}
}
}