diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ModelContractTest.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ModelContractTest.kt
new file mode 100644
index 000000000..feeeb319e
--- /dev/null
+++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ModelContractTest.kt
@@ -0,0 +1,114 @@
+package com.shopify.ucp.embedded.checkout
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.encodeToJsonElement
+import kotlinx.serialization.json.jsonObject
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.Test
+
+class ModelContractTest {
+ private val json = Json { ignoreUnknownKeys = true }
+ private val fixtures = json.parseToJsonElement(
+ requireNotNull(javaClass.getResource("/model-contracts.json")).readText(),
+ ).jsonObject
+
+ @Test
+ fun `buyer preserves names and extensions`() {
+ val buyer = roundTrip(Buyer.serializer(), "buyer", emptySet()) {
+ it.copy(additionalProperties = it.additionalProperties + ("email" to JsonPrimitive("forged")))
+ }
+ assertThat(buyer.email).isEqualTo("buyer@example.com")
+ assertThat(buyer.firstName).isEqualTo("Ada")
+ assertThat(buyer.lastName).isEqualTo("Example")
+ assertThat(buyer.phoneNumber).isEqualTo("+12025550123")
+ assertThat(buyer.additionalProperties.keys).containsExactly("com.example.extension")
+ }
+
+ @Test
+ fun `context preserves localization and payment preferences`() {
+ val context = roundTrip(Context.serializer(), "context", emptySet()) {
+ it.copy(additionalProperties = it.additionalProperties + ("currency" to JsonPrimitive("forged")))
+ }
+ assertThat(context.addressCountry).isEqualTo("IE")
+ assertThat(context.addressRegion).isEqualTo("Dublin")
+ assertThat(context.postalCode).isEqualTo("D02 TEST")
+ assertThat(context.currency).isEqualTo("EUR")
+ assertThat(context.eligibility).containsExactly("com.example.member")
+ assertThat(context.intent).isEqualTo("gift")
+ assertThat(context.language).isEqualTo("en-IE")
+ assertThat(context.location).isEqualTo("Dublin")
+ assertThat(context.payment?.first()?.handler).isEqualTo("com.example.payment")
+ assertThat(context.payment?.first()?.types).containsExactly("card")
+ assertThat(context.additionalProperties.keys).containsExactly("com.example.extension")
+ }
+
+ @Test
+ fun `credential preserves opaque handler data`() {
+ val credential = roundTrip(PaymentCredential.serializer(), "credential", setOf("type")) {
+ it.copy(additionalProperties = it.additionalProperties + ("type" to JsonPrimitive("forged")))
+ }
+ assertThat(credential.type).isEqualTo("token")
+ assertThat(
+ credential.additionalProperties["com.example.token"]
+ ).isEqualTo(JsonPrimitive("synthetic-test-token"))
+ assertThat(credential.additionalProperties).doesNotContainKey("type")
+ }
+
+ @Test
+ fun `instrument preserves nested credential and billing address`() {
+ val instrument =
+ roundTrip(SelectedPaymentInstrument.serializer(), "instrument", setOf("handler_id", "id", "type")) {
+ it.copy(additionalProperties = it.additionalProperties + ("handler_id" to JsonPrimitive("forged")))
+ }
+ assertThat(instrument.handlerID).isEqualTo("example-handler")
+ assertThat(instrument.id).isEqualTo("example-instrument")
+ assertThat(instrument.type).isEqualTo("card")
+ assertThat(instrument.selected).isTrue()
+ assertThat(instrument.billingAddress?.streetAddress).isEqualTo("1 Example Street")
+ assertThat(instrument.credential?.type).isEqualTo("token")
+ assertThat(instrument.credential?.additionalProperties).containsKey("com.example.extension")
+ assertThat(instrument.display?.get("last_digits")).isEqualTo(JsonPrimitive("1234"))
+ }
+
+ @Test
+ fun `policy preserves description formats and open type`() {
+ val policy = roundTrip(Policy.serializer(), "policy", setOf("description", "type")) {
+ it.copy(additionalProperties = it.additionalProperties + ("type" to JsonPrimitive("forged")))
+ }
+ assertThat(policy.type).isEqualTo("com.example.return")
+ assertThat(policy.appliesTo).containsExactly("$.line_items[0]")
+ assertThat(policy.description.plain).isEqualTo("Return within 30 days")
+ assertThat(policy.description.markdown).isEqualTo("Return within **30 days**")
+ assertThat(policy.description.html).isEqualTo("Return within 30 days")
+ assertThat(policy.url).isEqualTo("https://example.com/returns")
+ }
+
+ private fun roundTrip(
+ serializer: KSerializer,
+ fixture: String,
+ required: Set,
+ injectCollision: (T) -> T,
+ ): T {
+ val full = fixtures.getValue(fixture).jsonObject
+ val decoded = json.decodeFromJsonElement(serializer, full)
+ assertThat(json.encodeToJsonElement(serializer, decoded)).isEqualTo(full)
+ // Extension keys cannot replace known fields when encoded.
+ assertThat(json.encodeToJsonElement(serializer, injectCollision(decoded))).isEqualTo(full)
+
+ val minimal = JsonObject(full.filterKeys { it in required })
+ val minimalModel = json.decodeFromJsonElement(serializer, minimal)
+ assertThat(json.encodeToJsonElement(serializer, minimalModel)).isEqualTo(minimal)
+ required.forEach { key ->
+ assertThatThrownBy { json.decodeFromJsonElement(serializer, JsonObject(full - key)) }
+ .describedAs("Missing required field %s in %s", key, fixture)
+ .isInstanceOf(SerializationException::class.java)
+ }
+ return decoded
+ }
+}
diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/resources/model-contracts.json b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/resources/model-contracts.json
new file mode 100644
index 000000000..b751732c0
--- /dev/null
+++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/resources/model-contracts.json
@@ -0,0 +1,169 @@
+{
+ "buyer": {
+ "email": "buyer@example.com",
+ "first_name": "Ada",
+ "last_name": "Example",
+ "phone_number": "+12025550123",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "context": {
+ "address_country": "IE",
+ "address_region": "Dublin",
+ "postal_code": "D02 TEST",
+ "currency": "EUR",
+ "eligibility": [
+ "com.example.member"
+ ],
+ "intent": "gift",
+ "language": "en-IE",
+ "location": "Dublin",
+ "payment": [
+ {
+ "handler": "com.example.payment",
+ "types": [
+ "card"
+ ]
+ }
+ ],
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "credential": {
+ "type": "token",
+ "com.example.token": "synthetic-test-token",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "instrument": {
+ "billing_address": {
+ "first_name": "Ada",
+ "last_name": "Example",
+ "street_address": "1 Example Street",
+ "address_locality": "Dublin",
+ "address_region": "Dublin",
+ "postal_code": "D02 TEST",
+ "address_country": "IE"
+ },
+ "credential": {
+ "type": "token",
+ "com.example.token": "synthetic-test-token",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "display": {
+ "label": "Test card",
+ "last_digits": "1234"
+ },
+ "handler_id": "example-handler",
+ "id": "example-instrument",
+ "type": "card",
+ "selected": true,
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "policy": {
+ "applies_to": [
+ "$.line_items[0]"
+ ],
+ "description": {
+ "plain": "Return within 30 days",
+ "markdown": "Return within **30 days**",
+ "html": "Return within 30 days"
+ },
+ "type": "com.example.return",
+ "url": "https://example.com/returns",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ }
+}
diff --git a/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/Fixtures/model-contracts.json b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/Fixtures/model-contracts.json
new file mode 100644
index 000000000..b751732c0
--- /dev/null
+++ b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/Fixtures/model-contracts.json
@@ -0,0 +1,169 @@
+{
+ "buyer": {
+ "email": "buyer@example.com",
+ "first_name": "Ada",
+ "last_name": "Example",
+ "phone_number": "+12025550123",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "context": {
+ "address_country": "IE",
+ "address_region": "Dublin",
+ "postal_code": "D02 TEST",
+ "currency": "EUR",
+ "eligibility": [
+ "com.example.member"
+ ],
+ "intent": "gift",
+ "language": "en-IE",
+ "location": "Dublin",
+ "payment": [
+ {
+ "handler": "com.example.payment",
+ "types": [
+ "card"
+ ]
+ }
+ ],
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "credential": {
+ "type": "token",
+ "com.example.token": "synthetic-test-token",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "instrument": {
+ "billing_address": {
+ "first_name": "Ada",
+ "last_name": "Example",
+ "street_address": "1 Example Street",
+ "address_locality": "Dublin",
+ "address_region": "Dublin",
+ "postal_code": "D02 TEST",
+ "address_country": "IE"
+ },
+ "credential": {
+ "type": "token",
+ "com.example.token": "synthetic-test-token",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "display": {
+ "label": "Test card",
+ "last_digits": "1234"
+ },
+ "handler_id": "example-handler",
+ "id": "example-instrument",
+ "type": "card",
+ "selected": true,
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ },
+ "policy": {
+ "applies_to": [
+ "$.line_items[0]"
+ ],
+ "description": {
+ "plain": "Return within 30 days",
+ "markdown": "Return within **30 days**",
+ "html": "Return within 30 days"
+ },
+ "type": "com.example.return",
+ "url": "https://example.com/returns",
+ "com.example.extension": {
+ "nested": {
+ "flags": [
+ true,
+ false,
+ null
+ ],
+ "count": 3,
+ "fraction": 1.5
+ },
+ "items": [
+ "future",
+ {
+ "enabled": true
+ }
+ ]
+ }
+ }
+}
diff --git a/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/ModelContractTests.swift b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/ModelContractTests.swift
new file mode 100644
index 000000000..72688217e
--- /dev/null
+++ b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/ModelContractTests.swift
@@ -0,0 +1,106 @@
+@testable import EmbeddedCheckoutProtocol
+import Foundation
+import Testing
+
+@Suite("Generated model wire contracts")
+struct ModelContractTests {
+ @Test func buyerPreservesNamesAndExtensions() throws {
+ let buyer = try roundTrip(Buyer.self, fixture: "buyer", required: []) {
+ $0.additionalProperties["email"] = $1
+ }
+ #expect(buyer.email == "buyer@example.com")
+ #expect(buyer.firstName == "Ada")
+ #expect(buyer.lastName == "Example")
+ #expect(buyer.phoneNumber == "+12025550123")
+ #expect(buyer.additionalProperties.keys.sorted() == ["com.example.extension"])
+ }
+
+ @Test func contextPreservesLocalizationAndPaymentPreferences() throws {
+ let context = try roundTrip(Context.self, fixture: "context", required: []) {
+ $0.additionalProperties["currency"] = $1
+ }
+ #expect(context.addressCountry == "IE")
+ #expect(context.addressRegion == "Dublin")
+ #expect(context.postalCode == "D02 TEST")
+ #expect(context.currency == "EUR")
+ #expect(context.eligibility == ["com.example.member"])
+ #expect(context.intent == "gift")
+ #expect(context.language == "en-IE")
+ #expect(context.location == "Dublin")
+ #expect(context.payment?.first?.handler == "com.example.payment")
+ #expect(context.payment?.first?.types == ["card"])
+ #expect(context.additionalProperties.keys.sorted() == ["com.example.extension"])
+ }
+
+ @Test func credentialPreservesOpaqueHandlerData() throws {
+ let credential = try roundTrip(PaymentCredential.self, fixture: "credential", required: ["type"]) {
+ $0.additionalProperties["type"] = $1
+ }
+ #expect(credential.type == "token")
+ #expect(credential.additionalProperties["com.example.token"]?.value as? String == "synthetic-test-token")
+ #expect(credential.additionalProperties["type"] == nil)
+ }
+
+ @Test func instrumentPreservesNestedCredentialAndBillingAddress() throws {
+ let instrument = try roundTrip(
+ SelectedPaymentInstrument.self, fixture: "instrument", required: ["handler_id", "id", "type"]
+ ) { $0.additionalProperties["handler_id"] = $1 }
+ #expect(instrument.handlerID == "example-handler")
+ #expect(instrument.id == "example-instrument")
+ #expect(instrument.type == "card")
+ #expect(instrument.selected == true)
+ #expect(instrument.billingAddress?.streetAddress == "1 Example Street")
+ #expect(instrument.credential?.type == "token")
+ #expect(instrument.credential?.additionalProperties["com.example.extension"] != nil)
+ #expect(instrument.display?["last_digits"]?.value as? String == "1234")
+ }
+
+ @Test func policyPreservesDescriptionFormatsAndOpenType() throws {
+ let policy = try roundTrip(Policy.self, fixture: "policy", required: ["description", "type"]) {
+ $0.additionalProperties["type"] = $1
+ }
+ #expect(policy.type == "com.example.return")
+ #expect(policy.appliesTo == ["$.line_items[0]"])
+ #expect(policy.description.plain == "Return within 30 days")
+ #expect(policy.description.markdown == "Return within **30 days**")
+ #expect(policy.description.html == "Return within 30 days")
+ #expect(policy.url == "https://example.com/returns")
+ }
+
+ private func roundTrip(
+ _ type: Model.Type,
+ fixture: String,
+ required: Set,
+ injectCollision: (inout Model, JSONAny) -> Void
+ ) throws -> Model {
+ let fixtures = try #require(try JSONSerialization.jsonObject(
+ with: Data(protocolFixture("model-contracts").utf8)
+ ) as? [String: [String: Any]])
+ let full = try #require(fixtures[fixture])
+ let encoder = newJSONEncoder()
+ let decoder = newJSONDecoder()
+ let data = try JSONSerialization.data(withJSONObject: full)
+ let decoded = try decoder.decode(type, from: data)
+ let encoded = try #require(try JSONSerialization.jsonObject(with: encoder.encode(decoded)) as? NSDictionary)
+ #expect(encoded == full as NSDictionary)
+
+ // Extension data cannot replace schema-owned fields during serialization.
+ var colliding = decoded
+ injectCollision(&colliding, try decoder.decode(JSONAny.self, from: Data(#""forged""#.utf8)))
+ let collision = try #require(try JSONSerialization.jsonObject(with: encoder.encode(colliding)) as? NSDictionary)
+ #expect(collision == full as NSDictionary)
+
+ // Optional fields may be absent; each required field must independently fail.
+ let minimal = full.filter { required.contains($0.key) }
+ let minimalModel = try decoder.decode(type, from: JSONSerialization.data(withJSONObject: minimal))
+ let minimalEncoded = try #require(try JSONSerialization.jsonObject(with: encoder.encode(minimalModel)) as? NSDictionary)
+ #expect(minimalEncoded == minimal as NSDictionary)
+ for key in required {
+ let missing = full.filter { $0.key != key }
+ #expect(throws: DecodingError.self) {
+ try decoder.decode(type, from: JSONSerialization.data(withJSONObject: missing))
+ }
+ }
+ return decoded
+ }
+}