thread-master/apps/backend/cmd/worker/scout_scan_test.go

167 lines
6.0 KiB
Go
Raw Normal View History

2026-08-13 02:22:24 +00:00
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)
}
}