Files
BountyBoard/internal/contract/contract_test.go
T
etalon d698f70c4f 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>
2026-06-12 20:31:39 +02:00

259 lines
7.8 KiB
Go

//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)
}
}