Files
BountyBoard/internal/atomize/client_test.go
T
etalon f87b954f27 feat: atomization pipeline with job queue, atomizer client, WS hub, and board UI (phase 7)
- internal/extsvc: circuit breaker (§11.16) + retrying bearer JSON client
- atomizer client: §5.1 contract incl. defensive coefficient normalization
  and extension coefficient range (0,2]
- persisted jobs collection: atomic claim, panic recovery, exponential
  backoff (max 3), stale-running requeue, shutdown-safe bookkeeping
- subdivide (async 202, atomizing status, re-run replaces unpublished
  children after confirm) and extend (sibling task, source budget)
- failure path reverts status and notifies the consultant on final attempt
- PATCH task editing with bounty recompute, budget cascade to descendants
- publish single + bulk; task detail endpoint role-scoped
- coder/websocket hub: multiplexed channels, origin check, 30s heartbeats;
  client ws.js with reconnect + 15s polling fallback
- consultant atomization board: tree by root, coefficient sliders with live
  per-parent sum indicator, bounty preview, modals, shimmer, atomizer-down
  gating via /api/v1/service-health
- fix: tasks external-ref unique index is partial, not sparse (compound
  sparse indexed every task through customerId and broke child inserts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:17:09 +02:00

144 lines
4.4 KiB
Go

package atomize
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"bountyboard/internal/extsvc"
)
func TestNormalizeCoefficients(t *testing.T) {
tests := []struct {
name string
in []float64
wantErr bool
wantSum float64
}{
{"exact sum kept", []float64{0.2, 0.5, 0.3}, false, 1},
{"tiny deviation accepted", []float64{0.2, 0.5, 0.3001}, false, 1},
{"small deviation renormalized", []float64{0.21, 0.52, 0.31}, false, 1}, // sum 1.04
{"under-sum renormalized", []float64{0.3, 0.3, 0.36}, false, 1}, // sum 0.96
{"large deviation rejected", []float64{0.5, 0.5, 0.5}, true, 0},
{"zero coefficient rejected", []float64{0, 0.5, 0.5}, true, 0},
{"negative rejected", []float64{-0.1, 0.6, 0.5}, true, 0},
{"above one rejected", []float64{1.2}, true, 0},
{"empty rejected", nil, true, 0},
{"single exact", []float64{1.0}, false, 1},
{"NaN rejected", []float64{math.NaN(), 0.5}, true, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := NormalizeCoefficients(tt.in)
if tt.wantErr {
if !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("want ErrBadCoefficients, got %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
sum := 0.0
for _, c := range out {
if c <= 0 || c > 1 {
t.Errorf("normalized coefficient %v out of range", c)
}
sum += c
}
if math.Abs(sum-tt.wantSum) > 1e-9 {
t.Errorf("sum = %.6f, want exactly %v (coeffs %v)", sum, tt.wantSum, out)
}
})
}
}
func fakeAtomizer(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return New(func() string { return srv.URL }, "test-token", 5*time.Second)
}
func TestAtomizeAppliesNormalization(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("missing bearer token")
}
var req AtomizeRequest
json.NewDecoder(r.Body).Decode(&req)
if req.TaskID != "01JTASK" {
t.Errorf("taskId = %q", req.TaskID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{
{Title: "A", EffortCoefficient: 0.31}, // sum 1.03 → renormalize
{Title: "B", EffortCoefficient: 0.41},
{Title: "C", EffortCoefficient: 0.31},
}})
})
resp, err := client.Atomize(context.Background(), AtomizeRequest{TaskID: "01JTASK"})
if err != nil {
t.Fatal(err)
}
sum := 0.0
for _, st := range resp.Tasks {
sum += st.EffortCoefficient
}
if math.Abs(sum-1) > 1e-9 {
t.Fatalf("normalized sum = %v", sum)
}
}
func TestAtomizeRejectsBadSum(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AtomizeResponse{Tasks: []SubTask{
{Title: "A", EffortCoefficient: 0.9},
{Title: "B", EffortCoefficient: 0.9},
}})
})
_, err := client.Atomize(context.Background(), AtomizeRequest{})
if !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("want ErrBadCoefficients, got %v", err)
}
}
func TestExtendValidatesCoefficient(t *testing.T) {
coeff := 2.5
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ExtendResponse{Task: SubTask{Title: "X", EffortCoefficient: coeff}})
})
if _, err := client.Extend(context.Background(), ExtendRequest{}); !errors.Is(err, ErrBadCoefficients) {
t.Fatalf("coefficient 2.5: want ErrBadCoefficients, got %v", err)
}
coeff = 1.4 // legal for extensions
if _, err := client.Extend(context.Background(), ExtendRequest{}); err != nil {
t.Fatalf("coefficient 1.4 should pass: %v", err)
}
}
func TestBreakerOpensAfterRepeatedFailures(t *testing.T) {
client := fakeAtomizer(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
for i := 0; i < 3; i++ {
if _, err := client.Atomize(context.Background(), AtomizeRequest{}); err == nil {
t.Fatal("expected failure")
}
}
if client.BreakerState() != "open" {
t.Fatalf("breaker = %s, want open", client.BreakerState())
}
_, err := client.Atomize(context.Background(), AtomizeRequest{})
if !errors.Is(err, extsvc.ErrBreakerOpen) {
t.Fatalf("want ErrBreakerOpen, got %v", err)
}
}