Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 82 additions & 44 deletions Mindbox.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -386,32 +386,64 @@ extension TransparentView {

Logger.common(message: "[WebView] syncOperation '\(params.name)' sending", level: .info, category: .webViewInAppMessages)

eventRepository.send(type: OperationResponse.self, event: event) { [weak self] result in
// HTTP 2xx → forward the raw body to JS as a Response so the JS Tracker
// can dispatch onSuccess / onValidationError by the body's `status`.
// 4xx, 5xx and network failures stay on the MindboxError → Error path.
eventRepository.sendRaw(event: event) { [weak self] result in
DispatchQueue.main.async {
switch result {
case .success(let response):
let outgoing = TransparentView.makeSyncOperationResponse(
result: result,
action: message.action,
id: message.id
)
switch outgoing.type {
case .response:
Logger.common(message: "[WebView] syncOperation '\(params.name)' success", level: .info, category: .webViewInAppMessages)
let responseJSON = response.createJSON()
let successResponse = BridgeMessage(
type: .response,
action: message.action,
payload: .string(responseJSON),
id: message.id
)
self?.facade?.sendToJS(successResponse)

case .failure(let error):
Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages)
let errorJSON = error.createJSON()
let errorResponse = BridgeMessage(
type: .error,
action: message.action,
payload: .string(errorJSON),
id: message.id
)
self?.facade?.sendToJS(errorResponse)
case .error:
if case .failure(let error) = result {
Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages)
} else {
Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: non-UTF-8 response body", level: .error, category: .webViewInAppMessages)
}
default:
break
}
self?.facade?.sendToJS(outgoing)
}
}
}

/// Maps the raw `sendRaw` result of a `syncOperation` request to the outgoing
/// `BridgeMessage` sent back to JS. Pure function — no side effects — extracted
/// to keep the JS-bridge contract independently unit-testable.
static func makeSyncOperationResponse(
result: Result<Data, MindboxError>,
action: String,
id: UUID
) -> BridgeMessage {
switch result {
case .success(let data):
guard let bodyString = String(data: data, encoding: .utf8) else {
return BridgeMessage(
type: .error,
action: action,
payload: .object(["error": .string("Response body is not valid UTF-8")]),
id: id
)
}
return BridgeMessage(
type: .response,
action: action,
payload: .string(bodyString),
id: id
)
case .failure(let error):
return BridgeMessage(
type: .error,
action: action,
payload: .string(error.createJSON()),
id: id
)
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions Mindbox/Network/Abstract/NetworkFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ protocol NetworkFetcher {
completion: @escaping ((Result<Void, MindboxError>) -> Void)
)

/// Returns the raw HTTP 2xx response body without parsing `BaseResponse`,
/// so the caller can decide how to interpret it. 4xx, 5xx and network
/// failures still surface as `MindboxError` through the shared response
/// pipeline.
func requestRaw(
route: Route,
completion: @escaping ((Result<Data, MindboxError>) -> Void)
)

/// Cancels all ongoing network tasks.
func cancelAllTasks()
}
Expand Down
41 changes: 41 additions & 0 deletions Mindbox/Network/MBNetworkFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,47 @@ class MBNetworkFetcher: NetworkFetcher {
}
}

func requestRaw(
route: Route,
completion: @escaping (Result<Data, MindboxError>) -> Void
) {
guard let configuration = persistenceStorage.configuration else {
let error = MindboxError(.init(
errorKey: .invalidConfiguration,
reason: "Configuration is not set"
))
Logger.error(error.asLoggerError())
completion(.failure(error))
return
}

let builder = URLRequestBuilder(
domain: configuration.domain,
operationsDomain: resolvedOperationsDomain(configuration: configuration)
)
do {
let urlRequest = try builder.asURLRequest(route: route)
Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders)
let startTime = CFAbsoluteTimeGetCurrent()
session.dataTask(with: urlRequest) { data, response, error in
let networkTimeMs = Int((CFAbsoluteTimeGetCurrent() - startTime) * 1000)
self.handleResponse(data, response, error, needBaseResponse: false, networkTimeMs: networkTimeMs) { result in
switch result {
case let .success(data):
completion(.success(data))
case let .failure(error):
Logger.error(error.asLoggerError())
completion(.failure(error))
}
}
}.resume()
} catch let error {
let errorModel = MindboxError.unknown(error)
Logger.error(errorModel.asLoggerError())
completion(.failure(errorModel))
}
}

