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