thread-master/apps/backend/internal/logic/radar/m1_integration_test.go

349 lines
12 KiB
Go
Raw Normal View History

2026-08-03 05:52:02 +00:00
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.1SP-01SP-02RW-01RW-04
這裡走 handler 呼叫的 logic 層與真的 usecaserepository只有兩處換成假的
儲存層用 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-02RW-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-02pause 後不再排入每日巡,但資料還在,恢復後照樣回到巡的名單。
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_copysource 標 radar.suggestspec §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")
}
}