f87b954f27
- 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>
144 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|