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