package usecase import ( "context" "errors" "fmt" "strings" "testing" "apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/repository" usageDomain "apps/backend/internal/module/usage/domain" usageRepo "apps/backend/internal/module/usage/repository" usageUC "apps/backend/internal/module/usage/usecase" ) type stubAI struct { reply string err error lastPrompt string calls int } func (s *stubAI) Complete(_ context.Context, _, _, prompt string) (string, error) { s.calls++ s.lastPrompt = prompt return s.reply, s.err } func (s *stubAI) CompleteStream(ctx context.Context, apiKey, model, prompt string, _ func(string) error) (string, error) { return s.Complete(ctx, apiKey, model, prompt) } func (s *stubAI) ListModels(context.Context, string) ([]string, error) { return nil, nil } type stubPainTerms struct { terms []string err error } func (s stubPainTerms) PainTerms(context.Context, int64) ([]string, error) { return s.terms, s.err } // platformUsage 給一個用平台點數的會員,這樣扣點與退點都真的會落到用量帳上。 func platformUsage(uid int64) *usageUC.Service { key := func(meter string) string { return fmt.Sprintf("%d:%s", uid, meter) } return usageUC.New(usageRepo.NewMemory(), &usageUC.StaticResolver{Map: map[string]string{ key(usageDomain.MeterAICopy): usageDomain.KeyModePlatform, key(usageDomain.MeterAIResearch): usageDomain.KeyModePlatform, }}) } func suggestService(t *testing.T, reply string) (*Service, *stubAI, context.Context) { t.Helper() ai := &stubAI{reply: reply} svc := New(repository.NewMemory()) svc.AI = ai ctx := context.Background() if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { t.Fatalf("seed profile: %v", err) } return svc, ai, ctx } const suggestReply = `[ {"term":"台北 婚攝 推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"}, {"term":"婚禮 攝影 價格","reason":"問價格通常已經在比較廠商","usage":"include"}, {"term":"徵 婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"} ]` // RW-03:有服務檔案就回得出建議,每則都要有理由。 func TestSuggestWatchTermsReturnsReasonedSuggestions(t *testing.T) { svc, ai, ctx := suggestService(t, suggestReply) list, err := svc.SuggestWatchTerms(ctx, 42, 0) if err != nil { t.Fatalf("suggest: %v", err) } if len(list) != 3 { t.Fatalf("got %d suggestions, want 3", len(list)) } for _, s := range list { if strings.TrimSpace(s.Reason) == "" { t.Fatalf("suggestion %q has no reason", s.Term) } if s.Usage != domain.SuggestUsageInclude && s.Usage != domain.SuggestUsageExclude { t.Fatalf("suggestion %q has usage %q", s.Term, s.Usage) } } if list[2].Usage != domain.SuggestUsageExclude { t.Fatalf("exclude suggestion lost its usage: %+v", list[2]) } // prompt 必須帶上服務檔案的內容,否則建議跟這個人的生意無關。 if !strings.Contains(ai.lastPrompt, "婚禮攝影") { t.Fatal("prompt missing the member's service items") } if !strings.Contains(ai.lastPrompt, "臺北市") { t.Fatal("prompt missing the member's service areas") } if !strings.Contains(ai.lastPrompt, "保證接到案") { t.Fatal("prompt missing the forbidden words") } } func TestSuggestDoesNotCreateWatches(t *testing.T) { svc, _, ctx := suggestService(t, suggestReply) if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil { t.Fatalf("suggest: %v", err) } _, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{}) if err != nil { t.Fatalf("list: %v", err) } if total != 0 { t.Fatalf("suggest created %d watches; it must only propose", total) } } // 沒有服務檔案就沒有依據,寧可明確拒絕也不要憑空猜關鍵字。 func TestSuggestRequiresServiceProfile(t *testing.T) { svc := New(repository.NewMemory()) ai := &stubAI{reply: suggestReply} svc.AI = ai _, err := svc.SuggestWatchTerms(context.Background(), 42, 0) 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) } if ai.calls != 0 { t.Fatal("AI was called without a service profile") } } func TestSuggestRespectsLimit(t *testing.T) { svc, ai, ctx := suggestService(t, suggestReply) list, err := svc.SuggestWatchTerms(ctx, 42, 2) if err != nil { t.Fatalf("suggest: %v", err) } if len(list) != 2 { t.Fatalf("got %d suggestions, want the requested 2", len(list)) } if !strings.Contains(ai.lastPrompt, "最多 2 則") { t.Fatal("limit was not passed to the prompt") } if _, err := svc.SuggestWatchTerms(ctx, 42, 999); err != nil { t.Fatalf("oversized limit should clamp, not fail: %v", err) } if !strings.Contains(ai.lastPrompt, "最多 20 則") { t.Fatalf("limit was not clamped to %d", domain.MaxSuggestions) } } func TestSuggestDropsUnusableItems(t *testing.T) { // 沒理由、太短、重複的項目都要丟掉,而不是補一句假理由湊數。 svc, _, ctx := suggestService(t, `[ {"term":"婚攝 推薦","reason":"在找攝影師的人常這樣問","usage":"include"}, {"term":"沒有理由的詞","reason":" ","usage":"include"}, {"term":"a","reason":"太短","usage":"include"}, {"term":"婚攝 推薦","reason":"重複","usage":"include"} ]`) list, err := svc.SuggestWatchTerms(ctx, 42, 0) if err != nil { t.Fatalf("suggest: %v", err) } if len(list) != 1 || list[0].Term != "婚攝 推薦" { t.Fatalf("got %+v, want only the one usable suggestion", list) } } // 模型愛加開場白或 code fence,這種回應也要吃得下。 func TestSuggestToleratesProseAroundJSON(t *testing.T) { svc, _, ctx := suggestService(t, "好的,以下是建議:\n```json\n"+suggestReply+"\n```\n希望有幫助!") list, err := svc.SuggestWatchTerms(ctx, 42, 0) if err != nil { t.Fatalf("suggest: %v", err) } if len(list) != 3 { t.Fatalf("got %d suggestions from a fenced reply, want 3", len(list)) } } // 解析不出任何東西時要報錯:空清單會被讀成「你的服務沒有關鍵字可監控」。 func TestSuggestFailsLoudlyOnUnusableReply(t *testing.T) { svc, _, ctx := suggestService(t, "我不知道要建議什麼") _, err := svc.SuggestWatchTerms(ctx, 42, 0) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation", err) } } func TestSuggestSurfacesAIFailure(t *testing.T) { svc, ai, ctx := suggestService(t, "") ai.err = errors.New("provider down") if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil { t.Fatal("AI failure was swallowed") } } func TestSuggestUsesPainTermsAsMaterialWhenAvailable(t *testing.T) { svc, ai, ctx := suggestService(t, suggestReply) svc.PainTerms = stubPainTerms{terms: []string{"找不到有檔期的攝影師"}} if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil { t.Fatalf("suggest: %v", err) } if !strings.Contains(ai.lastPrompt, "找不到有檔期的攝影師") { t.Fatal("existing pain terms were not used as prompt material") } // 素材拿不到只影響品質,不該讓整個請求失敗。 svc.PainTerms = stubPainTerms{err: errors.New("scout unavailable")} if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil { t.Fatalf("pain term failure must not break suggest: %v", err) } } /* 計費對照(spec §5.5):走既有 ai_copy meter,source 標 radar.suggest。 source 前綴是 P1 價格校準的唯一資料來源,標錯就等於這次雷達的成本無法歸因。 */ func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) { svc, _, ctx := suggestService(t, suggestReply) usage := platformUsage(42) svc.Usage = usage if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil { t.Fatalf("suggest: %v", err) } events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0) if err != nil { t.Fatalf("list events: %v", err) } if len(events) != 1 { t.Fatalf("got %d usage events, want exactly 1", len(events)) } if events[0].Meter != usageDomain.MeterAICopy { t.Fatalf("meter = %q, want %q", events[0].Meter, usageDomain.MeterAICopy) } if events[0].Source != "radar.suggest" { t.Fatalf("source = %q, want radar.suggest", events[0].Source) } } // AI 失敗要退點:扣了點卻沒拿到東西是最難解釋的帳。 func TestSuggestReleasesCreditWhenAIFails(t *testing.T) { svc, ai, ctx := suggestService(t, "") ai.err = errors.New("provider down") usage := platformUsage(42) svc.Usage = usage if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err == nil { t.Fatal("AI failure was swallowed") } events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0) if err != nil { t.Fatalf("list events: %v", err) } if len(events) != 0 { t.Fatalf("charged %d events for a failed call", len(events)) } } func TestSuggestWithoutAIClientAsksForKey(t *testing.T) { svc := New(repository.NewMemory()) ctx := context.Background() if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil { t.Fatalf("seed profile: %v", err) } _, err := svc.SuggestWatchTerms(ctx, 42, 0) if !errors.Is(err, domain.ErrValidation) { t.Fatalf("err = %v, want ErrValidation pointing at the AI key", err) } }