package usecase_test import ( "context" "testing" "apps/backend/internal/module/scout/domain" "apps/backend/internal/module/scout/repository" "apps/backend/internal/module/scout/usecase" "github.com/stretchr/testify/require" ) type capturedSearchProvider struct { terms []string hits []usecase.ThreadSearchResult } func TestPrepareBriefPlansDeduplicatedTermsFromAllInputs(t *testing.T) { svc := usecase.New(repository.NewMemory()) brand, err := svc.CreateBrand(context.Background(), 1, "B", "") require.NoError(t, err) product, err := svc.SaveProduct(context.Background(), 1, &domain.Product{ BrandID: brand.ID, Label: "P", PainPoints: []string{"敏感肌", "敏感肌"}, MatchTags: []string{"保養", "敏感肌"}, }) require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 1, "敏感肌保養", brand.ID, product.ID, "value", false) require.NoError(t, err) require.Equal(t, []string{"敏感肌", "保養", "使用情境", "替代方案", "成分/規格", "敏感肌保養"}, brief.ScanTerms) } func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) { p.terms = append([]string(nil), terms...) return p.hits, nil } func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) { provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{ {URL: "https://www.threads.net/@a/post/1?utm_source=test", Snippet: "請問敏感肌怎麼舒緩?"}, {URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?"}, {URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠"}, {URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得"}, {URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮"}, }} store := repository.NewMemory() svc := usecase.New(store) svc.Provider = provider brief := &domain.RunBrief{ Mode: domain.ModeProduct, Intent: "敏感肌保養", Pains: []string{"敏感肌", "敏感肌"}, Tags: []string{"保養", "敏感肌"}, Periphery: []string{"使用情境", "保養"}, } brief.ScanTerms = []string{"敏感肌", "保養", "使用情境", "敏感肌保養"} posts, err := svc.RunScanFromBrief(context.Background(), 42, brief) require.NoError(t, err) require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.terms) require.Len(t, posts, 4) byClass := map[string]*domain.Post{} for _, post := range posts { byClass[post.Classification] = post require.NotEmpty(t, post.Permalink) require.Equal(t, post.Permalink, post.ExternalID) require.InDelta(t, 0, post.Score, 100) require.NotEmpty(t, post.MatchReason) } require.Contains(t, byClass, domain.ClassificationSeekingHelp) require.Contains(t, byClass, domain.ClassificationSeekingRecommendation) require.Contains(t, byClass, domain.ClassificationProviderOffer) require.Contains(t, byClass, domain.ClassificationDiscussion) require.Contains(t, byClass[domain.ClassificationSeekingHelp].MatchReason, "敏感肌") require.Equal(t, "https://www.threads.net/@a/post/1", byClass[domain.ClassificationSeekingHelp].Permalink) again, err := svc.RunScanFromBrief(context.Background(), 42, brief) require.NoError(t, err) require.Equal(t, byClass[domain.ClassificationSeekingHelp].ID, again[0].ID) persisted, err := svc.ListPosts(context.Background(), 42, "") require.NoError(t, err) require.Len(t, persisted, 4) } func TestPlannerActivityClassifiesWithNeutralRecency(t *testing.T) { provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{ {URL: "https://www.threads.net/@a/post/1", Snippet: "請問這週市集怎麼報名?"}, {URL: "https://www.threads.net/@b/post/2", Snippet: "市集活動公告,週六開幕"}, {URL: "https://www.threads.net/@c/post/3", Snippet: "市集攤位服務洽詢"}, {URL: "https://www.threads.net/@d/post/4", Snippet: "分享市集逛街心得"}, {URL: "https://www.threads.net/@e/post/5", Snippet: "市集 crypto 賺錢群組"}, }} svc := usecase.New(repository.NewMemory()) svc.Provider = provider brief := &domain.RunBrief{Mode: domain.ModeActivity, Intent: "市集", ScanTerms: []string{"市集"}} posts, err := svc.RunScanFromBrief(context.Background(), 7, brief) require.NoError(t, err) require.Len(t, posts, 4) classes := map[string]bool{} for _, post := range posts { classes[post.Classification] = true require.Contains(t, post.MatchReason, "recency unavailable (neutral)") require.Greater(t, post.Score, 0) } require.True(t, classes[domain.ClassificationAsking]) require.True(t, classes[domain.ClassificationAnnouncement]) require.True(t, classes[domain.ClassificationProviderOffer]) require.True(t, classes[domain.ClassificationDiscussion]) } func TestDraftOutreachIsDeterministicAndDoesNotNeedAI(t *testing.T) { svc := usecase.New(repository.NewMemory()) svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{ URL: "https://www.threads.net/@author/post/1", Snippet: "請問怎麼處理頭皮困擾?", }}} posts, err := svc.RunScanFromBrief(context.Background(), 9, &domain.RunBrief{ Mode: domain.ModeTheme, Intent: "頭皮", ScanTerms: []string{"頭皮"}, }) require.NoError(t, err) draft, err := svc.DraftOutreach(context.Background(), 9, posts[0].ID, "") require.NoError(t, err) require.Equal(t, "嗨 @author,看到你提到「頭皮」,我也遇過類似情況。若你願意,想聽聽你後來怎麼處理。", draft.DraftText) }