private func handleResponse(
_ data: Data?,
_ response: URLResponse?,
Expand Down
8 changes: 7 additions & 1 deletion Mindbox/NetworkRepository/Event/EventRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import MindboxLogger
protocol EventRepository {
func send(event: Event, completion: @escaping (Result<Void, MindboxError>) -> Void)
func send<T>(type: T.Type, event: Event, completion: @escaping (Result<T, MindboxError>) -> Void) where T: Decodable


/// Sends an event and returns the raw HTTP 2xx response body. Skips
/// `BaseResponse` parsing so the caller can dispatch on the body itself
/// (e.g. forward the bytes verbatim to the WebView JS bridge so the JS
/// Tracker can route by the body's `status` field).
func sendRaw(event: Event, completion: @escaping (Result<Data, MindboxError>) -> Void)

/// Cancels all ongoing network requests associated with this repository.
func cancelAllRequests()
}
35 changes: 35 additions & 0 deletions Mindbox/NetworkRepository/Event/MBEventRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,41 @@ class MBEventRepository: EventRepository {
})
}

func sendRaw(event: Event, completion: @escaping (Result<Data, MindboxError>) -> Void) {
guard let configuration = persistenceStorage.configuration else {
let error = MindboxError(.init(
errorKey: .invalidConfiguration,
reason: "Configuration is not set"
))
completion(.failure(error))
return
}
guard let deviceUUID = persistenceStorage.deviceUUID else {
let error = MindboxError(.init(
errorKey: .invalidConfiguration,
reason: "DeviceUUID is not set"
))
completion(.failure(error))
return
}
let wrapper = EventWrapper(
event: event,
endpoint: configuration.endpoint,
deviceUUID: deviceUUID
)
let route = makeRoute(wrapper: wrapper)
fetcher.requestRaw(route: route) { result in
DispatchQueue.main.async {
switch result {
case let .failure(error):
completion(.failure(error))
case let .success(data):
completion(.success(data))
}
}
}
}

