Skip to content

Commit 6e5bde5

Browse files
VailenceVailence
andauthored
MOBILE-164: Forward raw 2xx sync-operation body to WebView JS (#703)
* MOBILE-164-Sync-send-error-raw * MOBILE-164: Add tests for raw sync-operation pass-through Cover MBNetworkFetcher.requestRaw, MBEventRepository.sendRaw, and the WebView sync-operation response shaping. Extract a pure helper TransparentView.makeSyncOperationResponse so the JS-bridge contract is unit-testable without spinning up WKWebView. * MOBILE-164: Address review feedback - MBNetworkFetcher.requestRaw: capture self strongly so completion is always invoked even if the fetcher were deallocated mid-request (matches the existing `request<T>` pattern). - TransparentView.handleSyncOperation: build the outgoing BridgeMessage first, then log based on its `type` so the non-UTF-8 fallback path reports "failed" instead of "success". --------- Co-authored-by: Vailence <utekeshev@mindbox.cloud>
1 parent ce1957b commit 6e5bde5

12 files changed

Lines changed: 803 additions & 70 deletions

File tree

Mindbox.xcodeproj/project.pbxproj

Lines changed: 82 additions & 44 deletions
Large diffs are not rendered by default.

Mindbox/InAppMessages/Presentation/Views/WebView/TransparentView.swift

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -386,32 +386,64 @@ extension TransparentView {
386386

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

389-
eventRepository.send(type: OperationResponse.self, event: event) { [weak self] result in
389+
// HTTP 2xx → forward the raw body to JS as a Response so the JS Tracker
390+
// can dispatch onSuccess / onValidationError by the body's `status`.
391+
// 4xx, 5xx and network failures stay on the MindboxError → Error path.
392+
eventRepository.sendRaw(event: event) { [weak self] result in
390393
DispatchQueue.main.async {
391-
switch result {
392-
case .success(let response):
394+
let outgoing = TransparentView.makeSyncOperationResponse(
395+
result: result,
396+
action: message.action,
397+
id: message.id
398+
)
399+
switch outgoing.type {
400+
case .response:
393401
Logger.common(message: "[WebView] syncOperation '\(params.name)' success", level: .info, category: .webViewInAppMessages)
394-
let responseJSON = response.createJSON()
395-
let successResponse = BridgeMessage(
396-
type: .response,
397-
action: message.action,
398-
payload: .string(responseJSON),
399-
id: message.id
400-
)
401-
self?.facade?.sendToJS(successResponse)
402-
403-
case .failure(let error):
404-
Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages)
405-
let errorJSON = error.createJSON()
406-
let errorResponse = BridgeMessage(
407-
type: .error,
408-
action: message.action,
409-
payload: .string(errorJSON),
410-
id: message.id
411-
)
412-
self?.facade?.sendToJS(errorResponse)
402+
case .error:
403+
if case .failure(let error) = result {
404+
Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: \(error)", level: .error, category: .webViewInAppMessages)
405+
} else {
406+
Logger.common(message: "[WebView] syncOperation '\(params.name)' failed: non-UTF-8 response body", level: .error, category: .webViewInAppMessages)
407+
}
408+
default:
409+
break
413410
}
411+
self?.facade?.sendToJS(outgoing)
412+
}
413+
}
414+
}
415+
416+
/// Maps the raw `sendRaw` result of a `syncOperation` request to the outgoing
417+
/// `BridgeMessage` sent back to JS. Pure function — no side effects — extracted
418+
/// to keep the JS-bridge contract independently unit-testable.
419+
static func makeSyncOperationResponse(
420+
result: Result<Data, MindboxError>,
421+
action: String,
422+
id: UUID
423+
) -> BridgeMessage {
424+
switch result {
425+
case .success(let data):
426+
guard let bodyString = String(data: data, encoding: .utf8) else {
427+
return BridgeMessage(
428+
type: .error,
429+
action: action,
430+
payload: .object(["error": .string("Response body is not valid UTF-8")]),
431+
id: id
432+
)
414433
}
434+
return BridgeMessage(
435+
type: .response,
436+
action: action,
437+
payload: .string(bodyString),
438+
id: id
439+
)
440+
case .failure(let error):
441+
return BridgeMessage(
442+
type: .error,
443+
action: action,
444+
payload: .string(error.createJSON()),
445+
id: id
446+
)
415447
}
416448
}
417449
}

Mindbox/Network/Abstract/NetworkFetcher.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ protocol NetworkFetcher {
2222
completion: @escaping ((Result<Void, MindboxError>) -> Void)
2323
)
2424

25+
/// Returns the raw HTTP 2xx response body without parsing `BaseResponse`,
26+
/// so the caller can decide how to interpret it. 4xx, 5xx and network
27+
/// failures still surface as `MindboxError` through the shared response
28+
/// pipeline.
29+
func requestRaw(
30+
route: Route,
31+
completion: @escaping ((Result<Data, MindboxError>) -> Void)
32+
)
33+
2534
/// Cancels all ongoing network tasks.
2635
func cancelAllTasks()
2736
}

