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

349 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 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-01、SP-02、RW-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")
}
}