private func makeRoute(wrapper: EventWrapper) -> Route {
switch wrapper.event.type {
case .installed,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//
// TransparentViewSyncOperationResponseTests.swift
// MindboxTests
//

import Testing
import Foundation
@_spi(Internal) @testable import Mindbox

@Suite("TransparentView.makeSyncOperationResponse")
struct TransparentViewSyncOperationResponseTests {

private let action = "syncOperation"
private let requestId = UUID()

// MARK: - HTTP 200 + ValidationError body → .response with raw body (regression: MOBILE-164)

@Test("HTTP 200 ValidationError body becomes .response with raw body string")
func validationErrorBody_becomesResponseWithRawBody() throws {
let rawBody = #"{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}"#
let data = try #require(rawBody.data(using: .utf8))

let outgoing = TransparentView.makeSyncOperationResponse(
result: .success(data),
action: action,
id: requestId
)

#expect(outgoing.type == .response)
#expect(outgoing.action == action)
#expect(outgoing.id == requestId)
if case .string(let value) = outgoing.payload {
#expect(value == rawBody, "Payload must be the raw body, not re-serialized")
} else {
Issue.record("Expected .string payload, got \(String(describing: outgoing.payload))")
}
}

// MARK: - HTTP 200 + Success body → .response with raw body

@Test("HTTP 200 Success body becomes .response with raw body string (not re-serialized)")
func successBody_becomesResponseWithRawBody() throws {
let rawBody = #"{"status":"Success","customer":{"email":"a@b.c"}}"#
let data = try #require(rawBody.data(using: .utf8))

let outgoing = TransparentView.makeSyncOperationResponse(
result: .success(data),
action: action,
id: requestId
)

#expect(outgoing.type == .response)
if case .string(let value) = outgoing.payload {
#expect(value == rawBody)
} else {
Issue.record("Expected .string payload, got \(String(describing: outgoing.payload))")
}
}

// MARK: - HTTP 200 + non-JSON body → .response with raw body string

@Test("HTTP 200 with non-JSON body still becomes .response (JS decides)")
func nonJSONBody_becomesResponseWithRawBody() throws {
let rawBody = "plain text body"
let data = try #require(rawBody.data(using: .utf8))

let outgoing = TransparentView.makeSyncOperationResponse(
result: .success(data),
action: action,
id: requestId
)

#expect(outgoing.type == .response)
if case .string(let value) = outgoing.payload {
#expect(value == rawBody)
} else {
Issue.record("Expected .string payload")
}
}

// MARK: - HTTP 200 + empty body → .response with empty string

@Test("HTTP 200 with empty body becomes .response with empty string payload")
func emptyBody_becomesResponseWithEmptyString() {
let outgoing = TransparentView.makeSyncOperationResponse(
result: .success(Data()),
action: action,
id: requestId
)

#expect(outgoing.type == .response)
if case .string(let value) = outgoing.payload {
#expect(value == "")
} else {
Issue.record("Expected .string payload")
}
}

// MARK: - Non-UTF-8 body → .error with explanatory payload

@Test("Non-UTF-8 body becomes .error with 'Response body is not valid UTF-8'")
func nonUTF8Body_becomesError() {
// Bytes that are not valid UTF-8: lone continuation byte 0xC3 + invalid follow-up
let data = Data([0xC3, 0x28])

let outgoing = TransparentView.makeSyncOperationResponse(
result: .success(data),
action: action,
id: requestId
)

#expect(outgoing.type == .error)
if case .object(let dict) = outgoing.payload,
case .string(let errorMessage) = dict["error"] {
#expect(errorMessage == "Response body is not valid UTF-8")
} else {
Issue.record("Expected .object payload with 'error' key, got \(String(describing: outgoing.payload))")
}
}

// MARK: - Failure (.connectionError) → .error with createJSON payload

@Test("Connection failure becomes .error with MindboxError.createJSON payload")
func connectionError_becomesError() {
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")
}
}

// MARK: - Failure (.protocolError) → .error with createJSON payload

@Test("Protocol error becomes .error with MindboxError.createJSON payload")
func protocolError_becomesError() {
let pe = ProtocolError(status: .protocolError, errorMessage: "Bad", httpStatusCode: 400)
let outgoing = TransparentView.makeSyncOperationResponse(
result: .failure(.protocolError(pe)),
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")
}
}

// MARK: - id and action propagated

@Test("Action and id from the request are preserved on the outgoing message")
func actionAndIdPreserved() throws {
let specificAction = "customAction"
let specificId = UUID()
let data = try #require("body".data(using: .utf8))

let outgoing = TransparentView.makeSyncOperationResponse(
result: .success(data),
action: specificAction,
id: specificId
)

#expect(outgoing.action == specificAction)
#expect(outgoing.id == specificId)
}
}
6 changes: 5 additions & 1 deletion MindboxTests/MindboxLogger/Mocks/EventRepositoryMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class EventRepositoryMock: EventRepository {
func send<T>(type: T.Type, event: Event, completion: @escaping (Result<T, MindboxError>) -> Void) where T: Decodable {
return
}


func sendRaw(event: Event, completion: @escaping (Result<Data, MindboxError>) -> Void) {
return
}

func cancelAllRequests() {}
}
Loading