Mindbox/Network/MBNetworkFetcher.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,47 @@ class MBNetworkFetcher: NetworkFetcher {
133133
}
134134
}
135135

136+
func requestRaw(
137+
route: Route,
138+
completion: @escaping (Result<Data, MindboxError>) -> Void
139+
) {
140+
guard let configuration = persistenceStorage.configuration else {
141+
let error = MindboxError(.init(
142+
errorKey: .invalidConfiguration,
143+
reason: "Configuration is not set"
144+
))
145+
Logger.error(error.asLoggerError())
146+
completion(.failure(error))
147+
return
148+
}
149+
150+
let builder = URLRequestBuilder(
151+
domain: configuration.domain,
152+
operationsDomain: resolvedOperationsDomain(configuration: configuration)
153+
)
154+
do {
155+
let urlRequest = try builder.asURLRequest(route: route)
156+
Logger.network(request: urlRequest, httpAdditionalHeaders: session.configuration.httpAdditionalHeaders)
157+
let startTime = CFAbsoluteTimeGetCurrent()
158+
session.dataTask(with: urlRequest) { data, response, error in
159+
let networkTimeMs = Int((CFAbsoluteTimeGetCurrent() - startTime) * 1000)
160+
self.handleResponse(data, response, error, needBaseResponse: false, networkTimeMs: networkTimeMs) { result in
161+
switch result {
162+
case let .success(data):
163+
completion(.success(data))
164+
case let .failure(error):
165+
Logger.error(error.asLoggerError())
166+
completion(.failure(error))
167+
}
168+
}
169+
}.resume()
170+
} catch let error {
171+
let errorModel = MindboxError.unknown(error)
172+
Logger.error(errorModel.asLoggerError())
173+
completion(.failure(errorModel))
174+
}
175+
}
176+
136177
private func handleResponse(
137178
_ data: Data?,
138179
_ response: URLResponse?,

Mindbox/NetworkRepository/Event/EventRepository.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import MindboxLogger
1212
protocol EventRepository {
1313
func send(event: Event, completion: @escaping (Result<Void, MindboxError>) -> Void)
1414
func send<T>(type: T.Type, event: Event, completion: @escaping (Result<T, MindboxError>) -> Void) where T: Decodable
15-
15+
16+
/// Sends an event and returns the raw HTTP 2xx response body. Skips
17+
/// `BaseResponse` parsing so the caller can dispatch on the body itself
18+
/// (e.g. forward the bytes verbatim to the WebView JS bridge so the JS
19+
/// Tracker can route by the body's `status` field).
20+
func sendRaw(event: Event, completion: @escaping (Result<Data, MindboxError>) -> Void)
21+
1622
/// Cancels all ongoing network requests associated with this repository.
1723
func cancelAllRequests()
1824
}

Mindbox/NetworkRepository/Event/MBEventRepository.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,41 @@ class MBEventRepository: EventRepository {
8080
})
8181
}
8282

