thread-master/apps/backend/internal/module/scout/usecase/planner_test.go

131 lines
6.0 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_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)
// 依發文時間新→舊
require.Equal(t, "https://www.threads.net/@a/post/1", posts[0].Permalink)
require.Equal(t, int64(300), 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.ClassificationSeekingHelp].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 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)
}