package usecase_test import ( "context" "fmt" "strings" "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 } 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 } 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()) svc.SessionSecret = "test-crawler-session-secret" svc.Transport = tp svc.Settings = dev svc.Provider = fakeThreadSearchProvider{} svc.Crawler = fakeChromeCrawler{} 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 require.NoError(t, svc.SetCrawlerSession(context.Background(), uid, validStorageState())) 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) } 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}]}` } 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) _, err = svc.MarkPublished(context.Background(), uid, posts[0].ID) require.Error(t, err) } func TestSC_11_SendOutreach(t *testing.T) { svc, _, _ := newScout() uid := int64(5_002_011) 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.Error(t, err) } 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") } 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) }