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"}, } }