feat: mock atomizer and work-performer services with contract tests (phase 11)
- services/atomizer: standalone Go module implementing §5.1 — Anthropic
OpenAI-compatible chat completions by default, native /v1/messages when
LLM_API_STYLE=anthropic, strict-JSON prompting, defensive fence-stripping
parse with one retry, deterministic equal-split fallback without an API
key, exact coefficient-sum normalization, bearer auth
- services/work-performer: Node 20 http server implementing §5.2 — single
concurrency, /work/{jobId}/TASK.md preparation, attachment downloads,
optional shallow git clone, claude CLI execution with JSON output,
simulated success when the CLI is unavailable (offline demo), artifact
endpoint, idempotent HMAC-signed callbacks with retry, best-effort cancel
- compose profile 'mocks': separate builds/ports/tokens, healthchecks,
${HOME}/.claude(.json) mounted read-only into the performer
- contract tests (go test -tags=contract) for both services; Makefile
test-contract target; verified live incl. a real Claude Code job run
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
//go:build contract
|
||||
|
||||
// Contract tests for the two mock services (§13). They run against live
|
||||
// containers:
|
||||
//
|
||||
// docker compose --profile mocks -f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||
// ATOMIZER_TOKEN=… WORK_PERFORMER_TOKEN=… go test -tags=contract ./internal/contract/
|
||||
//
|
||||
// URLs default to the loopback ports published by the mocks profile.
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/workperform"
|
||||
)
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
var (
|
||||
atomizerURL = env("CONTRACT_ATOMIZER_URL", "http://127.0.0.1:8090")
|
||||
performerURL = env("CONTRACT_PERFORMER_URL", "http://127.0.0.1:8091")
|
||||
atomizerToken = os.Getenv("ATOMIZER_TOKEN")
|
||||
performerToken = os.Getenv("WORK_PERFORMER_TOKEN")
|
||||
)
|
||||
|
||||
func postJSON(t *testing.T, url, token string, body any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, data
|
||||
}
|
||||
|
||||
func TestAtomizerContract(t *testing.T) {
|
||||
// healthz
|
||||
resp, err := http.Get(atomizerURL + "/healthz")
|
||||
if err != nil {
|
||||
t.Skipf("atomizer not reachable at %s (start the mocks profile): %v", atomizerURL, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("healthz: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// wrong bearer token rejected (when a token is configured)
|
||||
if atomizerToken != "" {
|
||||
r, _ := postJSON(t, atomizerURL+"/v1/atomize", "wrong-token", map[string]any{"taskId": "x", "title": "x"})
|
||||
if r.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong token: %d, want 401", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// atomize honors the §5.1 response contract
|
||||
r, data := postJSON(t, atomizerURL+"/v1/atomize", atomizerToken, map[string]any{
|
||||
"taskId": "01JCONTRACT",
|
||||
"title": "Implement user import",
|
||||
"description": "Import users from CSV with mapping and validation.",
|
||||
"acceptanceCriteria": []string{"valid rows imported", "errors reported per row"},
|
||||
"constraints": map[string]int{"minTasks": 2, "maxTasks": 5},
|
||||
})
|
||||
if r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("atomize: %d %s", r.StatusCode, data)
|
||||
}
|
||||
var out struct {
|
||||
Tasks []struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
EffortCoefficient float64 `json:"effortCoefficient"`
|
||||
} `json:"tasks"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("decode: %v: %s", err, data)
|
||||
}
|
||||
if len(out.Tasks) < 2 || len(out.Tasks) > 5 {
|
||||
t.Fatalf("task count %d outside constraints", len(out.Tasks))
|
||||
}
|
||||
sum := 0.0
|
||||
for _, task := range out.Tasks {
|
||||
if task.Title == "" || task.EffortCoefficient <= 0 || task.EffortCoefficient > 1 {
|
||||
t.Fatalf("bad task: %+v", task)
|
||||
}
|
||||
sum += task.EffortCoefficient
|
||||
}
|
||||
if math.Abs(sum-1) > 0.001 {
|
||||
t.Fatalf("coefficient sum %v, want 1±0.001", sum)
|
||||
}
|
||||
|
||||
// extend returns exactly one task with coefficient in (0, 2]
|
||||
r, data = postJSON(t, atomizerURL+"/v1/extend", atomizerToken, map[string]any{
|
||||
"taskId": "01JCONTRACT", "title": "Implement user import",
|
||||
"description": "Import users from CSV.",
|
||||
"extensionNote": "add reusable column-mapping presets",
|
||||
})
|
||||
if r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("extend: %d %s", r.StatusCode, data)
|
||||
}
|
||||
var ext struct {
|
||||
Task struct {
|
||||
Title string `json:"title"`
|
||||
EffortCoefficient float64 `json:"effortCoefficient"`
|
||||
} `json:"task"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &ext); err != nil {
|
||||
t.Fatalf("decode extend: %v", err)
|
||||
}
|
||||
if ext.Task.Title == "" || ext.Task.EffortCoefficient <= 0 || ext.Task.EffortCoefficient > 2 {
|
||||
t.Fatalf("bad extension: %+v", ext.Task)
|
||||
}
|
||||
|
||||
// extend without a note is rejected
|
||||
r, _ = postJSON(t, atomizerURL+"/v1/extend", atomizerToken, map[string]any{
|
||||
"taskId": "x", "title": "x",
|
||||
})
|
||||
if r.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("extend without note: %d, want 400", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkPerformerContract(t *testing.T) {
|
||||
resp, err := http.Get(performerURL + "/healthz")
|
||||
if err != nil {
|
||||
t.Skipf("work performer not reachable at %s (start the mocks profile): %v", performerURL, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("healthz: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// callback receiver on the host, reachable from the container via
|
||||
// host.docker.internal (extra_hosts: host-gateway)
|
||||
callbackCh := make(chan struct {
|
||||
body []byte
|
||||
sig string
|
||||
}, 1)
|
||||
ln, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
select {
|
||||
case callbackCh <- struct {
|
||||
body []byte
|
||||
sig string
|
||||
}{body, r.Header.Get("X-Signature")}:
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
callbackURL := fmt.Sprintf("http://host.docker.internal:%d/cb", port)
|
||||
|
||||
r, data := postJSON(t, performerURL+"/v1/jobs", performerToken, map[string]any{
|
||||
"taskId": "01JCONTRACTWP", "title": "Write hello world",
|
||||
"description": "Create hello.txt containing hello world.",
|
||||
"acceptanceCriteria": []string{"hello.txt exists"},
|
||||
"callbackUrl": callbackURL,
|
||||
})
|
||||
if r.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("submit: %d %s", r.StatusCode, data)
|
||||
}
|
||||
var accepted struct {
|
||||
JobID string `json:"jobId"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &accepted); err != nil || accepted.JobID == "" {
|
||||
t.Fatalf("bad 202 body: %s", data)
|
||||
}
|
||||
|
||||
// status endpoint
|
||||
req, _ := http.NewRequest(http.MethodGet, performerURL+"/v1/jobs/"+accepted.JobID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+performerToken)
|
||||
sResp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sBody, _ := io.ReadAll(sResp.Body)
|
||||
sResp.Body.Close()
|
||||
var status struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(sBody, &status); err != nil {
|
||||
t.Fatalf("status decode: %s", sBody)
|
||||
}
|
||||
if !strings.Contains("queued running succeeded failed", status.Status) {
|
||||
t.Fatalf("status %q", status.Status)
|
||||
}
|
||||
|
||||
// HMAC-signed callback arrives (the simulated path is fast; the real
|
||||
// claude path may take minutes — allow a generous window)
|
||||
select {
|
||||
case cb := <-callbackCh:
|
||||
if !workperform.VerifySignature(performerToken, cb.body, cb.sig) {
|
||||
t.Fatalf("callback signature invalid")
|
||||
}
|
||||
var payload workperform.Callback
|
||||
if err := json.Unmarshal(cb.body, &payload); err != nil {
|
||||
t.Fatalf("callback decode: %v", err)
|
||||
}
|
||||
if payload.JobID != accepted.JobID || payload.TaskID != "01JCONTRACTWP" {
|
||||
t.Fatalf("callback ids: %+v", payload)
|
||||
}
|
||||
if payload.Status != "succeeded" && payload.Status != "failed" {
|
||||
t.Fatalf("callback status %q", payload.Status)
|
||||
}
|
||||
// artifacts must be fetchable (rewrite in-network host for the host-side test)
|
||||
for _, a := range payload.Artifacts {
|
||||
url := strings.Replace(a.URL, "http://work-performer:8091", performerURL, 1)
|
||||
aResp, err := http.Get(url)
|
||||
if err != nil || aResp.StatusCode != 200 {
|
||||
t.Fatalf("artifact %s not fetchable: %v %v", a.URL, err, aResp)
|
||||
}
|
||||
aResp.Body.Close()
|
||||
}
|
||||
case <-time.After(5 * time.Minute):
|
||||
t.Fatal("no callback within 5 minutes")
|
||||
}
|
||||
|
||||
// cancel is accepted for unknown-but-wellformed ids → 404, and DELETE on
|
||||
// a finished job still answers
|
||||
req, _ = http.NewRequest(http.MethodDelete, performerURL+"/v1/jobs/"+accepted.JobID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+performerToken)
|
||||
dResp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dResp.Body.Close()
|
||||
if dResp.StatusCode != 200 {
|
||||
t.Fatalf("cancel: %d", dResp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user