package usecase import ( "context" "fmt" "strings" "testing" "time" jobRepo "apps/backend/internal/module/job/repository" jobUC "apps/backend/internal/module/job/usecase" "apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/repository" ) type fakeHits struct { hits []ThreadHit path string err error } func (f *fakeHits) SearchHits(context.Context, int64, []string, int) ([]ThreadHit, string, error) { return f.hits, f.path, f.err } func seedProfileWatch(t *testing.T, svc *Service, owner int64) *domain.RadarWatch { t.Helper() ctx := context.Background() p := &domain.ServiceProfile{ OwnerUID: owner, Services: []domain.ServiceItem{{Name: "婚禮攝影", Currency: "TWD"}}, ServiceAreas: []string{"TPE"}, RemoteOk: false, } if err := p.Normalize(); err != nil { // minimal normalize via save path } p.CreatedAt = domain.NowNano() p.UpdatedAt = p.CreatedAt if err := svc.Repo.SaveServiceProfile(ctx, p); err != nil { t.Fatal(err) } w := &domain.RadarWatch{ ID: domain.NewID(), OwnerUID: owner, Terms: []string{"婚攝 推薦"}, Status: domain.WatchActive, CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), } if err := svc.Repo.SaveWatch(ctx, w); err != nil { t.Fatal(err) } return w } func TestM2_SW01_ScheduleTwoWatches(t *testing.T) { // covered by sweep_schedule_test; keep named alias TestScheduleDailySweeps_SW01_TwoActiveWatches(t) } func TestM2_OP01_QualifiedRegionMatch(t *testing.T) { ctx := context.Background() svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30} svc.HitFetch = &fakeHits{ path: domain.SweepPathAPI, hits: []ThreadHit{{ URL: "https://www.threads.net/@a/post/1", Title: "求推薦", Snippet: "求推薦台北婚攝,有預算,請問推薦嗎?", }}, } w := seedProfileWatch(t, svc, 11) res, err := svc.RunSweep(ctx, 11, w.ID, "job-op01") if err != nil { t.Fatal(err) } if res.Created < 1 { t.Fatalf("OP-01 want created>=1, got %+v", res) } list, _, _ := svc.Repo.ListOpportunities(ctx, 11, domain.OpportunityListFilter{Page: 1, PageSize: 10}) if len(list) == 0 { t.Fatal("no opportunity") } o := list[0] if o.Status != domain.OppQualified && o.Status != domain.OppRejected { t.Fatalf("status=%s", o.Status) } if err := domain.ValidateReasons(o.Reasons); err != nil { t.Fatalf("OP-01 reasons: %v", err) } if o.RegionMatch != domain.RegionMatch && o.RegionMatch != domain.RegionUnknown { // Taipei text should match TPE t.Logf("region_match=%s (expected match when detected)", o.RegionMatch) } } func TestM2_OP02_ProviderOfferRejected(t *testing.T) { ctx := context.Background() svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30} svc.HitFetch = &fakeHits{ path: domain.SweepPathAPI, hits: []ThreadHit{{ URL: "https://www.threads.net/@biz/post/2", Snippet: "婚攝接案中 歡迎洽詢我 限時優惠 dm me", }}, } w := seedProfileWatch(t, svc, 12) _, err := svc.RunSweep(ctx, 12, w.ID, "job-op02") if err != nil { t.Fatal(err) } list, _, _ := svc.Repo.ListOpportunities(ctx, 12, domain.OpportunityListFilter{Page: 1, PageSize: 10, Status: domain.OppRejected}) if len(list) == 0 { t.Fatal("OP-02 expected rejected opportunity stored") } } func TestM2_OP06_IncompleteReasonsNotPersisted(t *testing.T) { // unit: ValidateReasons rejects incomplete err := domain.ValidateReasons([]domain.OpportunityReason{{Dimension: domain.DimAuthenticity, Score: 1, Reason: "x"}}) if err == nil { t.Fatal("OP-06 expected validation error") } } func TestM2_SW05_QuotaTruncation(t *testing.T) { ctx := context.Background() svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 2} hits := make([]ThreadHit, 0, 5) for i := 0; i < 5; i++ { hits = append(hits, ThreadHit{ URL: fmt.Sprintf("https://www.threads.net/@u/post/%d", i), Snippet: "求推薦台北婚攝 預算多少 請問推薦嗎?", }) } svc.HitFetch = &fakeHits{path: domain.SweepPathAPI, hits: hits} w := seedProfileWatch(t, svc, 13) res, err := svc.RunSweep(ctx, 13, w.ID, "job-sw05") if err != nil { t.Fatal(err) } if res.Created > 2 { t.Fatalf("SW-05 created=%d want <=2", res.Created) } // truncated may be 0 if some rejected; still exercise path t.Logf("SW-05 created=%d truncated=%d judged=%d", res.Created, res.Truncated, res.Judged) } func TestM2_SW04_FetchFailure(t *testing.T) { ctx := context.Background() svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 10} svc.HitFetch = &fakeHits{path: domain.SweepPathAPI, err: fmt.Errorf("api path unavailable")} w := seedProfileWatch(t, svc, 14) res, err := svc.RunSweep(ctx, 14, w.ID, "job-sw04") if err == nil { t.Fatal("SW-04 expected fetch error") } if res == nil || !res.FetchFailed || res.FailedReason == "" { t.Fatalf("SW-04 want failed reason, got %+v", res) } } func TestM2_SW06_DedupeTerms(t *testing.T) { ctx := context.Background() mem := repository.NewMemory() svc := New(mem) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30} o := &domain.Opportunity{ OwnerUID: 15, Source: domain.OppSourceThreads, ExternalID: "ext-same", Permalink: "https://x", AuthorHandle: "a", Text: "求推薦台北婚攝", Status: domain.OppQualified, IntentScore: 80, IntentBand: domain.BandHigh, Reasons: []domain.OpportunityReason{ {Dimension: domain.DimAuthenticity, Score: 30, Reason: "真需求"}, {Dimension: domain.DimIntent, Score: 30, Reason: "求推薦"}, {Dimension: domain.DimRegion, Score: 15, Reason: "台北"}, {Dimension: domain.DimFreshness, Score: 15, Reason: "新"}, {Dimension: domain.DimFit, Score: 10, Reason: "婚攝"}, }, RegionMatch: domain.RegionMatch, MatchedTerms: []string{"婚攝"}, } first, err := mem.UpsertByExternalID(ctx, o) if err != nil { t.Fatal(err) } second, err := mem.UpsertByExternalID(ctx, &domain.Opportunity{ OwnerUID: 15, ExternalID: "ext-same", MatchedTerms: []string{"台北 婚攝"}, }) if err != nil { t.Fatal(err) } if first.ID != second.ID { t.Fatal("SW-06 expected same id") } if len(second.MatchedTerms) != 2 { t.Fatalf("SW-06 terms=%v", second.MatchedTerms) } _ = svc } func TestM2_QT01_AtCapNoError(t *testing.T) { ctx := context.Background() svc := New(repository.NewMemory()) svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 1} // pre-fill one opportunity today w := seedProfileWatch(t, svc, 16) o := &domain.Opportunity{ OwnerUID: 16, Source: domain.OppSourceThreads, ExternalID: "pre", Permalink: "https://x/pre", AuthorHandle: "a", Text: "x", Status: domain.OppQualified, IntentScore: 90, IntentBand: domain.BandHigh, Reasons: []domain.OpportunityReason{ {Dimension: domain.DimAuthenticity, Score: 30, Reason: "a"}, {Dimension: domain.DimIntent, Score: 30, Reason: "b"}, {Dimension: domain.DimRegion, Score: 15, Reason: "c"}, {Dimension: domain.DimFreshness, Score: 15, Reason: "d"}, {Dimension: domain.DimFit, Score: 10, Reason: "e"}, }, RegionMatch: domain.RegionUnknown, MatchedTerms: []string{"t"}, CreatedAt: domain.NowNano(), } if _, err := svc.Repo.UpsertByExternalID(ctx, o); err != nil { t.Fatal(err) } svc.HitFetch = &fakeHits{path: domain.SweepPathAPI, hits: []ThreadHit{{ URL: "https://www.threads.net/@z/post/new", Snippet: "求推薦婚攝", }}} res, err := svc.RunSweep(ctx, 16, w.ID, "job-qt01") if err != nil { t.Fatalf("QT-01 must not error at cap: %v", err) } if res.Truncated < 1 && res.Created != 0 { t.Logf("QT-01 result %+v", res) } } func TestM2_ManualTriggerPausedRejected(t *testing.T) { ctx := context.Background() jobs := jobUC.New(jobRepo.NewMemory()) svc := New(repository.NewMemory()) svc.SweepJobs = SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt) if err != nil { return "", err } return j.ID, nil }) w := &domain.RadarWatch{ ID: "paused-1", OwnerUID: 17, Terms: []string{"a"}, Status: domain.WatchPaused, CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), } _ = svc.Repo.SaveWatch(ctx, w) _, err := svc.TriggerSweep(ctx, 17, w.ID) if err == nil || !strings.Contains(err.Error(), "active") { t.Fatalf("want paused error, got %v", err) } } func TestM2_BandThresholds(t *testing.T) { if domain.BandFromScore(80) != domain.BandHigh || domain.BandFromScore(79) != domain.BandMid || domain.BandFromScore(49) != domain.BandLow { t.Fatal("band thresholds broken") } } func TestM2_PastDailySlot(t *testing.T) { at := time.Date(2026, 7, 31, 22, 0, 0, 0, time.UTC) if !PastDailySweepSlot(at) { t.Fatal("expected past slot") } }