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)
|
|
|
|
|
}
|