thread-master/apps/backend/internal/module/radar/usecase/explore_test.go

151 lines
4.5 KiB
Go
Raw Normal View History

2026-08-09 07:57:35 +00:00
package usecase
import (
"context"
"errors"
"testing"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/module/radar/repository"
)
func exploreSvc(t *testing.T) (*Service, context.Context) {
t.Helper()
svc := New(repository.NewMemory())
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
ctx := context.Background()
if _, err := svc.UpsertServiceProfile(ctx, 7, sampleProfile()); err != nil {
t.Fatalf("seed profile: %v", err)
}
return svc, ctx
}
func TestExploreRejectsBadTerms(t *testing.T) {
svc, ctx := exploreSvc(t)
cases := [][]string{
nil,
{},
{"台北 婚攝 推薦 價格"}, // too many tokens
{"ok", "ok2", "ok3", "ok4", "ok5", "ok6", "ok7"}, // >6
{"a"},
}
for _, terms := range cases {
_, err := svc.ExploreOpportunities(ctx, 7, terms)
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("terms=%v err=%v, want ErrValidation", terms, err)
}
}
}
func TestExploreFanOutHitsAndCreates(t *testing.T) {
svc, ctx := exploreSvc(t)
calls := 0
svc.HitFetch = HitFetcherFunc(func(_ context.Context, _ int64, terms []string, limit int) ([]ThreadHit, string, error) {
calls++
var hits []ThreadHit
for i, term := range terms {
// unique URLs so each term can produce a create
hits = append(hits, ThreadHit{
URL: "https://www.threads.net/@u/post/e" + string(rune('a'+i)),
Title: term,
Snippet: "求推薦 " + term + ",有人知道嗎?怎麼辦",
})
}
if len(hits) > limit {
hits = hits[:limit]
}
return hits, domain.SweepPathAPI, nil
})
res, err := svc.ExploreOpportunities(ctx, 7, []string{"保母 求推薦", "到府保母"})
if err != nil {
t.Fatalf("explore: %v", err)
}
if calls != 1 {
t.Fatalf("HitFetch calls=%d want 1", calls)
}
if res.HitCount != 2 {
t.Fatalf("hit_count=%d want 2", res.HitCount)
}
if res.SweepID == "" {
t.Fatal("missing sweep_id")
}
if res.JudgedCount < 1 {
t.Fatalf("judged=%d want >=1", res.JudgedCount)
}
if res.CreatedCount < 1 {
t.Fatalf("created=%d want >=1", res.CreatedCount)
}
}
func TestExploreTruncatesWhenQuotaZero(t *testing.T) {
svc, ctx := exploreSvc(t)
max, err := svc.MaxDailyOpportunities(ctx, 7)
if err != nil {
t.Fatalf("max: %v", err)
}
now := domain.NowNano()
for i := 0; i < max; i++ {
id := domain.NewID()
o := &domain.Opportunity{
ID: id, OwnerUID: 7, Source: domain.OppSourceThreads,
ExternalID: "https://x/" + id, Permalink: "https://x/" + id,
AuthorHandle: "u", Text: "dummy", PostedAt: now,
Status: domain.OppQualified, IntentScore: 80, IntentBand: domain.BandHigh,
Reasons: sampleExploreReasons(), RegionMatch: domain.RegionUnknown, FreshnessHours: 1,
CreatedAt: now, UpdatedAt: now,
}
if _, err := svc.Repo.UpsertByExternalID(ctx, o); err != nil {
t.Fatalf("seed opp: %v", err)
}
}
svc.HitFetch = HitFetcherFunc(func(_ context.Context, _ int64, _ []string, _ int) ([]ThreadHit, string, error) {
return []ThreadHit{{
URL: "https://www.threads.net/@u/post/new1", Title: "t",
Snippet: "求推薦 到府保母,有人知道嗎?",
}}, domain.SweepPathAPI, nil
})
res, err := svc.ExploreOpportunities(ctx, 7, []string{"保母 求推薦"})
if err != nil {
t.Fatalf("explore: %v", err)
}
if res.CreatedCount != 0 {
t.Fatalf("created=%d want 0 when quota full", res.CreatedCount)
}
if res.TruncatedCount < 1 {
t.Fatalf("truncated=%d want >=1", res.TruncatedCount)
}
}
func TestExploreJudgeFailureTolerated(t *testing.T) {
// Even when AI is down, heuristic judge should still work and explore must not crash.
svc, ctx := exploreSvc(t)
svc.HitFetch = HitFetcherFunc(func(_ context.Context, _ int64, _ []string, _ int) ([]ThreadHit, string, error) {
return []ThreadHit{{
URL: "https://www.threads.net/@u/post/jfail", Title: "t",
Snippet: "求推薦 保母 有人知道嗎",
}}, domain.SweepPathAPI, nil
})
svc.AI = &stubAI{err: errors.New("ai down")}
res, err := svc.ExploreOpportunities(ctx, 7, []string{"保母 求推薦"})
if err != nil {
t.Fatalf("explore must tolerate AI failure via heuristic: %v", err)
}
if res.JudgedCount < 1 {
t.Fatalf("judged=%d want >=1", res.JudgedCount)
}
}
func sampleExploreReasons() []domain.OpportunityReason {
return []domain.OpportunityReason{
{Dimension: domain.DimAuthenticity, Score: 20, Reason: "ok"},
{Dimension: domain.DimIntent, Score: 20, Reason: "ok"},
{Dimension: domain.DimRegion, Score: 10, Reason: "ok"},
{Dimension: domain.DimFreshness, Score: 15, Reason: "ok"},
{Dimension: domain.DimFit, Score: 15, Reason: "ok"},
}
}