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