package main import ( "context" "encoding/json" "errors" "testing" 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" ) type workerScoutProvider struct{ err error } func (p workerScoutProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]scoutUC.ThreadSearchResult, error) { if p.err != nil { return nil, p.err } term := "市集" if len(terms) > 0 && terms[0] != "" { term = terms[0] } if limit < 1 { limit = 1 } return []scoutUC.ThreadSearchResult{{ URL: "https://www.threads.net/@worker/post/ok", Snippet: term + "討論內容", }}, nil } type failingPublishScoutRepo struct{ scoutDomain.Repository } func (failingPublishScoutRepo) PublishRunPosts(context.Context, int64, string, []*scoutDomain.Post) error { return errors.New("publish storage unavailable") } func workerPayload(runID string, brief scoutDomain.RunBrief) string { raw, _ := json.Marshal(struct { RunID string `json:"run_id"` scoutDomain.RunBrief }{RunID: runID, RunBrief: brief}) return string(raw) } func workerRunAndJob(t *testing.T, scoutStore scoutDomain.Repository, jobStore jobDomain.Repository, status string) (*scoutUC.Service, *jobUC.Service, *jobDomain.Job, *scoutDomain.Run) { t.Helper() brief := scoutDomain.RunBrief{Intent: "市集", Mode: scoutDomain.ModeActivity, ScanTerms: []string{"市集"}} run := scoutDomain.NewRun("worker-run", "worker-job", 7, brief, 1) if err := scoutStore.CreateRun(context.Background(), run); err != nil { t.Fatal(err) } job := &jobDomain.Job{ID: run.JobID, OwnerUID: 7, TemplateType: jobDomain.TemplateScoutScan, Status: jobDomain.StatusRunning, RefID: run.ID, Payload: workerPayload(run.ID, brief), CreatedAt: 1, UpdatedAt: 1} if err := jobStore.Insert(context.Background(), job); err != nil { t.Fatal(err) } if status != scoutDomain.RunQueued { if status == scoutDomain.RunCancelled { run.Status = scoutDomain.RunCancelled } else { run.Status = status } if err := scoutStore.ReplaceRunGuarded(context.Background(), 7, run.ID, []string{scoutDomain.RunQueued}, run); err != nil { t.Fatal(err) } } scout := scoutUC.New(scoutStore) scout.Provider = workerScoutProvider{} return scout, jobUC.New(jobStore), job, run } func TestWorkerScoutScanSuccessPublishesExactlyOnce(t *testing.T) { ctx := context.Background() scoutStore := scoutRepo.NewMemory() jobStore := jobRepo.NewMemory() scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) if err := runScoutScan(ctx, jobs, scout, job); err != nil { t.Fatal(err) } gotRun, err := scoutStore.GetRun(ctx, 7, run.ID) if err != nil || gotRun.Status != scoutDomain.RunSucceeded || gotRun.EligibleCount != 1 { t.Fatalf("run=%+v err=%v", gotRun, err) } posts, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10) if err != nil || len(posts.Items) != 1 { t.Fatalf("published posts=%+v err=%v", posts, err) } jobAfter, err := jobStore.FindByID(ctx, job.ID) if err != nil || jobAfter.Status != jobDomain.StatusSucceeded { t.Fatalf("job=%+v err=%v", jobAfter, err) } if err := runScoutScan(ctx, jobs, scout, job); err == nil { t.Fatal("terminal run retry should not search/publish again") } postsAgain, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10) if err != nil || len(postsAgain.Items) != 1 { t.Fatalf("retry changed result membership: %+v %v", postsAgain, err) } } func TestWorkerScoutScanMismatchFailsBeforeSearch(t *testing.T) { ctx := context.Background() scoutStore := scoutRepo.NewMemory() jobStore := jobRepo.NewMemory() scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) job.RefID = "wrong-run" if err := runScoutScan(ctx, jobs, scout, job); err == nil { t.Fatal("reference mismatch should fail") } got, err := scoutStore.GetRun(ctx, 7, run.ID) if err != nil || got.Status != scoutDomain.RunQueued { t.Fatalf("mismatch changed run=%+v err=%v", got, err) } } func TestWorkerScoutScanProviderFailureFailsRunAndHidesResults(t *testing.T) { ctx := context.Background() scoutStore := scoutRepo.NewMemory() jobStore := jobRepo.NewMemory() scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) scout.Provider = workerScoutProvider{err: errors.New("provider timeout: token redacted")} if err := runScoutScan(ctx, jobs, scout, job); err == nil { t.Fatal("provider failure should fail") } got, err := scoutStore.GetRun(ctx, 7, run.ID) if err != nil || got.Status != scoutDomain.RunFailed || got.Error == "" { t.Fatalf("failed run=%+v err=%v", got, err) } posts, err := scoutStore.ListRunPosts(ctx, 7, run.ID, 1, 10) if err != nil || len(posts.Items) != 0 { t.Fatalf("failed run exposed posts=%+v err=%v", posts, err) } } func TestWorkerScoutScanPublishFailureFailsRun(t *testing.T) { ctx := context.Background() base := scoutRepo.NewMemory() scoutStore := failingPublishScoutRepo{Repository: base} jobStore := jobRepo.NewMemory() scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunQueued) if err := runScoutScan(ctx, jobs, scout, job); err == nil { t.Fatal("publish failure should fail") } got, err := base.GetRun(ctx, 7, run.ID) if err != nil || got.Status != scoutDomain.RunFailed { t.Fatalf("publish failed run=%+v err=%v", got, err) } } func TestWorkerScoutScanCancelledRunDoesNotRestart(t *testing.T) { ctx := context.Background() scoutStore := scoutRepo.NewMemory() jobStore := jobRepo.NewMemory() scout, jobs, job, run := workerRunAndJob(t, scoutStore, jobStore, scoutDomain.RunCancelled) if err := runScoutScan(ctx, jobs, scout, job); err == nil { t.Fatal("cancelled run should not restart") } got, err := scoutStore.GetRun(ctx, 7, run.ID) if err != nil || got.Status != scoutDomain.RunCancelled { t.Fatalf("cancelled run changed=%+v err=%v", got, err) } }