151 lines
4.5 KiB
Go
151 lines
4.5 KiB
Go
|
|
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"},
|
||
|
|
}
|
||
|
|
}
|