package contracts import ( "testing" ) // Helpers ------------------------------------------------------------------- func makeContract(role Role, repo, contractID string, meta map[string]any) Contract { return Contract{ ID: contractID, Type: ContractHTTP, Role: role, RepoPrefix: repo, Meta: meta, } } func buildRegistry(contracts ...Contract) *Registry { r := NewRegistry() for _, c := range contracts { r.Add(c) } return r } // shapeMap is a trivial in-memory ShapeLookup keyed by symbol ID. type shapeMap map[string]*Shape func (sm shapeMap) lookup(id string) *Shape { return sm[id] } func findIssue(t *testing.T, issues []ContractIssue, kind string, field string) *ContractIssue { t.Helper() for i := range issues { if issues[i].Kind == kind && issues[i].Field == field { return &issues[i] } } kinds := make([]string, 0, len(issues)) for _, is := range issues { kinds = append(kinds, is.Kind+"("+is.Field+")") } t.Fatalf("missing %s(%s) in %v", kind, field, kinds) return nil } // Tests --------------------------------------------------------------------- func TestValidate_OrphanConsumer(t *testing.T) { reg := buildRegistry( makeContract(RoleConsumer, "web", "http::GET::/ghost", map[string]any{}), ) got := Validate(reg, nil) if len(got) != 1 { t.Fatalf("want 1 issue, got %d", len(got)) } if got[0].Kind != IssueOrphanConsumer { t.Errorf("kind = %q", got[0].Kind) } if got[0].Severity != SeverityWarning { t.Errorf("severity = %q, want warning", got[0].Severity) } } func TestValidate_OrphanProvider(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::GET::/health", map[string]any{}), ) got := Validate(reg, nil) if len(got) != 1 { t.Fatalf("want 1 issue, got %d", len(got)) } if got[0].Kind != IssueOrphanProvider || got[0].Severity != SeverityInfo { t.Errorf("kind=%s severity=%s", got[0].Kind, got[0].Severity) } } // Response: provider removed a field that consumer still reads — breaking. func TestValidate_Response_FieldRemoved_Breaking(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::GET::/users", map[string]any{ "response_type": "api/resp.go::UserResp", }), makeContract(RoleConsumer, "web", "http::GET::/users", map[string]any{ "response_type": "web/types.ts::UserResp", }), ) shapes := shapeMap{ "api/resp.go::UserResp": { Kind: "struct", Fields: []ShapeField{ {Name: "id", Type: "string", Required: true}, }, }, "web/types.ts::UserResp": { Kind: "interface", Fields: []ShapeField{ {Name: "id", Type: "string", Required: true}, {Name: "email", Type: "string", Required: true}, // consumer still expects it }, }, } issues := Validate(reg, shapes.lookup) iss := findIssue(t, issues, IssueResponseFieldRemoved, "email") if iss.Severity != SeverityBreaking { t.Errorf("severity = %q, want breaking", iss.Severity) } } // Response: consumer optionally reads a missing field — downgraded warning. func TestValidate_Response_FieldRemoved_Optional_Warning(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::GET::/users", map[string]any{ "response_type": "api/resp.go::UserResp", }), makeContract(RoleConsumer, "web", "http::GET::/users", map[string]any{ "response_type": "web/types.ts::UserResp", }), ) shapes := shapeMap{ "api/resp.go::UserResp": { Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}, }, "web/types.ts::UserResp": { Fields: []ShapeField{ {Name: "id", Type: "string", Required: true}, {Name: "nickname", Type: "string", Required: false}, }, }, } issues := Validate(reg, shapes.lookup) iss := findIssue(t, issues, IssueResponseFieldRemoved, "nickname") if iss.Severity != SeverityWarning { t.Errorf("severity = %q, want warning (optional field absent)", iss.Severity) } } // Response: provider emits extra field consumer doesn't read — info. func TestValidate_Response_FieldAdded_Info(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::GET::/x", map[string]any{"response_type": "api/resp.go::X"}), makeContract(RoleConsumer, "web", "http::GET::/x", map[string]any{"response_type": "web/resp.ts::X"}), ) shapes := shapeMap{ "api/resp.go::X": {Fields: []ShapeField{ {Name: "id", Type: "string", Required: true}, {Name: "notes", Type: "string", Required: false}, }}, "web/resp.ts::X": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}}, } issues := Validate(reg, shapes.lookup) iss := findIssue(t, issues, IssueResponseFieldAdded, "notes") if iss.Severity != SeverityInfo { t.Errorf("severity = %q, want info", iss.Severity) } } // Request: provider requires a field the consumer doesn't send — breaking. func TestValidate_Request_RequiredFieldMissing_Breaking(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::POST::/users", map[string]any{ "request_type": "api/req.go::CreateUser", }), makeContract(RoleConsumer, "web", "http::POST::/users", map[string]any{ "request_type": "web/req.ts::CreateUser", }), ) shapes := shapeMap{ "api/req.go::CreateUser": {Fields: []ShapeField{ {Name: "email", Type: "string", Required: true}, {Name: "password", Type: "string", Required: true}, }}, "web/req.ts::CreateUser": {Fields: []ShapeField{ {Name: "email", Type: "string", Required: true}, // missing password }}, } issues := Validate(reg, shapes.lookup) iss := findIssue(t, issues, IssueRequestFieldRequired, "password") if iss.Severity != SeverityBreaking { t.Errorf("severity = %q, want breaking", iss.Severity) } } // Request: consumer sends a field the provider doesn't accept — info. func TestValidate_Request_ExtraField_Info(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::POST::/x", map[string]any{"request_type": "api/req.go::X"}), makeContract(RoleConsumer, "web", "http::POST::/x", map[string]any{"request_type": "web/req.ts::X"}), ) shapes := shapeMap{ "api/req.go::X": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}}, "web/req.ts::X": {Fields: []ShapeField{ {Name: "id", Type: "string", Required: true}, {Name: "debug", Type: "boolean"}, }}, } issues := Validate(reg, shapes.lookup) iss := findIssue(t, issues, IssueRequestFieldExtra, "debug") if iss.Severity != SeverityInfo { t.Errorf("severity = %q, want info", iss.Severity) } } // Shared field with different types across provider / consumer. func TestValidate_TypeChanged_Breaking(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::GET::/thing", map[string]any{"response_type": "api/resp.go::Thing"}), makeContract(RoleConsumer, "web", "http::GET::/thing", map[string]any{"response_type": "web/resp.ts::Thing"}), ) shapes := shapeMap{ "api/resp.go::Thing": {Fields: []ShapeField{{Name: "id", Type: "int64", Required: true}}}, "web/resp.ts::Thing": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}}, } issues := Validate(reg, shapes.lookup) iss := findIssue(t, issues, IssueResponseFieldTypeChanged, "id") if iss.Severity != SeverityBreaking { t.Errorf("severity = %q, want breaking", iss.Severity) } if iss.Details == "" { t.Error("expected details with provider/consumer types") } } // Shape comparison is tolerant across language boundaries: // // - Nullable-optional variants: `*User` (Go) ≡ `User | null` (TS) ≡ `User | None` (Py). // - Collection variants: `[]User` ≡ `Array` ≡ `List` ≡ `User[]`. // - Package qualifier: `api.User` ≡ `User`. // - Cross-language primitive aliases: `string` ≡ `String` ≡ `str`, // `bool` ≡ `Boolean`, etc. // // Otherwise every cross-language contract would look broken. func TestValidate_TypesCompatible_AcrossLanguages(t *testing.T) { cases := [][2]string{ {"*User", "User | null"}, {"User", "User | None"}, {"[]User", "Array"}, {"List", "User[]"}, {"api.User", "User"}, {"string", "String"}, // Go / Dart {"string", "str"}, // Go / Python {"bool", "Boolean"}, // Go / Java / Kotlin {"int64", "Long"}, // Go / Java {"float64", "Double"}, // Go / Java / Dart {"time.Time", "DateTime"}, // Go / Dart {"time.Time", "Instant"}, // Go / Java (java.time) {"time.Time", "datetime"}, // Go / Python {"time.Time", "OffsetDateTime"}, // Go / Java {"DateTime", "Instant"}, // Dart / Java {"time.Duration", "Duration"}, // Go / Dart / Java {"time.Duration", "timedelta"}, // Go / Python {"[]byte", "Uint8List"}, // Go / Dart {"[]byte", "bytes"}, // Go / Python {"UUID", "Uuid"}, // Python / Rust } for _, c := range cases { if !canAssign(c[0], c[1]) { t.Errorf("canAssign(%q, %q) = false, want true (symmetric alias case)", c[0], c[1]) } if !canAssign(c[1], c[0]) { t.Errorf("canAssign(%q, %q) = false, want true (symmetric alias case)", c[1], c[0]) } } } // Directional numeric compatibility: int widens to float (JSON // numbers coerce), float → int truncates and is breaking. func TestValidate_CanAssign_NumericWidening(t *testing.T) { if !canAssign("float", "int") { t.Error("canAssign(float, int) = false, want true (JSON numbers widen)") } if canAssign("int", "float") { t.Error("canAssign(int, float) = true, want false (truncation risk)") } if !canAssign("Double", "int32") { t.Error("canAssign(Double, int32) across languages failed") } if canAssign("int64", "Double") { t.Error("canAssign(int64, Double) should fail — float → int is lossy") } } // `any` / `interface{}` / `Dynamic` / `object` are wire-level // universals. A slot typed `any` can hold anything. A slot typed `X` // can NOT safely hold `any` — you'd be trusting unchecked data. func TestValidate_CanAssign_AnyType(t *testing.T) { if !canAssign("any", "User") { t.Error("canAssign(any, User) = false, want true") } if !canAssign("interface{}", "int") { t.Error("canAssign(interface{}, int) = false, want true") } if canAssign("User", "any") { t.Error("canAssign(User, any) = true, want false (can't trust unchecked data)") } if canAssign("int", "Dynamic") { t.Error("canAssign(int, Dynamic) = true, want false") } } // Same field on both sides where the provider emits a string and // the consumer reads the same thing spelled differently by language. // Previously this was a `response_field_type_changed` breaking issue. // Now it's compatible and emits no issue. func TestValidate_Response_PrimitiveAlias_NoIssue(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "core-api", "http::GET::/x", map[string]any{ "response_type": "core-api/resp.go::Thing", }), makeContract(RoleConsumer, "tuck_app", "http::GET::/x", map[string]any{ "response_type": "tuck_app/resp.dart::Thing", }), ) shapes := shapeMap{ "core-api/resp.go::Thing": {Fields: []ShapeField{ {Name: "tuckId", Type: "string", Required: true}, {Name: "count", Type: "int", Required: true}, }}, "tuck_app/resp.dart::Thing": {Fields: []ShapeField{ {Name: "tuckId", Type: "String", Required: true}, // Consumer reads a float even though provider sends int — // widening is safe. {Name: "count", Type: "double", Required: true}, }}, } issues := Validate(reg, shapes.lookup) for _, is := range issues { if is.Kind == IssueResponseFieldTypeChanged { t.Errorf("unexpected response_field_type_changed for field %q: %s", is.Field, is.Details) } } } // UUIDs and bytes serialise as strings on the wire, so a `str` slot // can hold them — but the reverse isn't safe. The validator should // treat these widening directions and flag the narrow ones. func TestValidate_CanAssign_WireString_WideningOnly(t *testing.T) { // Wide: str can hold UUID / bytes (serialised form is a string). if !canAssign("string", "UUID") { t.Error("canAssign(string, UUID) = false, want true (UUID serialises as string)") } if !canAssign("String", "[]byte") { t.Error("canAssign(String, []byte) = false, want true (bytes serialise as base64 string)") } // Narrow: a string slot accepting an arbitrary string on the // consumer side when the provider declared a UUID — the consumer // might receive a non-UUID and fail at runtime if it expected // one. Breaking. if canAssign("UUID", "string") { t.Error("canAssign(UUID, string) = true, want false (arbitrary strings aren't UUIDs)") } if canAssign("bytes", "string") { t.Error("canAssign(bytes, string) = true, want false (arbitrary strings aren't base64)") } } // Realistic cross-language contract: provider is Go (time.Time, // []byte, UUID), consumer is Dart (DateTime, Uint8List, String). // None of these should flag as breaking. func TestValidate_Response_CrossLangTemporal_NoIssue(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "core-api", "http::GET::/x", map[string]any{ "response_type": "core-api/resp.go::Thing", }), makeContract(RoleConsumer, "tuck_app", "http::GET::/x", map[string]any{ "response_type": "tuck_app/resp.dart::Thing", }), ) shapes := shapeMap{ "core-api/resp.go::Thing": {Fields: []ShapeField{ {Name: "createdAt", Type: "time.Time", Required: true}, {Name: "ttl", Type: "time.Duration", Required: true}, {Name: "sessionId", Type: "uuid.UUID", Required: true}, {Name: "payload", Type: "[]byte", Required: true}, }}, "tuck_app/resp.dart::Thing": {Fields: []ShapeField{ {Name: "createdAt", Type: "DateTime", Required: true}, {Name: "ttl", Type: "Duration", Required: true}, // Consumer types sessionId as String — UUID serialises // that way and the slot accepts it. Not breaking. {Name: "sessionId", Type: "String", Required: true}, {Name: "payload", Type: "String", Required: true}, // base64 }}, } issues := Validate(reg, shapes.lookup) for _, is := range issues { if is.Kind == IssueResponseFieldTypeChanged { t.Errorf("unexpected type-change on %q: provider=%s consumer=%s — %s", is.Field, is.ProviderType, is.ConsumerType, is.Details) } } } // Request direction: provider expects int, consumer sends float → // breaking. The widening rule is direction-sensitive, and a value // that's "compatible" as a response read isn't as a request write. func TestValidate_Request_NarrowingBreaks(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::POST::/x", map[string]any{ "request_type": "api/req.go::X", }), makeContract(RoleConsumer, "web", "http::POST::/x", map[string]any{ "request_type": "web/req.ts::X", }), ) shapes := shapeMap{ "api/req.go::X": {Fields: []ShapeField{{Name: "n", Type: "int", Required: true}}}, "web/req.ts::X": {Fields: []ShapeField{{Name: "n", Type: "float", Required: true}}}, } issues := Validate(reg, shapes.lookup) found := false for _, is := range issues { if is.Kind == IssueRequestFieldTypeChanged && is.Field == "n" { found = true if is.Severity != SeverityBreaking { t.Errorf("want breaking, got %s", is.Severity) } } } if !found { t.Error("expected request_field_type_changed for n (float→int narrowing)") } } // Missing shape on one side → warning that diff was skipped, not breaking. func TestValidate_TypeUnknown_Warning(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::GET::/x", map[string]any{"response_type": "api/resp.go::X"}), makeContract(RoleConsumer, "web", "http::GET::/x", map[string]any{"response_type": "web/resp.ts::X"}), ) // Only provider shape is known; consumer type is a symbol ID we // can't resolve (cross-repo, not re-indexed). shapes := shapeMap{ "api/resp.go::X": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}}, } issues := Validate(reg, shapes.lookup) found := false for _, is := range issues { if is.Kind == IssueResponseTypeUnknown && is.Severity == SeverityWarning { found = true } } if !found { t.Errorf("expected response_type_unknown warning, got %#v", issues) } } // Matched pair with identical shapes → zero issues. func TestValidate_MatchingShapes_NoIssues(t *testing.T) { reg := buildRegistry( makeContract(RoleProvider, "api", "http::POST::/users", map[string]any{ "request_type": "api/req.go::User", "response_type": "api/resp.go::User", }), makeContract(RoleConsumer, "web", "http::POST::/users", map[string]any{ "request_type": "web/req.ts::User", "response_type": "web/resp.ts::User", }), ) shape := &Shape{Fields: []ShapeField{ {Name: "id", Type: "string", Required: true}, {Name: "email", Type: "string", Required: true}, }} shapes := shapeMap{ "api/req.go::User": shape, "api/resp.go::User": shape, "web/req.ts::User": shape, "web/resp.ts::User": shape, } issues := Validate(reg, shapes.lookup) if len(issues) != 0 { t.Errorf("want 0 issues, got %d: %#v", len(issues), issues) } }