diff --git a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/DescriptorsTest.kt b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/DescriptorsTest.kt index d8dce2347..49e95485b 100644 --- a/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/DescriptorsTest.kt +++ b/protocol/languages/kotlin/embedded-checkout-protocol/src/test/java/com/shopify/ucp/embedded/checkout/DescriptorsTest.kt @@ -1,10 +1,12 @@ package com.shopify.ucp.embedded.checkout import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test class DescriptorsTest { @@ -65,4 +67,69 @@ class DescriptorsTest { assertThat(descriptor.delegation).isNull() } + + @Test + fun `notification mappings compose and preserve wire decoding failures`() { + val descriptor = notificationDescriptor( + method = "ec.fixture", + paramsSerializer = FixtureParams.serializer(), + decode = { it.name }, + ).map { "Hello, $it!" }.map { it.length } + + assertThat(descriptor.method).isEqualTo("ec.fixture") + assertThat(descriptor.decode(JsonObject(mapOf("name" to JsonPrimitive("Ada"))))).isEqualTo(11) + assertThatThrownBy { descriptor.decode(JsonObject(emptyMap())) } + .isInstanceOf(SerializationException::class.java) + } + + @Test + fun `notification mapping skips missing payload and preserves mapping rejection`() { + val untyped = NotificationDescriptor("ec.fixture").map { + throw AssertionError("Mapping must not run without a decoded payload") + } + assertThat(untyped.decode(null)).isNull() + + val rejected = notificationDescriptor( + method = "ec.fixture", + paramsSerializer = FixtureParams.serializer(), + decode = { it.name }, + ).map { null }.map { throw AssertionError("Rejected payload reached next mapping") } + assertThat(rejected.decode(JsonObject(mapOf("name" to JsonPrimitive("Ada"))))).isNull() + } + + @Test + fun `request mappings compose in opposite directions for payload and result`() { + val descriptor: RequestDescriptor = requestDescriptor( + method = "ec.fixture", + delegation = "fixture.delegation", + requestSerializer = FixtureParams.serializer(), + responseSerializer = FixtureResult.serializer(), + decode = { it.name }, + encode = { FixtureResult(ok = it) }, + ) + val mapped = descriptor.map( + decode = { name -> name.length }, + encode = { count: Int -> count > 0 }, + ).map( + decode = { length -> "length=$length" }, + encode = { value: String -> value.toInt() }, + ) + assertThat(mapped.method).isEqualTo(descriptor.method) + assertThat(mapped.delegation).isEqualTo(descriptor.delegation) + assertThat(mapped.decode(JsonObject(mapOf("name" to JsonPrimitive("Ada"))))).isEqualTo("length=3") + assertThat(mapped.encode("1")).isEqualTo(JsonObject(mapOf("ok" to JsonPrimitive(true)))) + assertThat(mapped.encode("0")).isEqualTo(JsonObject(mapOf("ok" to JsonPrimitive(false)))) + assertThatThrownBy { mapped.decode(JsonObject(emptyMap())) } + .isInstanceOf(SerializationException::class.java) + } + + @Test + fun `request mapping preserves null delegation and skips missing payload`() { + val mapped = RequestDescriptor("ec.fixture", null).map( + decode = { _: String -> throw AssertionError("No payload to map") }, + encode = { value: Boolean -> value }, + ) + assertThat(mapped.delegation).isNull() + assertThat(mapped.decode(null)).isNull() + } } diff --git a/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/DescriptorMappingTests.swift b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/DescriptorMappingTests.swift new file mode 100644 index 000000000..a8172971a --- /dev/null +++ b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/DescriptorMappingTests.swift @@ -0,0 +1,58 @@ +@testable import EmbeddedCheckoutProtocol +import Foundation +import Testing + +private struct MappingPayload: EventPayload { + let name: String +} + +private struct MappingResult: ResponsePayload { + let greeting: String +} + +@Suite("Descriptor mapping") +struct DescriptorMappingTests { + @Test @MainActor func notificationMappingsComposeAfterDecoding() async throws { + let descriptor = NotificationDescriptor>( + method: "ec.fixture", + decode: { try JSONDecoder().decode(MappingPayload.self, from: $0) } + ).map { $0.params.name }.map { "Hello, \($0)!" } + var received: [String] = [] + let client = EmbeddedCheckoutProtocol.Client().on(descriptor) { received.append($0) } + + #expect(descriptor.method == "ec.fixture") + #expect(await client.process(#"{"jsonrpc":"2.0","method":"ec.fixture","params":{"name":"Ada"}}"#) == nil) + #expect(received == ["Hello, Ada!"]) + // A failed wire decode must never reach either the mapping or the handler. + #expect(await client.process(#"{"jsonrpc":"2.0","method":"ec.fixture","params":{"name":42}}"#) == nil) + #expect(received == ["Hello, Ada!"]) + } + + @Test(arguments: [nil, "fixture.greeting"]) + @MainActor func requestMappingPreservesIdentityAndWireResult(delegation: String?) async throws { + let descriptor = RequestDescriptor, MappingResult>( + method: "ec.fixture_request", + delegation: delegation, + decode: { try JSONDecoder().decode(MappingPayload.self, from: $0) } + ).map { $0.params.name }.map { "Hello, \($0)!" } + let client = EmbeddedCheckoutProtocol.Client().on(descriptor) { MappingResult(greeting: $0) } + + #expect(descriptor.method == "ec.fixture_request") + #expect(descriptor.delegation == delegation) + #expect(client.delegations == delegation.map { [$0] } ?? []) + let response = try #require(await client.process( + #"{"jsonrpc":"2.0","id":7,"method":"ec.fixture_request","params":{"name":"Ada"}}"# + )) + let object = try #require(try JSONSerialization.jsonObject(with: Data(response.utf8)) as? [String: Any]) + #expect(object["jsonrpc"] as? String == "2.0") + #expect(object["id"] as? Int == 7) + #expect(object["result"] as? [String: String] == ["greeting": "Hello, Ada!"]) + + let invalid = try #require(await client.process( + #"{"jsonrpc":"2.0","id":7,"method":"ec.fixture_request","params":{}}"# + )) + let error = try #require(try JSONSerialization.jsonObject(with: Data(invalid.utf8)) as? [String: Any]) + #expect((error["error"] as? [String: Any])?["code"] as? Int == -32602) + #expect(error["result"] == nil) + } +} diff --git a/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/JSONRPCIDTests.swift b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/JSONRPCIDTests.swift new file mode 100644 index 000000000..9b752fc4a --- /dev/null +++ b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/JSONRPCIDTests.swift @@ -0,0 +1,29 @@ +@testable import EmbeddedCheckoutProtocol +import Foundation +import Testing + +@Suite("JSON-RPC IDs") +struct JSONRPCIDTests { + @Test(arguments: [#""request-1""#, "0", "-1", "9223372036854775807", "-9223372036854775808", "null"]) + func roundTripsWireIdentity(json: String) throws { + let value = try JSONDecoder().decode(JSONRPCID.self, from: Data(json.utf8)) + #expect(String(decoding: try JSONEncoder().encode(value), as: UTF8.self) == json) + } + + @Test(arguments: ["true", "1.5", "9223372036854775808", "[]", "{}"]) + func rejectsUnsupportedIDs(json: String) { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSONRPCID.self, from: Data(json.utf8)) + } + } + + @Test func literalsPreserveStringAndIntegerIdentity() { + let string: JSONRPCID = "request-1" + let integer: JSONRPCID = 42 + #expect(string == .string("request-1")) + #expect(integer == .int(42)) + #expect(string.stringValue == "request-1") + #expect(integer.stringValue == nil) + #expect(JSONRPCID.null.stringValue == nil) + } +} diff --git a/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/JSONValueTests.swift b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/JSONValueTests.swift new file mode 100644 index 000000000..ed15041e4 --- /dev/null +++ b/protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests/JSONValueTests.swift @@ -0,0 +1,45 @@ +@testable import EmbeddedCheckoutProtocol +import Foundation +import Testing + +@Suite("JSON value contracts") +struct JSONValueTests { + @Test(arguments: [ + "true", "false", "42", "-42", "1.5", #""hello""#, "null", "[]", "{}", + #"[true,false,42,-42,1.5,"hello",null,[1],{"nested":true}]"#, + #"{"bool":true,"int":42,"double":1.5,"string":"hello","null":null,"array":[1],"object":{"nested":false}}"# + ]) + func preservesEveryJSONShape(json: String) throws { + let input = Data(json.utf8) + let value = try JSONDecoder().decode(JSONAny.self, from: input) + let encoded = try JSONEncoder().encode(value) + let expected = try JSONSerialization.jsonObject(with: input, options: .fragmentsAllowed) as AnyObject + let actual = try JSONSerialization.jsonObject(with: encoded, options: .fragmentsAllowed) as AnyObject + #expect(actual.isEqual(expected)) + } + + @Test func exposesNestedValuesWithoutLosingTheirTypes() throws { + let json = #"{"values":[true,42,1.5,"hello",null,{"nested":[false]}]}"# + let value = try JSONDecoder().decode(JSONAny.self, from: Data(json.utf8)) + let object = try #require(value.value as? [String: Any]) + let values = try #require(object["values"] as? [Any]) + #expect(values[0] as? Bool == true) + #expect(values[1] as? Int64 == 42) + #expect(values[2] as? Double == 1.5) + #expect(values[3] as? String == "hello") + #expect(values[4] is JSONNull) + let nested = try #require(values[5] as? [String: Any]) + #expect(nested["nested"] as? [Bool] == [false]) + } + + @Test func nullRoundTripsAndHasStableEquality() throws { + let first = try JSONDecoder().decode(JSONNull.self, from: Data("null".utf8)) + let second = JSONNull() + #expect(first == second) + #expect(Set([first, second]).count == 1) + #expect(String(decoding: try JSONEncoder().encode(first), as: UTF8.self) == "null") + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSONNull.self, from: Data("false".utf8)) + } + } +}