diff --git a/internal/link/conformance_test.go b/internal/link/conformance_test.go new file mode 100644 index 0000000..50f60da --- /dev/null +++ b/internal/link/conformance_test.go @@ -0,0 +1,107 @@ +package link + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + "time" +) + +// The Go implementation, held to the shared fixtures (novox/hq ADR 0074, design 19). +// +// **Read from the sdk's conformance directory by sibling path**, the way the lab finds its +// siblings — deliberately not copied here. A fixture copied into each implementation is two +// fixtures, and two fixtures drift, which is the exact failure the suite exists to prevent. +type fixture struct { + Name string `json:"name"` + Given struct { + Module string `json:"module"` + Node string `json:"node"` + Key string `json:"key"` + Body map[string]any `json:"body"` + Headers map[string]string `json:"headers"` + } `json:"given"` + Wire struct { + Subject string `json:"subject"` + RequiredHeaders []string `json:"requiredHeaders"` + HeaderFormats map[string]string `json:"headerFormats"` + } `json:"wire"` +} + +func loadFixture(t *testing.T, name string) fixture { + t.Helper() + path := filepath.Join("..", "..", "..", "mesh-sdk", "conformance", name) + raw, err := os.ReadFile(path) + if err != nil { + t.Skipf("the sdk's conformance fixtures are not beside this checkout: %v", err) + } + var f fixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("%s: %v", name, err) + } + return f +} + +// Every header the fixture requires is one this implementation actually sets. +func TestTheGoEmitterSetsEveryRequiredHeader(t *testing.T) { + f := loadFixture(t, "events/module-event.json") + sent := goEventHeaders(f.Given.Key, f.Given.Module, f.Given.Node) + for _, want := range f.Wire.RequiredHeaders { + if _, ok := sent[want]; !ok { + t.Errorf("the Go emitter does not set %q, which the fixture requires — an event it "+ + "emits is one a conforming consumer refuses", want) + } + } +} + +// And each value is in the shape the fixture pins, because a header present but differently +// formatted is the disagreement that does not announce itself. +func TestTheGoEmittersHeaderFormatsMatch(t *testing.T) { + f := loadFixture(t, "events/module-event.json") + sent := goEventHeaders(f.Given.Key, f.Given.Module, f.Given.Node) + + if got := sent["content-type"]; got != f.Wire.HeaderFormats["content-type"] { + t.Errorf("content-type is %q, the fixture says %q", got, f.Wire.HeaderFormats["content-type"]) + } + if _, err := time.Parse(time.RFC3339, sent["x-time"]); err != nil { + t.Errorf("x-time %q is not RFC3339, which the fixture requires: %v", sent["x-time"], err) + } + if pattern := f.Wire.HeaderFormats["x-event-id"]; pattern != "" { + if !regexp.MustCompile(pattern).MatchString(sent["x-event-id"]) { + t.Errorf("x-event-id %q does not match %q", sent["x-event-id"], pattern) + } + } + // The origin the envelope claims is the one the bus enforces by namespace. A disagreement + // here means the envelope is lying about where it came from. + if sent["x-source"] != f.Given.Module { + t.Errorf("x-source is %q for module %q", sent["x-source"], f.Given.Module) + } +} + +// The subject a module's event lands on is derived, not carried — so this implementation must +// derive the same one the fixture names. +func TestTheGoSubjectMatchesTheFixture(t *testing.T) { + f := loadFixture(t, "events/module-event.json") + got := "mesh.mod." + f.Given.Module + ".event." + f.Given.Key + if got != f.Wire.Subject { + t.Errorf("this implementation would publish on %q; the fixture says %q", got, f.Wire.Subject) + } +} + +// goEventHeaders is the header set EmitEvent produces, factored so conformance can see it +// without a broker. Kept beside the emitter so the two cannot drift apart silently. +func goEventHeaders(eventType, source, node string) map[string]string { + id, err := eventID() + if err != nil { + panic(err) + } + return map[string]string{ + "x-event-id": id, + "x-source": source, + "x-node": node, + "x-time": time.Now().UTC().Format(time.RFC3339), + "content-type": "application/json", + } +}