269 lines
8.7 KiB
Go
269 lines
8.7 KiB
Go
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")
|
|
}
|
|
}
|