2026-07-13 08:59:13 +00:00
|
|
|
|
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 {
|
2026-08-03 07:58:07 +00:00
|
|
|
|
// queries records each fan-out call (one term per call)
|
|
|
|
|
|
queries []string
|
|
|
|
|
|
hits []usecase.ThreadSearchResult
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-03 07:58:07 +00:00
|
|
|
|
// 每條是獨立 query;含 intent/痛點/標籤與求助 variant;不含 periphery
|
|
|
|
|
|
require.Equal(t, []string{"敏感肌保養", "敏感肌", "保養", "敏感肌 求推薦"}, brief.ScanTerms)
|
|
|
|
|
|
require.NotContains(t, brief.ScanTerms, "使用情境")
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (p *capturedSearchProvider) SearchThreads(_ context.Context, terms []string, _ int) ([]usecase.ThreadSearchResult, error) {
|
2026-08-03 07:58:07 +00:00
|
|
|
|
if len(terms) > 0 {
|
|
|
|
|
|
p.queries = append(p.queries, terms[0])
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
return p.hits, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestPlannerProductSignalsAndDeterministicPersistence(t *testing.T) {
|
|
|
|
|
|
provider := &capturedSearchProvider{hits: []usecase.ThreadSearchResult{
|
2026-08-03 07:58:07 +00:00
|
|
|
|
{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},
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}}
|
|
|
|
|
|
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)
|
2026-08-03 07:58:07 +00:00
|
|
|
|
// 多 query 扇出:每條 scan_term 各搜一次
|
|
|
|
|
|
require.Equal(t, []string{"敏感肌", "保養", "使用情境", "敏感肌保養"}, provider.queries)
|
|
|
|
|
|
// product 模式略過 provider_offer(硬廣),noise 也略過 → 剩 3
|
|
|
|
|
|
require.Len(t, posts, 3)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
// score 主排序;發文時間只在同分時決勝
|
|
|
|
|
|
require.Equal(t, "https://www.threads.net/@b/post/2", posts[0].Permalink)
|
|
|
|
|
|
require.Equal(t, int64(200), posts[0].PostedAt)
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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)
|
2026-08-03 07:58:07 +00:00
|
|
|
|
require.NotEqual(t, domain.ClassificationProviderOffer, post.Classification)
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
require.Equal(t, byClass[domain.ClassificationSeekingRecommendation].ID, again[0].ID)
|
2026-07-13 08:59:13 +00:00
|
|
|
|
persisted, err := svc.ListPosts(context.Background(), 42, "")
|
|
|
|
|
|
require.NoError(t, err)
|
2026-08-03 07:58:07 +00:00
|
|
|
|
require.Len(t, persisted, 3)
|
2026-07-13 08:59:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-06 14:16:49 +00:00
|
|
|
|
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)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
require.Equal(t, domain.ClassificationProviderRecommended, posts[0].Classification)
|
|
|
|
|
|
require.Equal(t, domain.ClassificationProviderDirect, posts[1].Classification)
|
2026-08-06 14:16:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
require.Equal(t, domain.ClassificationSeekingRecommendation, posts[0].Classification)
|
|
|
|
|
|
require.Equal(t, domain.ClassificationSeekingHelp, posts[1].Classification)
|
2026-08-06 14:16:49 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|