fluid-core/internal/contract/contract_test.go
tegwick 76912adef8
Some checks failed
ci / build (push) Failing after 1m6s
Generate contract types, pin fixtures to spec, add build and ADRs
Completes FLUID-WP-0002. The record types in internal/contract are
generated from schemas/ by a dependency-free generator; fixtures
transcribed from the spec's worked examples validate against those
schemas and round-trip through the generated types with
DisallowUnknownFields, so a spec change that misses the schemas fails
CI rather than drifting silently.

Adds identifier prefix helpers, Makefile, GitHub Actions, and five ADRs
recording the Go choice, out-of-process attachment, the wire contract as
boundary, the evidence store, and revision identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:03:12 +02:00

203 lines
5.6 KiB
Go

package contract
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
// fixtureDir holds JSON copies of the spec-derived YAML fixtures, written by
// conformance/validate_schemas.py.
const fixtureDir = "../../conformance/fixtures/json"
// roundTrip decodes a fixture into T, re-encodes it, and reports any field the
// generated types dropped on the way through.
//
// This is the drift check that matters: a schema change that schemagen did not
// pick up shows here as a field that vanishes.
func roundTrip[T any](t *testing.T, name string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join(fixtureDir, name+".json"))
if err != nil {
t.Fatalf("read fixture: %v (run `make validate` first)", err)
}
var typed T
dec := json.NewDecoder(bytesReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&typed); err != nil {
t.Fatalf("decode into %T: %v", typed, err)
}
encoded, err := json.Marshal(typed)
if err != nil {
t.Fatalf("re-encode: %v", err)
}
var before, after map[string]any
if err := json.Unmarshal(raw, &before); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if err := json.Unmarshal(encoded, &after); err != nil {
t.Fatalf("unmarshal re-encoded: %v", err)
}
if missing := missingKeys(before, after, ""); len(missing) > 0 {
t.Errorf("round trip dropped fields: %v", missing)
}
}
// missingKeys walks want and reports paths absent from got.
//
// Explicit nulls and empty collections are skipped. The schemas treat
// "end: null" and an absent "end" as the same statement (the window is still
// open), so omitempty dropping them on re-encode is correct rather than drift.
// Genuine drift -- a fixture field the generated types have no home for -- is
// caught by DisallowUnknownFields on the way in.
func missingKeys(want, got map[string]any, prefix string) []string {
var missing []string
for k, wv := range want {
path := k
if prefix != "" {
path = prefix + "." + k
}
if isEmptyValue(wv) {
continue
}
gv, ok := got[k]
if !ok {
missing = append(missing, path)
continue
}
wm, wok := wv.(map[string]any)
gm, gok := gv.(map[string]any)
if wok && gok {
missing = append(missing, missingKeys(wm, gm, path)...)
}
}
return missing
}
// isEmptyValue reports values the schema treats as equivalent to absent.
func isEmptyValue(v any) bool {
switch t := v.(type) {
case nil:
return true
case []any:
return len(t) == 0
case map[string]any:
return len(t) == 0
}
return false
}
func TestFixturesRoundTrip(t *testing.T) {
t.Run("pressure", func(t *testing.T) { roundTrip[PressureDocument](t, "pressure") })
t.Run("hypothesis", func(t *testing.T) { roundTrip[HypothesisDocument](t, "hypothesis") })
t.Run("revision", func(t *testing.T) { roundTrip[RevisionDocument](t, "revision") })
t.Run("experiment", func(t *testing.T) { roundTrip[ExperimentDocument](t, "experiment") })
t.Run("event", func(t *testing.T) { roundTrip[EventDocument](t, "event") })
t.Run("feedback", func(t *testing.T) { roundTrip[FeedbackDocument](t, "feedback") })
t.Run("backend-requirement", func(t *testing.T) {
roundTrip[BackendRequirementDocument](t, "backend-requirement")
})
t.Run("revision-descriptor", func(t *testing.T) {
roundTrip[RevisionDescriptorDocument](t, "revision-descriptor")
})
t.Run("routing-policy", func(t *testing.T) { roundTrip[RoutingPolicyDocument](t, "routing-policy") })
t.Run("telemetry-envelope", func(t *testing.T) {
roundTrip[TelemetryEnvelopeDocument](t, "telemetry-envelope")
})
}
func TestKindOf(t *testing.T) {
cases := map[string]EntityKind{
"H-000184": KindHypothesis,
"R-000221": KindRevision,
"E-000093": KindExperiment,
"P-1831": KindPressure,
"BR-0041": KindBackendRequirement,
"D-2811": KindDecision,
"EV-990281": KindEvent,
"F-9821": KindFeedback,
"C-17": KindCohort,
}
for id, want := range cases {
got, ok := KindOf(id)
if !ok || got != want {
t.Errorf("KindOf(%q) = %q, %v; want %q", id, got, ok, want)
}
}
// BR- and EV- must not be swallowed by the single-letter prefixes.
if k, _ := KindOf("BR-1"); k != KindBackendRequirement {
t.Errorf("BR- prefix mis-classified as %q", k)
}
if k, _ := KindOf("EV-1"); k != KindEvent {
t.Errorf("EV- prefix mis-classified as %q", k)
}
if _, ok := KindOf("something"); ok {
t.Error("unprefixed identifier should not classify")
}
}
func TestRequireKind(t *testing.T) {
if err := RequireKind("R-1", KindRevision); err != nil {
t.Errorf("valid revision id rejected: %v", err)
}
err := RequireKind("H-1", KindRevision)
if err == nil {
t.Fatal("hypothesis id accepted where a revision id was required")
}
var wrong *ErrWrongKind
if !errorsAs(err, &wrong) {
t.Fatalf("expected *ErrWrongKind, got %T", err)
}
if wrong.Got != KindHypothesis || wrong.Want != KindRevision {
t.Errorf("unexpected error detail: %+v", wrong)
}
}
// Small helpers keep the test file free of imports the generated code does not
// already require.
func bytesReader(b []byte) *jsonReader { return &jsonReader{b: b} }
type jsonReader struct {
b []byte
i int
}
func (r *jsonReader) Read(p []byte) (int, error) {
if r.i >= len(r.b) {
return 0, errEOF
}
n := copy(p, r.b[r.i:])
r.i += n
return n, nil
}
var errEOF = errorString("EOF")
type errorString string
func (e errorString) Error() string { return string(e) }
func errorsAs(err error, target any) bool {
tv := reflect.ValueOf(target).Elem()
for err != nil {
if reflect.TypeOf(err).AssignableTo(tv.Type()) {
tv.Set(reflect.ValueOf(err))
return true
}
u, ok := err.(interface{ Unwrap() error })
if !ok {
return false
}
err = u.Unwrap()
}
return false
}