From cf7f5588b9da8e9ed66eefe77ab0ff0e2ca2d099 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:56:20 +0300 Subject: [PATCH 1/4] MOBILE-197: Send data-only payload in WebView sync onError The JS-bridge onError contract must match onValidationError: the contents of data directly, without the {type, data} envelope. createJSON() is unchanged - public API used by wrapper SDKs. --- .../Views/WebView/TransparentView.swift | 2 +- Mindbox/Model/MindboxError/MindboxError.swift | 35 ++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift index 80aff1d53..d0e11f029 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift @@ -486,7 +486,7 @@ extension TransparentView { return BridgeMessage( type: .error, action: action, - payload: .string(error.createJSON()), + payload: .string(error.createDataJSON()), id: id ) } diff --git a/Mindbox/Model/MindboxError/MindboxError.swift b/Mindbox/Model/MindboxError/MindboxError.swift index 33db0f4c2..494ebc639 100644 --- a/Mindbox/Model/MindboxError/MindboxError.swift +++ b/Mindbox/Model/MindboxError/MindboxError.swift @@ -142,45 +142,64 @@ public extension MindboxError { } return errorString } + + func convertDataToString() -> String { + guard + let errorData = try? JSONEncoder().encode(data), + let errorString = String(data: errorData, encoding: .utf8) else { + return #"{"errorMessage":"Unable to convert Data to JSON"}"# + } + return errorString + } } func createJSON() -> String { + makeErrorJSON().convertToString() + } + + /// Data-only JSON without the `{type, data}` envelope — the WebView JS-bridge + /// `onError` contract. `createJSON()` must keep the envelope: RN/Flutter dispatch on it. + internal func createDataJSON() -> String { + makeErrorJSON().convertDataToString() + } + + private func makeErrorJSON() -> MindboxErrorJSON { switch self { case .validationError(let error): return MindboxErrorJSON(status: error.status, - validationMessages: error.validationMessages).convertToString() + validationMessages: error.validationMessages) case .protocolError(let error): return MindboxErrorJSON(status: error.status, errorMessage: error.errorMessage, errorId: error.errorId ?? "", - httpStatusCode: error.httpStatusCode).convertToString() + httpStatusCode: error.httpStatusCode) case .serverError(let error): return MindboxErrorJSON(status: error.status, errorMessage: error.errorMessage, errorId: error.errorId ?? "", - httpStatusCode: error.httpStatusCode).convertToString() + httpStatusCode: error.httpStatusCode) case .internalError(let error): return MindboxErrorJSON(errorKey: error.errorKey, errorName: error.reason ?? "", - errorMessage: error.description).convertToString() + errorMessage: error.description) case .invalidResponse(let response): if let httpResponse = response as? HTTPURLResponse { let httpStatusCode = String(httpResponse.statusCode) let errorMessage = httpResponse.description return MindboxErrorJSON(httpStatusCode: httpStatusCode, - errorMessage: errorMessage).convertToString() + errorMessage: errorMessage) } else { return MindboxErrorJSON(httpStatusCode: "null", - errorMessage: "Connection error").convertToString() + errorMessage: "Connection error") } case .connectionError: return MindboxErrorJSON(httpStatusCode: "null", - errorMessage: "Connection error").convertToString() + errorMessage: "Connection error") case .unknown(let error): return MindboxErrorJSON(errorKey: "unknown", errorName: "", - errorMessage: error.localizedDescription).convertToString() + errorMessage: error.localizedDescription) } } } From f26f91dac6fb0e87ffd5c086a93431ca5bd62cab Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:56:20 +0300 Subject: [PATCH 2/4] MOBILE-197: Cover data-only sync error payload for all MindboxError kinds --- ...parentViewSyncOperationResponseTests.swift | 96 ++++++++++++++----- 1 file changed, 74 insertions(+), 22 deletions(-) diff --git a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift index bd245386e..812092873 100644 --- a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift +++ b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift @@ -118,43 +118,95 @@ struct TransparentViewSyncOperationResponseTests { } } - // MARK: - Failure (.connectionError) → .error with createJSON payload + // MARK: - Failure payloads: data contents only, no {type, data} envelope (MOBILE-197) - @Test("Connection failure becomes .error with MindboxError.createJSON payload") - func connectionError_becomesError() { + private func decodedErrorPayload(_ outgoing: BridgeMessage) throws -> [String: Any] { + #expect(outgoing.type == .error) + var jsonString: String? + if case .string(let value) = outgoing.payload { jsonString = value } + let json = try #require(jsonString, "Expected .string payload, got \(String(describing: outgoing.payload))") + let data = try #require(json.data(using: .utf8)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["type"] == nil, "Payload must not carry the {type, data} envelope") + #expect(object["data"] == nil, "Payload must not carry the {type, data} envelope") + return object + } + + @Test("Protocol error payload is the data contents: status, errorMessage, httpStatusCode, errorId") + func protocolError_payloadIsDataContentsOnly() throws { + let pe = ProtocolError(status: .protocolError, errorMessage: "Operation Test not found", httpStatusCode: 400, errorId: "error-id-1") + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.protocolError(pe)), + action: action, + id: requestId + ) + + let payload = try decodedErrorPayload(outgoing) + #expect(payload["status"] as? String == "ProtocolError") + #expect(payload["errorMessage"] as? String == "Operation Test not found") + #expect(payload["httpStatusCode"] as? String == "400") + #expect(payload["errorId"] as? String == "error-id-1") + } + + @Test("Server error payload is the data contents with InternalServerError status") + func serverError_payloadIsDataContentsOnly() throws { + let pe = ProtocolError(status: .internalServerError, errorMessage: "Something went wrong", httpStatusCode: 500) + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.serverError(pe)), + action: action, + id: requestId + ) + + let payload = try decodedErrorPayload(outgoing) + #expect(payload["status"] as? String == "InternalServerError") + #expect(payload["errorMessage"] as? String == "Something went wrong") + #expect(payload["httpStatusCode"] as? String == "500") + } + + @Test("Connection failure payload is the data contents without the NetworkError envelope") + func connectionError_payloadIsDataContentsOnly() throws { let outgoing = TransparentView.makeSyncOperationResponse( result: .failure(.connectionError), action: action, id: requestId ) - #expect(outgoing.type == .error) - if case .string(let value) = outgoing.payload { - #expect(value.contains("NetworkError"), "createJSON for connectionError produces a NetworkError envelope") - #expect(value.contains("Connection error")) - } else { - Issue.record("Expected .string payload") - } + let payload = try decodedErrorPayload(outgoing) + #expect(payload["httpStatusCode"] as? String == "null") + #expect(payload["errorMessage"] as? String == "Connection error") } - // MARK: - Failure (.protocolError) → .error with createJSON payload + @Test("Validation error payload is the data contents with validationMessages") + func validationError_payloadIsDataContentsOnly() throws { + let ve = ValidationError( + status: .validationError, + validationMessages: [ValidationMessage(message: "Invalid email", location: "/customer/email")] + ) + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.validationError(ve)), + action: action, + id: requestId + ) + + let payload = try decodedErrorPayload(outgoing) + #expect(payload["status"] as? String == "ValidationError") + let messages = try #require(payload["validationMessages"] as? [[String: Any]]) + #expect(messages.count == 1) + #expect(messages.first?["message"] as? String == "Invalid email") + #expect(messages.first?["location"] as? String == "/customer/email") + } - @Test("Protocol error becomes .error with MindboxError.createJSON payload") - func protocolError_becomesError() { - let pe = ProtocolError(status: .protocolError, errorMessage: "Bad", httpStatusCode: 400) + @Test("Internal error payload is the data contents with errorKey") + func internalError_payloadIsDataContentsOnly() throws { let outgoing = TransparentView.makeSyncOperationResponse( - result: .failure(.protocolError(pe)), + result: .failure(.internalError(InternalError(errorKey: .parsing, reason: "Broken body"))), action: action, id: requestId ) - #expect(outgoing.type == .error) - if case .string(let value) = outgoing.payload { - #expect(value.contains("MindboxError")) - #expect(value.contains("ProtocolError")) - } else { - Issue.record("Expected .string payload") - } + let payload = try decodedErrorPayload(outgoing) + #expect(payload["errorKey"] as? String == "Error_parsing") + #expect(payload["errorName"] as? String == "Broken body") } // MARK: - id and action propagated From 172f6220e8b3415f9315cba9e03573b3fa5de568 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:40:23 +0300 Subject: [PATCH 3/4] MOBILE-197: Make the error-JSON fallback valid JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convertToString() fallback had unquoted keys, trailing commas and misspelled errroKey/errroName — unparseable by any JS consumer. Flagged by Copilot on the PR; pre-existing, but it belongs to this contract. --- Mindbox/Model/MindboxError/MindboxError.swift | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/Mindbox/Model/MindboxError/MindboxError.swift b/Mindbox/Model/MindboxError/MindboxError.swift index 494ebc639..cf64d0d13 100644 --- a/Mindbox/Model/MindboxError/MindboxError.swift +++ b/Mindbox/Model/MindboxError/MindboxError.swift @@ -128,17 +128,7 @@ public extension MindboxError { guard let errorData = try? JSONEncoder().encode(self), let errorString = String(data: errorData, encoding: .utf8) else { - return - """ - { - type: "InternalError", - data: { - errroKey: "\(self.data.errorKey ?? "null")", - errroName: "JSON encoding error", - errorMessage: "Unable to convert Data to JSON", - } - } - """ + return #"{"type":"InternalError","data":{"errorKey":"\#(self.data.errorKey ?? "null")","errorName":"JSON encoding error","errorMessage":"Unable to convert Data to JSON"}}"# } return errorString } From 7c6e52adc4dd002b5e3ecd65262c86fa4b30f0a5 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:40:24 +0300 Subject: [PATCH 4/4] MOBILE-197: Cover invalidResponse and unknown data-only payloads --- ...parentViewSyncOperationResponseTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift index 812092873..ba406fa24 100644 --- a/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift +++ b/MindboxTests/InApp/Tests/TransparentViewSyncOperationResponseTests.swift @@ -209,6 +209,37 @@ struct TransparentViewSyncOperationResponseTests { #expect(payload["errorName"] as? String == "Broken body") } + @Test("Invalid response payload is the data contents with httpStatusCode from the response") + func invalidResponse_payloadIsDataContentsOnly() throws { + let url = try #require(URL(string: "https://api.mindbox.ru/v3/operations/sync")) + let httpResponse = try #require(HTTPURLResponse(url: url, statusCode: 403, httpVersion: nil, headerFields: nil)) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.invalidResponse(httpResponse)), + action: action, + id: requestId + ) + + let payload = try decodedErrorPayload(outgoing) + #expect(payload["httpStatusCode"] as? String == "403") + #expect((payload["errorMessage"] as? String)?.isEmpty == false) + } + + @Test("Unknown error payload is the data contents with errorKey 'unknown'") + func unknownError_payloadIsDataContentsOnly() throws { + let underlying = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Something exploded"]) + + let outgoing = TransparentView.makeSyncOperationResponse( + result: .failure(.unknown(underlying)), + action: action, + id: requestId + ) + + let payload = try decodedErrorPayload(outgoing) + #expect(payload["errorKey"] as? String == "unknown") + #expect(payload["errorMessage"] as? String == "Something exploded") + } + // MARK: - id and action propagated @Test("Action and id from the request are preserved on the outgoing message")