package radar import ( "context" "fmt" "net/http" "strings" "testing" "apps/backend/internal/middleware" radarRepo "apps/backend/internal/module/radar/repository" radarUC "apps/backend/internal/module/radar/usecase" usageDomain "apps/backend/internal/module/usage/domain" usageRepo "apps/backend/internal/module/usage/repository" usageUC "apps/backend/internal/module/usage/usecase" "apps/backend/internal/svc" "apps/backend/internal/types" ) /* M1 驗收(spec §9.1):SP-01、SP-02、RW-01~RW-04。 這裡走 handler 呼叫的 logic 層與真的 usecase/repository,只有兩處換成假的: 儲存層用 memory(不需要 mongo 就能跑)、AI 用 fakeSuggestAI(不打 provider、不花錢)。 每個 case 的失敗訊息都帶 spec ID,壞掉時能直接對回驗收表。 */ type fakeSuggestAI struct { reply string err error calls int prompt string } func (f *fakeSuggestAI) Complete(_ context.Context, _, _, prompt string) (string, error) { f.calls++ f.prompt = prompt return f.reply, f.err } func (f *fakeSuggestAI) CompleteStream(ctx context.Context, apiKey, model, prompt string, _ func(string) error) (string, error) { return f.Complete(ctx, apiKey, model, prompt) } func (f *fakeSuggestAI) ListModels(context.Context, string) ([]string, error) { return nil, nil } const fakeSuggestReply = `[ {"term":"台北 婚攝 推薦","reason":"正在找婚禮攝影的人最常這樣問","usage":"include"}, {"term":"婚禮 攝影 價格","reason":"問價格的人通常已經在比較廠商","usage":"include"}, {"term":"徵 婚攝","reason":"這是同業徵才,不是客戶需求","usage":"exclude"} ]` type m1Env struct { ctx context.Context svcCtx *svc.ServiceContext ai *fakeSuggestAI usage *usageUC.Service uid int64 } func newM1Env(t *testing.T, uid int64, maxActive int) *m1Env { t.Helper() ai := &fakeSuggestAI{reply: fakeSuggestReply} key := func(meter string) string { return fmt.Sprintf("%d:%s", uid, meter) } usage := usageUC.New(usageRepo.NewMemory(), &usageUC.StaticResolver{Map: map[string]string{ key(usageDomain.MeterAICopy): usageDomain.KeyModePlatform, key(usageDomain.MeterAIResearch): usageDomain.KeyModePlatform, }}) radar := radarUC.New(radarRepo.NewMemory()) radar.Quota = radarUC.FixedQuota{MaxActiveWatches: maxActive, MaxDailyOpportunities: 30} radar.AI = ai radar.Usage = usage return &m1Env{ ctx: middleware.WithUID(context.Background(), uid), svcCtx: &svc.ServiceContext{Radar: radar}, ai: ai, usage: usage, uid: uid, } } func (e *m1Env) seedProfile(t *testing.T) { t.Helper() if _, err := NewUpsertServiceProfileLogic(e.ctx, e.svcCtx).UpsertServiceProfile(validProfileReq()); err != nil { t.Fatalf("seed service profile: %v", err) } } func (e *m1Env) createWatch(t *testing.T, term string, enabled bool) *types.RadarWatchPublic { t.Helper() w, err := NewCreateWatchLogic(e.ctx, e.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{term}, Enabled: enabled, }) if err != nil { t.Fatalf("create watch %q: %v", term, err) } return w } func (e *m1Env) list(t *testing.T, status string) *types.WatchListData { t.Helper() data, err := NewListWatchesLogic(e.ctx, e.svcCtx).ListWatches(&types.ListWatchesReq{ Page: 1, PageSize: 50, Status: status, }) if err != nil { t.Fatalf("list watches (status=%q): %v", status, err) } return data } // sweepCandidates 是每日排程真正會巡的集合;RW-02/RW-04 的重點就是它有沒有變。 func (e *m1Env) sweepCandidates(t *testing.T) []string { t.Helper() list, err := e.svcCtx.Radar.ListActiveWatches(e.ctx, e.uid) if err != nil { t.Fatalf("list active watches: %v", err) } ids := make([]string, 0, len(list)) for _, w := range list { ids = append(ids, w.ID) } return ids } // SP-01:新會員沒有服務檔案就建 active watch → 明確錯誤,且訊息要指向服務檔案。 func TestM1_SP01_ActiveWatchWithoutServiceProfileIsRejected(t *testing.T) { env := newM1Env(t, 42, 5) _, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"婚攝 推薦"}, Enabled: true, }) if err == nil { t.Fatal("SP-01: active watch was created without a service profile") } envelope := assertStatus(t, err, http.StatusBadRequest, 400100) if !strings.Contains(envelope.Message, "service-profile") { t.Fatalf("SP-01: message must point at the service profile, got %q", envelope.Message) } // 擋下之後不能留半筆:使用者回頭填完檔案,配額要從 0 開始算。 if got := env.list(t, ""); got.Pagination.Total != 0 { t.Fatalf("SP-01: rejected create left %d watches behind", got.Pagination.Total) } // 停用狀態的 watch 不占用巡的資源,所以允許先建起來備用。 env.createWatch(t, "婚攝 推薦", false) if got := env.list(t, ""); got.Pagination.Total != 1 || got.ActiveCount != 0 { t.Fatalf("SP-01: paused watch should be allowed without a profile, got %+v", got) } } // SP-02:填完服務檔案後 GET 要拿回全部欄位;forbidden[] 是回覆生成的硬性過濾詞,尤其不能掉。 func TestM1_SP02_ServiceProfileReadsBackEveryField(t *testing.T) { env := newM1Env(t, 42, 5) env.seedProfile(t) got, err := NewGetServiceProfileLogic(env.ctx, env.svcCtx).GetServiceProfile() if err != nil { t.Fatalf("SP-02: get service profile: %v", err) } if !got.Exists { t.Fatal("SP-02: exists = false right after saving") } if len(got.Services) != 1 || got.Services[0].Name != "婚禮攝影" || got.Services[0].PriceMin != 18000 || got.Services[0].PriceMax != 36000 { t.Fatalf("SP-02: services = %+v", got.Services) } if len(got.Forbidden) != 1 || got.Forbidden[0] != "保證接到案" { t.Fatalf("SP-02: forbidden = %v, want the saved words readable", got.Forbidden) } if len(got.Cases) != 1 || len(got.Faq) != 1 || len(got.ServiceAreas) != 1 { t.Fatalf("SP-02: cases/faq/areas lost: %+v", got) } if got.Availability != "平日全天" || got.ToneNote != "親切、不推銷" { t.Fatalf("SP-02: free-text fields lost: %+v", got) } if got.UpdatedAt <= 0 { t.Fatalf("SP-02: updated_at = %d, want unix nanoseconds", got.UpdatedAt) } } // RW-01:上限 1 的方案已有一個 active,再建一個要被擋,訊息要同時說出上限與升級路徑。 func TestM1_RW01_SecondActiveWatchOverQuotaIsRejected(t *testing.T) { env := newM1Env(t, 42, 1) env.seedProfile(t) first := env.createWatch(t, "婚攝 推薦", true) _, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"活動紀錄"}, Enabled: true, }) if err == nil { t.Fatal("RW-01: second active watch accepted on a 1-watch plan") } envelope := assertStatus(t, err, http.StatusBadRequest, 400100) if !strings.Contains(envelope.Message, "1") || !strings.Contains(envelope.Message, "upgrade") { t.Fatalf("RW-01: message = %q, want the current limit plus an upgrade hint", envelope.Message) } // 擋下不影響既有那筆,也不能偷偷降級它。 after := env.list(t, "") if after.ActiveCount != 1 || after.MaxActive != 1 { t.Fatalf("RW-01: quota fields = active %d / max %d", after.ActiveCount, after.MaxActive) } if ids := env.sweepCandidates(t); len(ids) != 1 || ids[0] != first.Id { t.Fatalf("RW-01: sweep candidates = %v, want only the existing watch", ids) } // 停用狀態不佔配額:使用者可以先備好,暫停舊的再啟用。 if _, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{"活動紀錄"}, Enabled: false, }); err != nil { t.Fatalf("RW-01: paused watch should not consume the active quota: %v", err) } } // RW-02:pause 後不再排入每日巡,但資料還在,恢復後照樣回到巡的名單。 func TestM1_RW02_PausedWatchLeavesTheDailySweep(t *testing.T) { env := newM1Env(t, 42, 5) env.seedProfile(t) w := env.createWatch(t, "婚攝 推薦", true) paused, err := NewPauseWatchLogic(env.ctx, env.svcCtx).PauseWatch(&types.WatchIdReq{Id: w.Id}) if err != nil { t.Fatalf("RW-02: pause: %v", err) } if paused.Status != "paused" { t.Fatalf("RW-02: status = %q after pause", paused.Status) } if ids := env.sweepCandidates(t); len(ids) != 0 { t.Fatalf("RW-02: paused watch is still scheduled for sweeps: %v", ids) } // 設定保留:暫停是「先別巡」,不是刪掉。 still := env.list(t, "") if still.Pagination.Total != 1 || still.ActiveCount != 0 { t.Fatalf("RW-02: list = %+v, want the watch kept but inactive", still) } if len(still.List[0].Terms) != 1 || still.List[0].Terms[0] != "婚攝 推薦" { t.Fatalf("RW-02: terms lost on pause: %+v", still.List[0]) } resumed, err := NewResumeWatchLogic(env.ctx, env.svcCtx).ResumeWatch(&types.WatchIdReq{Id: w.Id}) if err != nil || resumed.Status != "active" { t.Fatalf("RW-02: resume = %+v, err = %v", resumed, err) } if ids := env.sweepCandidates(t); len(ids) != 1 || ids[0] != w.Id { t.Fatalf("RW-02: resumed watch missing from sweeps: %v", ids) } } // RW-03:有服務檔案就給得出帶理由的建議,而且只是建議 —— 不會自己建立訂閱。 func TestM1_RW03_SuggestReturnsAdoptableTerms(t *testing.T) { env := newM1Env(t, 42, 5) env.seedProfile(t) data, err := NewSuggestWatchTermsLogic(env.ctx, env.svcCtx).SuggestWatchTerms(&types.SuggestWatchTermsReq{}) if err != nil { t.Fatalf("RW-03: suggest: %v", err) } if len(data.List) != 3 { t.Fatalf("RW-03: got %d suggestions, want 3", len(data.List)) } for _, s := range data.List { if strings.TrimSpace(s.Term) == "" || strings.TrimSpace(s.Reason) == "" { t.Fatalf("RW-03: suggestion without term or reason: %+v", s) } if s.Usage != "include" && s.Usage != "exclude" { t.Fatalf("RW-03: suggestion %q has usage %q", s.Term, s.Usage) } } // prompt 必須帶服務檔案,否則建議跟這個人的生意無關。 if !strings.Contains(env.ai.prompt, "婚禮攝影") { t.Fatal("RW-03: prompt did not include the member's services") } if env.ai.calls != 1 { t.Fatalf("RW-03: AI called %d times for one suggest request", env.ai.calls) } // 建議不建立任何 watch:採用與否是使用者的決定。 if got := env.list(t, ""); got.Pagination.Total != 0 { t.Fatalf("RW-03: suggest created %d watches", got.Pagination.Total) } // 逐條採用=把建議當成 create 的輸入,這條路徑要真的走得通。 adopted, err := NewCreateWatchLogic(env.ctx, env.svcCtx).CreateWatch(&types.CreateWatchReq{ Terms: []string{data.List[0].Term}, ExcludeTerms: []string{data.List[2].Term}, Enabled: true, }) if err != nil { t.Fatalf("RW-03: adopting a suggestion failed: %v", err) } if len(adopted.Terms) != 1 || len(adopted.ExcludeTerms) != 1 { t.Fatalf("RW-03: adopted watch = %+v", adopted) } // 計費走既有 ai_copy,source 標 radar.suggest(spec §5.5)。 events, err := env.usage.ListEvents(env.ctx, env.uid, usageDomain.CurrentMonthKey(), "all", 0) if err != nil { t.Fatalf("RW-03: list usage events: %v", err) } if len(events) != 1 || events[0].Meter != usageDomain.MeterAICopy || events[0].Source != "radar.suggest" { t.Fatalf("RW-03: usage events = %+v, want one ai_copy/radar.suggest", events) } } // RW-04:封存是終點 —— 不再巡、不能恢復,但歷史還看得到。 func TestM1_RW04_ArchivedWatchStopsProducingAndStaysVisible(t *testing.T) { env := newM1Env(t, 42, 5) env.seedProfile(t) keep := env.createWatch(t, "婚攝 推薦", true) drop := env.createWatch(t, "活動紀錄", true) if _, err := NewArchiveWatchLogic(env.ctx, env.svcCtx).ArchiveWatch(&types.WatchIdReq{Id: drop.Id}); err != nil { t.Fatalf("RW-04: archive: %v", err) } if ids := env.sweepCandidates(t); len(ids) != 1 || ids[0] != keep.Id { t.Fatalf("RW-04: sweep candidates = %v, want only the surviving watch", ids) } // 軟刪:列表仍看得到歷史,只是狀態是 archived。 all := env.list(t, "") if all.Pagination.Total != 2 || all.ActiveCount != 1 { t.Fatalf("RW-04: list = total %d / active %d, want 2 / 1", all.Pagination.Total, all.ActiveCount) } archived := env.list(t, "archived") if archived.Pagination.Total != 1 || archived.List[0].Id != drop.Id { t.Fatalf("RW-04: archived filter = %+v", archived.List) } // 不可復活:否則配額與統計都會出現無法解釋的跳動。 if _, err := NewResumeWatchLogic(env.ctx, env.svcCtx).ResumeWatch(&types.WatchIdReq{Id: drop.Id}); err == nil { t.Fatal("RW-04: archived watch was resumed") } else { assertStatus(t, err, http.StatusBadRequest, 400100) } if _, err := NewPauseWatchLogic(env.ctx, env.svcCtx).PauseWatch(&types.WatchIdReq{Id: drop.Id}); err == nil { t.Fatal("RW-04: archived watch accepted a pause") } }