//go:build integration package atomize import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "sync/atomic" "testing" "time" "bountyboard/internal/domain" "bountyboard/internal/jobs" "bountyboard/internal/metrics" "bountyboard/internal/store" "bountyboard/internal/testutil" "bountyboard/internal/ulid" ) type stack struct { st *store.Store svc *Service runner *jobs.Runner task *domain.Task } func newStack(t *testing.T, atomizerHandler http.HandlerFunc) *stack { t.Helper() db := testutil.MongoDB(t) if err := store.EnsureIndexes(t.Context(), db); err != nil { t.Fatal(err) } st := &store.Store{Client: db.Client(), DB: db} srv := httptest.NewServer(atomizerHandler) t.Cleanup(srv.Close) client := New(func() string { return srv.URL }, "tok", 10*time.Second) logOut := io.Writer(io.Discard) if os.Getenv("TEST_LOG") == "1" { logOut = os.Stderr } log := slog.New(slog.NewTextHandler(logOut, nil)) svc := NewService(st, client, log, "http://localhost:8787", []byte("0123456789abcdef0123456789abcdef"), nil) runner := jobs.NewRunner(st, log, metrics.NewRegistry(), 2) runner.Register(JobKindSubdivide, svc.HandleSubdivide) runner.Register(JobKindExtend, svc.HandleExtend) task := &domain.Task{ CustomerID: ulid.New(), ConsultantID: ulid.New(), Origin: domain.OriginImported, Status: domain.StatusImported, Title: "Implement user import", Description: "Full description", AcceptanceCriteria: []string{"works"}, Budget: 1000, EffortCoefficient: 1, Bounty: 1000, External: &domain.External{System: "demo", Key: "DEMO-1", URL: "https://demo.invalid/1"}, } if err := st.InsertTask(t.Context(), task); err != nil { t.Fatal(err) } return &stack{st: st, svc: svc, runner: runner, task: task} } // runJob synchronously claims + executes pending jobs until idle. func runJobs(t *testing.T, s *stack, deadline time.Duration) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), deadline) defer cancel() go s.runner.Run(ctx) <-ctx.Done() } func goodAtomizer(calls *atomic.Int64) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if calls != nil { calls.Add(1) } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/atomize": json.NewEncoder(w).Encode(AtomizeResponse{ Model: "claude-sonnet-4-6", Tasks: []SubTask{ {Title: "Backend", Description: "API part", AcceptanceCriteria: []string{"a"}, EffortCoefficient: 0.5}, {Title: "Frontend", Description: "UI part", AcceptanceCriteria: []string{"b"}, EffortCoefficient: 0.3}, {Title: "Migration", Description: "DB part", AcceptanceCriteria: []string{"c"}, EffortCoefficient: 0.2}, }, }) case "/v1/extend": json.NewEncoder(w).Encode(ExtendResponse{ Task: SubTask{Title: "CSV mapping presets", Description: "extension", AcceptanceCriteria: []string{"x"}, EffortCoefficient: 0.4}, }) default: http.NotFound(w, r) } } } func TestSubdivideEndToEnd(t *testing.T) { s := newStack(t, goodAtomizer(nil)) ctx := context.Background() // consultant moved it to atomizing (handler does this before enqueue) if err := s.st.TransitionTask(ctx, s.task, domain.StatusAtomizing, "cons", "subdivide_requested", nil, nil); err != nil { t.Fatal(err) } if _, err := s.runner.Enqueue(ctx, JobKindSubdivide, SubdividePayload{ TaskID: s.task.ID, Note: "split it", ActorID: "cons", FromStatus: "imported", }); err != nil { t.Fatal(err) } runJobs(t, s, 4*time.Second) parent, err := s.st.TaskByID(ctx, s.task.ID) if err != nil { t.Fatal(err) } if parent.Status != domain.StatusAtomized { t.Fatalf("parent status = %s, want atomized", parent.Status) } if parent.AtomizationNote != "split it" { t.Errorf("atomizationNote = %q", parent.AtomizationNote) } children, err := s.st.ListTasks(ctx, store.TaskFilter{ParentID: s.task.ID}) if err != nil { t.Fatal(err) } if len(children) != 3 { t.Fatalf("children = %d, want 3", len(children)) } sum := 0.0 for _, c := range children { if c.Status != domain.StatusAtomized || c.Origin != domain.OriginSubdivided { t.Errorf("child %s: status=%s origin=%s", c.ID, c.Status, c.Origin) } if c.RootID != s.task.ID || c.Budget != 1000 { t.Errorf("child %s: root=%s budget=%v", c.ID, c.RootID, c.Budget) } if c.Bounty != domain.ComputeBounty(c.EffortCoefficient, c.Budget) { t.Errorf("child %s: bounty %v != coeff %v × budget %v", c.ID, c.Bounty, c.EffortCoefficient, c.Budget) } sum += c.EffortCoefficient } if sum < 0.999 || sum > 1.001 { t.Errorf("children coefficients sum %v, want 1±0.001", sum) } // consultant got the "ready" notification notifs, _ := s.st.ListNotifications(ctx, s.task.ConsultantID, false, "", 10) found := false for _, n := range notifs { if n.Kind == "subdivision_ready" { found = true } } if !found { t.Error("missing subdivision_ready notification") } } func TestExtendEndToEnd(t *testing.T) { s := newStack(t, goodAtomizer(nil)) ctx := context.Background() if _, err := s.runner.Enqueue(ctx, JobKindExtend, ExtendPayload{ TaskID: s.task.ID, Note: "add CSV presets", ActorID: "cons", }); err != nil { t.Fatal(err) } runJobs(t, s, 4*time.Second) siblings, err := s.st.ListTasks(ctx, store.TaskFilter{ParentID: s.task.ID}) if err != nil { t.Fatal(err) } if len(siblings) != 1 { t.Fatalf("siblings = %d, want exactly 1", len(siblings)) } sib := siblings[0] if sib.Origin != domain.OriginExtended || sib.Status != domain.StatusAtomized { t.Fatalf("sibling: %s/%s", sib.Origin, sib.Status) } if sib.EffortCoefficient != 0.4 || sib.Bounty != 400 { t.Fatalf("sibling coeff=%v bounty=%v, want 0.4/400", sib.EffortCoefficient, sib.Bounty) } // source status unchanged, timeline notes the extension src, _ := s.st.TaskByID(ctx, s.task.ID) if src.Status != domain.StatusImported { t.Fatalf("source status = %s, want imported", src.Status) } } func TestSubdivideFailureRevertsAfterRetries(t *testing.T) { var calls atomic.Int64 s := newStack(t, func(w http.ResponseWriter, r *http.Request) { calls.Add(1) http.Error(w, `{"error":{"code":"llm_unavailable","message":"model down"}}`, http.StatusBadGateway) }) ctx := context.Background() if err := s.st.TransitionTask(ctx, s.task, domain.StatusAtomizing, "cons", "subdivide_requested", nil, nil); err != nil { t.Fatal(err) } if _, err := s.runner.Enqueue(ctx, JobKindSubdivide, SubdividePayload{ TaskID: s.task.ID, ActorID: "cons", FromStatus: "imported", }); err != nil { t.Fatal(err) } // job retries with backoff (10s, 20s) — run one attempt per window (the // HTTP client's own 5xx retries take ~4s), forcing queued retries due // immediately between windows to keep the test fast runJobs(t, s, 6*time.Second) for i := 0; i < 2; i++ { if _, err := s.st.DB.Collection("jobs").UpdateMany(ctx, map[string]any{"status": jobs.StatusQueued}, map[string]any{"$set": map[string]any{"runAfter": time.Now().UTC().Add(-time.Second)}}); err != nil { t.Fatal(err) } runJobs(t, s, 6*time.Second) } parent, err := s.st.TaskByID(ctx, s.task.ID) if err != nil { t.Fatal(err) } if parent.Status != domain.StatusImported { t.Fatalf("status after exhausted retries = %s, want imported", parent.Status) } foundFailure := false for _, e := range parent.Timeline { if e.Event == "atomization_failed" { foundFailure = true } } if !foundFailure { t.Error("timeline missing atomization_failed") } notifs, _ := s.st.ListNotifications(ctx, s.task.ConsultantID, false, "", 10) failNotif := false for _, n := range notifs { if n.Kind == "atomization_failed" { failNotif = true } } if !failNotif { t.Error("missing atomization_failed notification") } }