thread-master/apps/backend/internal/module/radar/usecase/suggest_test.go

290 lines
9.1 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 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 metersource 標 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)
}
}