83+
func sendRaw(event: Event, completion: @escaping (Result<Data, MindboxError>) -> Void) {
84+
guard let configuration = persistenceStorage.configuration else {
85+
let error = MindboxError(.init(
86+
errorKey: .invalidConfiguration,
87+
reason: "Configuration is not set"
88+
))
89+
completion(.failure(error))
90+
return
91+
}
92+
guard let deviceUUID = persistenceStorage.deviceUUID else {
93+
let error = MindboxError(.init(
94+
errorKey: .invalidConfiguration,
95+
reason: "DeviceUUID is not set"
96+
))
97+
completion(.failure(error))
98+
return
99+
}
100+
let wrapper = EventWrapper(
101+
event: event,
102+
endpoint: configuration.endpoint,
103+
deviceUUID: deviceUUID
104+
)
105+
let route = makeRoute(wrapper: wrapper)
106+
fetcher.requestRaw(route: route) { result in
107+
DispatchQueue.main.async {
108+
switch result {
109+
case let .failure(error):
110+
completion(.failure(error))
111+
case let .success(data):
112+
completion(.success(data))
113+
}
114+
}
115+
}
116+
}
117+
83118
private func makeRoute(wrapper: EventWrapper) -> Route {
84119
switch wrapper.event.type {
85120
case .installed,
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//
2+
// TransparentViewSyncOperationResponseTests.swift
3+
// MindboxTests
4+
//
5+
6+
import Testing
7+
import Foundation
8+
@_spi(Internal) @testable import Mindbox
9+
10+
@Suite("TransparentView.makeSyncOperationResponse")
11+
struct TransparentViewSyncOperationResponseTests {
12+
13+
private let action = "syncOperation"
14+
private let requestId = UUID()
15+
16+
// MARK: - HTTP 200 + ValidationError body → .response with raw body (regression: MOBILE-164)
17+
18+
@Test("HTTP 200 ValidationError body becomes .response with raw body string")
19+
func validationErrorBody_becomesResponseWithRawBody() throws {
20+
let rawBody = #"{"status":"ValidationError","validationMessages":[{"message":"Invalid email","location":"/customer/email"}]}"#
21+
let data = try #require(rawBody.data(using: .utf8))
22+
23+
let outgoing = TransparentView.makeSyncOperationResponse(
24+
result: .success(data),
25+
action: action,
26+
id: requestId
27+
)
28+
29+
#expect(outgoing.type == .response)
30+
#expect(outgoing.action == action)
31+
#expect(outgoing.id == requestId)
32+
if case .string(let value) = outgoing.payload {
33+
#expect(value == rawBody, "Payload must be the raw body, not re-serialized")
34+
} else {
35+
Issue.record("Expected .string payload, got \(String(describing: outgoing.payload))")
36+
}
37+
}
38+
39+
// MARK: - HTTP 200 + Success body → .response with raw body
40+
41+
@Test("HTTP 200 Success body becomes .response with raw body string (not re-serialized)")
42+
func successBody_becomesResponseWithRawBody() throws {
43+
let rawBody = #"{"status":"Success","customer":{"email":"a@b.c"}}"#
44+
let data = try #require(rawBody.data(using: .utf8))
45+
46+
let outgoing = TransparentView.makeSyncOperationResponse(
47+
result: .success(data),
48+
action: action,
49+
id: requestId
50+
)
51+
52+
#expect(outgoing.type == .response)
53+
if case .string(let value) = outgoing.payload {
54+
#expect(value == rawBody)
55+
} else {
56+
Issue.record("Expected .string payload, got \(String(describing: outgoing.payload))")
57+
}
58+
}
59+
60+
// MARK: - HTTP 200 + non-JSON body → .response with raw body string
61+
62+
@Test("HTTP 200 with non-JSON body still becomes .response (JS decides)")
63+
func nonJSONBody_becomesResponseWithRawBody() throws {
64+
let rawBody = "plain text body"
65+
let data = try #require(rawBody.data(using: .utf8))
66+
67+
let outgoing = TransparentView.makeSyncOperationResponse(
68+
result: .success(data),
69+
action: action,
70+
id: requestId
71+
)
72+
73+
#expect(outgoing.type == .response)
74+
if case .string(let value) = outgoing.payload {
75+
#expect(value == rawBody)
76+
} else {
77+
Issue.record("Expected .string payload")
78+
}
79+
}
80+
81+
// MARK: - HTTP 200 + empty body → .response with empty string
82+
83+
@Test("HTTP 200 with empty body becomes .response with empty string payload")
84+
func emptyBody_becomesResponseWithEmptyString() {
85+
let outgoing = TransparentView.makeSyncOperationResponse(
86+
result: .success(Data()),
87+
action: action,
88+
id: requestId
89+
)
90+
91+
#expect(outgoing.type == .response)
92+
if case .string(let value) = outgoing.payload {
93+
#expect(value == "")
94+
} else {
95+
Issue.record("Expected .string payload")
96+
}
97+
}
98+
99+
// MARK: - Non-UTF-8 body → .error with explanatory payload
100+
101+
@Test("Non-UTF-8 body becomes .error with 'Response body is not valid UTF-8'")
102+
func nonUTF8Body_becomesError() {
103+
// Bytes that are not valid UTF-8: lone continuation byte 0xC3 + invalid follow-up
104+
let data = Data([0xC3, 0x28])
105+
106+
let outgoing = TransparentView.makeSyncOperationResponse(
107+
result: .success(data),
108+
action: action,
109+
id: requestId
110+
)
111+
112+
#expect(outgoing.type == .error)
113+
if case .object(let dict) = outgoing.payload,
114+
case .string(let errorMessage) = dict["error"] {
115+
#expect(errorMessage == "Response body is not valid UTF-8")
116+
} else {
117+
Issue.record("Expected .object payload with 'error' key, got \(String(describing: outgoing.payload))")
118+
}
119+
}
120+
121+
// MARK: - Failure (.connectionError) → .error with createJSON payload
122+
123+
@Test("Connection failure becomes .error with MindboxError.createJSON payload")
124+
func connectionError_becomesError() {
125+
let outgoing = TransparentView.makeSyncOperationResponse(
126+
result: .failure(.connectionError),
127+
action: action,
128+
id: requestId
129+
)
130+
131+
#expect(outgoing.type == .error)
132+
if case .string(let value) = outgoing.payload {
133+
#expect(value.contains("NetworkError"), "createJSON for connectionError produces a NetworkError envelope")
134+
#expect(value.contains("Connection error"))
135+
} else {
136+
Issue.record("Expected .string payload")
137+
}
138+
}
139+
140+
// MARK: - Failure (.protocolError) → .error with createJSON payload
141+
142+
@Test("Protocol error becomes .error with MindboxError.createJSON payload")
143+
func protocolError_becomesError() {
144+
let pe = ProtocolError(status: .protocolError, errorMessage: "Bad", httpStatusCode: 400)
145+
let outgoing = TransparentView.makeSyncOperationResponse(
146+
result: .failure(.protocolError(pe)),
147+
action: action,
148+
id: requestId
149+
)
150+
151+
#expect(outgoing.type == .error)
152+
if case .string(let value) = outgoing.payload {
153+
#expect(value.contains("MindboxError"))
154+
#expect(value.contains("ProtocolError"))
155+
} else {
156+
Issue.record("Expected .string payload")
157+
}
158+
}
159+
160+
// MARK: - id and action propagated
161+
162+
@Test("Action and id from the request are preserved on the outgoing message")
163+
func actionAndIdPreserved() throws {
164+
let specificAction = "customAction"
165+
let specificId = UUID()
166+
let data = try #require("body".data(using: .utf8))
167+
168+
let outgoing = TransparentView.makeSyncOperationResponse(
169+
result: .success(data),
170+
action: specificAction,
171+
id: specificId
172+
)
173+
174+
#expect(outgoing.action == specificAction)
175+
#expect(outgoing.id == specificId)
176+
}
177+
}

MindboxTests/MindboxLogger/Mocks/EventRepositoryMock.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class EventRepositoryMock: EventRepository {
2727
func send<T>(type: T.Type, event: Event, completion: @escaping (Result<T, MindboxError>) -> Void) where T: Decodable {
2828
return
2929
}
30-
30+
31+
func sendRaw(event: Event, completion: @escaping (Result<Data, MindboxError>) -> Void) {
32+
return
33+
}
34+
3135
func cancelAllRequests() {}
3236
}

0 commit comments

Comments
 (0)