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 { // queries records each fan-out call (one term per call) queries []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) // 每條是獨立 query;含 intent/痛點/標籤與求助 variant;不含 periphery require.Equal(t, []string{"敏感肌保養", "敏感肌", "保養", "敏感肌 求推薦"}, brief.ScanTerms) require.NotContains(t, brief.ScanTerms, "使用情境") } func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) { if len(terms) > 0 { p.queries = append(p.queries, terms[0]) } 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: "請問敏感肌怎麼舒緩?", PublishedAt: 300}, {URL: "https://www.threads.net/@b/post/2", Snippet: "敏感肌保養有沒有推薦?", PublishedAt: 200}, {URL: "https://www.threads.net/@c/post/3", Snippet: "敏感肌服務洽詢,現在限時優惠", PublishedAt: 100}, {URL: "https://www.threads.net/@d/post/4", Snippet: "分享敏感肌保養的使用心得", PublishedAt: 50}, {URL: "https://www.threads.net/@e/post/5", Snippet: "敏感肌抽獎,互追拿好禮", PublishedAt: 10}, }} 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) // 多 query 扇出:每條 scan_term 各搜一次 require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.queries) // product 模式略過 provider_offer(硬廣),noise 也略過 → 剩 3 require.Len(t, posts, 3) // score 主排序;發文時間只在同分時決勝 require.Equal(t, "https://www.threads.net/@b/post/2", posts[0].Permalink) require.Equal(t, int64(200), posts[0].PostedAt) 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.NotEqual(t, domain.ClassificationProviderOffer, post.Classification) } require.Contains(t, byClass, domain.ClassificationSeekingHelp) require.Contains(t, byClass, domain.ClassificationSeekingRecommendation) 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.ClassificationSeekingRecommendation].ID, again[0].ID) persisted, err := svc.ListPosts(context.Background(), 42, "") require.NoError(t, err) require.Len(t, persisted, 3) } 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 TestProviderScoutFindsProvenSolversAndExcludesSameCategory(t *testing.T) { provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{ {URL: "https://www.threads.net/@doctor/post/1", Snippet: "刺鼻頭皮問題可由皮膚科專業諮詢協助", PublishedAt: 300}, {URL: "https://www.threads.net/@friend/post/2", Snippet: "推薦皮膚科處理刺鼻頭皮問題,有完整案例", PublishedAt: 200}, {URL: "https://www.threads.net/@seller/post/3", Snippet: "刺鼻頭皮洗髮精,皮膚科配方限時優惠", PublishedAt: 100}, {URL: "https://www.threads.net/@chat/post/4", Snippet: "刺鼻頭皮真的很困擾", PublishedAt: 50}, }} svc := usecase.New(repository.NewMemory()) svc.Provider = provider brand, err := svc.CreateBrand(context.Background(), 11, "B", "") require.NoError(t, err) p, err := svc.SaveProduct(context.Background(), 11, &domain.Product{ BrandID: brand.ID, Label: "無香洗髮", PainPoints: []string{"刺鼻頭皮"}, ProviderCapabilityTerms: []string{"皮膚科"}, ProviderExcludeTerms: []string{"洗髮精"}, }) require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 11, "", brand.ID, p.ID, "provider", false) require.NoError(t, err) require.Equal(t, domain.ModeProvider, brief.Mode) require.NotContains(t, brief.ScanTerms, p.Label) posts, err := svc.RunScanFromBrief(context.Background(), 11, brief) require.NoError(t, err) require.Len(t, posts, 2) require.Equal(t, domain.ClassificationProviderRecommended, posts[0].Classification) require.Equal(t, domain.ClassificationProviderDirect, posts[1].Classification) } func TestDemandScoutKeepsHelpSignalsWithoutProviderTerms(t *testing.T) { provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{ {URL: "https://www.threads.net/@need/post/1", Snippet: "頭皮一直發癢怎麼辦?"}, {URL: "https://www.threads.net/@need/post/2", Snippet: "敏感頭皮洗髮精有沒有推薦"}, {URL: "https://www.threads.net/@seller/post/3", Snippet: "頭皮發癢洗髮精限時優惠"}, {URL: "https://www.threads.net/@chat/post/4", Snippet: "頭皮發癢的季節又到了"}, }} svc := usecase.New(repository.NewMemory()) svc.Provider = provider brand, err := svc.CreateBrand(context.Background(), 15, "B", "") require.NoError(t, err) p, err := svc.SaveProduct(context.Background(), 15, &domain.Product{ BrandID: brand.ID, Label: "舒緩洗髮", PainPoints: []string{"頭皮發癢", "敏感頭皮"}, }) require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 15, "", brand.ID, p.ID, "demand", false) require.NoError(t, err) require.Equal(t, domain.ModeDemand, brief.Mode) require.Contains(t, brief.ScanTerms, "頭皮發癢 怎麼辦") posts, err := svc.RunScanFromBrief(context.Background(), 15, brief) require.NoError(t, err) require.Len(t, posts, 2) require.Equal(t, domain.ClassificationSeekingRecommendation, posts[0].Classification) require.Equal(t, domain.ClassificationSeekingHelp, posts[1].Classification) } func TestProviderScoutPlansShortHighIntentQueries(t *testing.T) { svc := usecase.New(repository.NewMemory()) brand, err := svc.CreateBrand(context.Background(), 14, "B", "") require.NoError(t, err) p, err := svc.SaveProduct(context.Background(), 14, &domain.Product{ BrandID: brand.ID, Label: "無香洗髮", PainPoints: []string{ "最近頭皮又乾又癢不知道怎麼辦", }, ProviderCapabilityTerms: []string{ "皮膚科敏感頭皮專業諮詢服務", }, }) require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 14, "", brand.ID, p.ID, "provider", false) require.NoError(t, err) require.NotEmpty(t, brief.ScanTerms) for _, term := range brief.ScanTerms { require.LessOrEqual(t, len([]rune(term)), 24) require.NotContains(t, term, "不知道怎麼辦") } require.Contains(t, brief.ScanTerms, "最近頭皮又乾又癢 推薦") require.Contains(t, brief.ScanTerms, "皮膚科敏感頭皮專業諮詢服務 推薦") } func TestProviderScoutUsesExistingMatchTagsWhenProviderFieldsAreEmpty(t *testing.T) { provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{ {URL: "https://www.threads.net/@doctor/post/1", Snippet: "頭皮刺鼻問題可由皮膚科專業諮詢協助"}, {URL: "https://www.threads.net/@seller/post/2", Snippet: "頭皮刺鼻問題,皮膚科配方限時優惠"}, }} svc := usecase.New(repository.NewMemory()) svc.Provider = provider brand, err := svc.CreateBrand(context.Background(), 12, "B", "") require.NoError(t, err) p, err := svc.SaveProduct(context.Background(), 12, &domain.Product{ BrandID: brand.ID, Label: "無香洗髮", PainPoints: []string{"頭皮刺鼻"}, MatchTags: []string{"皮膚科"}, }) require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 12, "", brand.ID, p.ID, "provider", false) require.NoError(t, err) require.Equal(t, []string{"皮膚科"}, brief.Tags) require.Contains(t, brief.Periphery, p.Label) posts, err := svc.RunScanFromBrief(context.Background(), 12, brief) require.NoError(t, err) require.Len(t, posts, 1) require.Equal(t, domain.ClassificationProviderDirect, posts[0].Classification) } func TestProviderScoutFallsBackToProductLabel(t *testing.T) { svc := usecase.New(repository.NewMemory()) svc.Provider = &capturedSearchProvider{hits: []usecase.ThreadSearchResult{{ URL: "https://www.threads.net/@expert/post/1", Snippet: "無香洗髮專業諮詢協助", }}} brand, err := svc.CreateBrand(context.Background(), 13, "B", "") require.NoError(t, err) p, err := svc.SaveProduct(context.Background(), 13, &domain.Product{BrandID: brand.ID, Label: "無香洗髮"}) require.NoError(t, err) brief, err := svc.PrepareBrief(context.Background(), 13, "", brand.ID, p.ID, "provider", false) require.NoError(t, err) require.NotEmpty(t, brief.ScanTerms) posts, err := svc.RunScanFromBrief(context.Background(), 13, brief) require.NoError(t, err) require.Len(t, posts, 1) } 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) }