package scout import ( "context" "encoding/json" "errors" "testing" "apps/backend/internal/middleware" jobDomain "apps/backend/internal/module/job/domain" jobRepo "apps/backend/internal/module/job/repository" jobUC "apps/backend/internal/module/job/usecase" scoutDomain "apps/backend/internal/module/scout/domain" scoutRepo "apps/backend/internal/module/scout/repository" scoutUC "apps/backend/internal/module/scout/usecase" "apps/backend/internal/svc" "apps/backend/internal/types" ) func TestRunScanCreatesIndependentRunAndJobLink(t *testing.T) { ctx := middleware.WithUID(context.Background(), 7) scoutStore := scoutRepo.NewMemory() jobs := jobUC.New(jobRepo.NewMemory()) svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: jobs} data, err := NewRunScanLogic(ctx, svcCtx).RunScan(&types.ScoutScanReq{Brief: types.ScoutBriefPublic{ Intent: "市集", Mode: domainModeActivity, ThemeKey: "same-theme", ThemeLabel: "週末市集", ScanTerms: []string{"市集"}, TargetCount: 12, }}) if err != nil { t.Fatal(err) } if data.Run.Id == "" || data.Job.Id == "" || data.Run.Id == data.Job.Id { t.Fatalf("missing independent ids: %+v", data) } if data.Job.RefId != data.Run.Id || data.Run.JobId != data.Job.Id || data.Run.Status != scoutDomain.RunQueued { t.Fatalf("job/run link mismatch: job=%+v run=%+v", data.Job, data.Run) } var raw map[string]json.RawMessage if err := json.Unmarshal([]byte(data.Job.Payload), &raw); err != nil { t.Fatal(err) } var payloadRunID string if err := json.Unmarshal(raw["run_id"], &payloadRunID); err != nil || payloadRunID != data.Run.Id { t.Fatalf("payload run_id=%q err=%v payload=%s", payloadRunID, err, data.Job.Payload) } var brief scoutDomain.RunBrief if err := json.Unmarshal([]byte(data.Job.Payload), &brief); err != nil || brief.Intent != "市集" || len(brief.ScanTerms) != 1 { t.Fatalf("flat worker brief=%+v err=%v", brief, err) } if data.Run.TargetCount != 12 { t.Fatalf("target count=%d", data.Run.TargetCount) } runs, err := scoutStore.ListRuns(context.Background(), scoutDomain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10}) if err != nil || len(runs.Items) != 1 || runs.Items[0].ID != data.Run.Id { t.Fatalf("stored runs=%+v err=%v", runs, err) } } func TestRunScanCreatesFreshRunForSameTheme(t *testing.T) { ctx := middleware.WithUID(context.Background(), 7) scoutStore := scoutRepo.NewMemory() svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: jobUC.New(jobRepo.NewMemory())} logic := NewRunScanLogic(ctx, svcCtx) request := &types.ScoutScanReq{Brief: types.ScoutBriefPublic{Intent: "市集", Mode: domainModeActivity, ThemeKey: "repeat", ScanTerms: []string{"市集"}}} first, err := logic.RunScan(request) if err != nil { t.Fatal(err) } second, err := logic.RunScan(request) if err != nil { t.Fatal(err) } if first.Run.Id == second.Run.Id || first.Job.Id == second.Job.Id { t.Fatalf("same theme reused id: first=%+v second=%+v", first, second) } } type failingInsertJobRepo struct{ jobDomain.Repository } func (failingInsertJobRepo) Insert(context.Context, *jobDomain.Job) error { return errors.New("insert unavailable") } func TestRunScanSchedulingFailureFailsQueuedRun(t *testing.T) { ctx := middleware.WithUID(context.Background(), 7) scoutStore := scoutRepo.NewMemory() failingJobs := jobUC.New(failingInsertJobRepo{Repository: jobRepo.NewMemory()}) svcCtx := &svc.ServiceContext{Scout: scoutUC.New(scoutStore), Jobs: failingJobs} _, err := NewRunScanLogic(ctx, svcCtx).RunScan(&types.ScoutScanReq{Brief: types.ScoutBriefPublic{ Intent: "市集", Mode: domainModeActivity, ScanTerms: []string{"市集"}, }}) if err == nil { t.Fatal("expected scheduling failure") } runs, listErr := scoutStore.ListRuns(context.Background(), scoutDomain.RunFilter{OwnerUID: 7, Page: 1, PageSize: 10}) if listErr != nil || len(runs.Items) != 1 || runs.Items[0].Status != scoutDomain.RunFailed { t.Fatalf("failed run convergence: runs=%+v err=%v listErr=%v", runs, err, listErr) } if runs.Items[0].Error == "" { t.Fatal("failed run missing safe error") } } const domainModeActivity = "activity"