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

284 lines
9.8 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
package usecase_test
import (
"context"
2026-07-13 08:59:13 +00:00
"fmt"
"strings"
2026-07-13 01:15:30 +00:00
"testing"
"apps/backend/internal/module/scout/domain"
"apps/backend/internal/module/scout/repository"
"apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish"
"github.com/stretchr/testify/require"
)
type staticDevMode struct {
on bool
}
2026-07-13 08:59:13 +00:00
type fakeThreadSearchProvider struct{}
type fakeChromeCrawler struct{ fakeThreadSearchProvider }
type failingChromeCrawler struct{ fakeChromeCrawler }
type fakeReplyQueue struct{}
func (fakeThreadSearchProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]usecase.ThreadSearchResult, error) {
results := make([]usecase.ThreadSearchResult, 0, limit)
for i, term := range terms {
if i == limit {
break
}
results = append(results, usecase.ThreadSearchResult{
URL: "https://www.threads.net/@test_user/post/" + term,
Title: term,
Snippet: "realistic result for " + term,
})
}
return results, nil
}
func (f fakeChromeCrawler) SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]usecase.ThreadSearchResult, error) {
if strings.TrimSpace(storageState) == "" {
return nil, domain.ErrNoCrawlerSession
}
return f.SearchThreads(ctx, terms, limit)
}
func (fakeChromeCrawler) ResolveMediaID(_ context.Context, _ string, _ string) (string, error) {
return "123456789012345", nil
}
func (failingChromeCrawler) ResolveMediaID(_ context.Context, _ string, _ string) (string, error) {
return "", fmt.Errorf("crawler session expired")
}
func (fakeReplyQueue) QueueExternalReply(_ context.Context, _ int64, _, _, _, _ string) (string, error) {
return "outbox_test", nil
}
2026-07-13 01:15:30 +00:00
func (s *staticDevMode) DevModeEnabled(_ context.Context, _ int64) (bool, error) {
return s.on, nil
}
func newScout() (*usecase.Service, *studioPublish.FakeTransport, *staticDevMode) {
tp := studioPublish.NewFake()
dev := &staticDevMode{}
svc := usecase.New(repository.NewMemory())
2026-07-13 08:59:13 +00:00
svc.SessionSecret = "test-crawler-session-secret"
2026-07-13 01:15:30 +00:00
svc.Transport = tp
svc.Settings = dev
2026-07-13 08:59:13 +00:00
svc.Provider = fakeThreadSearchProvider{}
svc.Crawler = fakeChromeCrawler{}
2026-07-13 01:15:30 +00:00
return svc, tp, dev
}
func TestSC_01_CreateBrandProduct(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_001)
b, err := svc.CreateBrand(context.Background(), uid, "海港", "敏感肌")
require.NoError(t, err)
p, err := svc.SaveProduct(context.Background(), uid, &domain.Product{
BrandID: b.ID, Label: "無香洗髮", PainPoints: []string{"刺鼻"}, MatchTags: []string{"洗髮"},
})
require.NoError(t, err)
list, err := svc.ListProducts(context.Background(), uid, b.ID)
require.NoError(t, err)
require.Len(t, list, 1)
require.Equal(t, p.ID, list[0].ID)
}
func TestSC_02_DeleteBrandWithProductsRefused(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_002)
b, _ := svc.CreateBrand(context.Background(), uid, "B", "")
_, _ = svc.SaveProduct(context.Background(), uid, &domain.Product{BrandID: b.ID, Label: "P"})
err := svc.RemoveBrand(context.Background(), uid, b.ID)
require.ErrorIs(t, err, domain.ErrHasProducts)
}
func TestSC_03_PrepareBrief(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_003)
brief, err := svc.PrepareBrief(context.Background(), uid, "敏感肌保養", "", "", "value", false)
require.NoError(t, err)
require.NotEmpty(t, brief.ScanTerms)
require.Equal(t, "敏感肌保養", brief.Intent)
}
func TestSC_04_PrepareBriefEmptyIntent(t *testing.T) {
svc, _, _ := newScout()
_, err := svc.PrepareBrief(context.Background(), 1, " ", "", "", "", false)
require.Error(t, err)
}
func TestSC_05_ApiPath(t *testing.T) {
svc, _, dev := newScout()
uid := int64(5_002_005)
dev.on = false
brief, _ := svc.PrepareBrief(context.Background(), uid, "遠端辦公", "", "", "value", false)
posts, err := svc.RunScanFromBrief(context.Background(), uid, brief)
require.NoError(t, err)
require.NotEmpty(t, posts)
require.Equal(t, domain.PathAPI, posts[0].ScanPath)
}
func TestSC_06_CrawlerPath(t *testing.T) {
svc, _, dev := newScout()
uid := int64(5_002_006)
dev.on = true
2026-07-13 08:59:13 +00:00
require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, validStorageState()))
2026-07-13 01:15:30 +00:00
brief, _ := svc.PrepareBrief(context.Background(), uid, "成分黨", "", "", "value", false)
posts, err := svc.RunScanFromBrief(context.Background(), uid, brief)
require.NoError(t, err)
require.Equal(t, domain.PathCrawler, posts[0].ScanPath)
}
func TestSC_07_NoSession(t *testing.T) {
svc, _, dev := newScout()
uid := int64(5_002_007)
dev.on = true
brief, _ := svc.PrepareBrief(context.Background(), uid, "無 session", "", "", "value", false)
_, err := svc.RunScanFromBrief(context.Background(), uid, brief)
require.ErrorIs(t, err, domain.ErrNoCrawlerSession)
}
2026-07-13 08:59:13 +00:00
func TestCrawlerSessionEncryptedAndValidated(t *testing.T) {
ctx := context.Background()
store := repository.NewMemory()
svc := usecase.New(store)
svc.SessionSecret = "test-crawler-session-secret"
state := validStorageState()
require.NoError(t, svc.SetCrawlerSession(ctx, 1, state))
persisted, err := store.GetCrawlerSession(ctx, 1)
require.NoError(t, err)
require.NotEqual(t, state, persisted.StorageStateEnc)
require.Positive(t, persisted.UpdatedAt)
require.Positive(t, persisted.ExpiresAt)
got, err := svc.GetCrawlerSessionToken(ctx, 1)
require.NoError(t, err)
require.Equal(t, state, got)
for _, invalid := range []string{
"not json",
"[]",
`{"cookies":[]}`,
`{"cookies":[{"domain":"evil.example","expires":4102444800}]}`,
`{"cookies":null}`,
strings.Repeat("x", 256*1024+1),
} {
require.ErrorIs(t, svc.SetCrawlerSession(ctx, 1, invalid), domain.ErrValidation)
}
}
func TestCrawlerSessionRequiresSecret(t *testing.T) {
svc := usecase.New(repository.NewMemory())
require.ErrorIs(t, svc.SetCrawlerSession(context.Background(), 1, validStorageState()), domain.ErrValidation)
}
func validStorageState() string {
return `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`
}
2026-07-13 01:15:30 +00:00
func TestSC_08_ListPostsAfterScan(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_008)
brief, _ := svc.PrepareBrief(context.Background(), uid, "list test", "", "", "value", false)
_, _ = svc.RunScanFromBrief(context.Background(), uid, brief)
list, err := svc.ListPosts(context.Background(), uid, "")
require.NoError(t, err)
require.NotEmpty(t, list)
require.True(t, list[0].CreatedAt > 0)
}
func TestSC_09_DraftOutreach(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_009)
brief, _ := svc.PrepareBrief(context.Background(), uid, "draft", "", "", "value", false)
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
p, err := svc.DraftOutreach(context.Background(), uid, posts[0].ID, "")
require.NoError(t, err)
require.Equal(t, domain.OutreachDrafted, p.OutreachStatus)
require.NotEmpty(t, p.DraftText)
}
func TestSC_10_SkipAndMark(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_010)
brief, _ := svc.PrepareBrief(context.Background(), uid, "skip", "", "", "value", false)
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID)
require.NoError(t, err)
require.Equal(t, domain.OutreachSkipped, p.OutreachStatus)
2026-07-13 08:59:13 +00:00
_, err = svc.MarkPublished(context.Background(), uid, posts[0].ID)
require.Error(t, err)
2026-07-13 01:15:30 +00:00
}
func TestSC_11_SendOutreach(t *testing.T) {
2026-07-13 08:59:13 +00:00
svc, _, _ := newScout()
2026-07-13 01:15:30 +00:00
uid := int64(5_002_011)
brief, _ := svc.PrepareBrief(context.Background(), uid, "send", "", "", "value", false)
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
2026-07-13 08:59:13 +00:00
_, err := svc.SendOutreach(context.Background(), uid, posts[0].ID, "你好呀", "acc1")
2026-07-13 01:15:30 +00:00
require.Error(t, err)
2026-07-13 08:59:13 +00:00
}
func TestSC_11_SendOutreachResolverFailureIsValidation(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_012)
svc.Crawler = failingChromeCrawler{}
svc.ReplyQueue = fakeReplyQueue{}
require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, validStorageState()))
brief, _ := svc.PrepareBrief(context.Background(), uid, "send", "", "", "value", false)
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
_, err := svc.SendOutreach(context.Background(), uid, posts[0].ID, "你好呀", "acc1")
require.ErrorIs(t, err, domain.ErrValidation)
require.ErrorContains(t, err, "unable to resolve the target Threads post")
2026-07-13 01:15:30 +00:00
}
func TestSC_12_RemovePostTheme(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_012)
brief, _ := svc.PrepareBrief(context.Background(), uid, "rm", "", "", "value", false)
posts, _ := svc.RunScanFromBrief(context.Background(), uid, brief)
require.NoError(t, svc.RemovePost(context.Background(), uid, posts[0].ID))
require.NoError(t, svc.RemoveTheme(context.Background(), uid, brief.ThemeKey))
list, _ := svc.ListPosts(context.Background(), uid, "")
require.Empty(t, list)
}
func TestSC_13_Homework(t *testing.T) {
svc, _, _ := newScout()
uid := int64(5_002_013)
brief, _ := svc.PrepareBrief(context.Background(), uid, "hw", "", "", "value", false)
h, err := svc.SaveHomework(context.Background(), uid, &domain.Homework{
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, Purpose: "value", Brief: *brief,
})
require.NoError(t, err)
list, err := svc.ListHomework(context.Background(), uid)
require.NoError(t, err)
require.Len(t, list, 1)
got, err := svc.GetHomework(context.Background(), uid, h.ThemeKey)
require.NoError(t, err)
require.Equal(t, brief.Intent, got.Brief.Intent)
require.NoError(t, svc.RemoveHomework(context.Background(), uid, h.ThemeKey))
}
func TestSC_14_ImportProductFromUrl(t *testing.T) {
svc, _, _ := newScout()
d, err := svc.ImportProductFromURL(context.Background(), "https://shop.example.com/item/1")
require.NoError(t, err)
require.NotEmpty(t, d.Label)
require.Equal(t, "https://shop.example.com/item/1", d.PlacementURL)
}
func TestSC_TopicRemoved(t *testing.T) {
svc, _, _ := newScout()
require.ErrorIs(t, svc.TopicRemoved(), domain.ErrTopicRemoved)
}