From 4f2aa765a3d0deed8043ef8759c7ca0c10e8caca Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 7 Oct 2026 13:32:41 +0100 Subject: [PATCH] Cover native protocol model serialization contracts --- .../embedded/checkout/ModelContractTest.kt | 114 ++++++++++++ .../src/test/resources/model-contracts.json | 169 ++++++++++++++++++ .../Fixtures/model-contracts.json | 169 ++++++++++++++++++ .../ModelContractTests.swift | 106 +++++++++++ 4 files changed, 558 insertions(+) create mode 100644 protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/ModelContractTest.kt create mode 100644 protocol/languages/kotlin/embedded-checkout-protocol/src/test/resources/model-contracts.json create mode 100644 protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/Fixtures/model-contracts.json create mode 100644 protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/ModelContractTests.swift 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 + } +}