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
41 changes: 4 additions & 37 deletions Mindbox.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1437,39 +1437,9 @@
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = InappConfigurationDataFacade;
sourceTree = "<group>";
};
F397DE1C2CFF568800B72DA9 /* JSONs */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = JSONs;
sourceTree = "<group>";
};
F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
explicitFileTypes = {
};
explicitFolders = (
);
path = InappSessionManagerTests;
sourceTree = "<group>";
};
F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = "<group>"; };
F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = "<group>"; };
F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -2280,7 +2250,6 @@
F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */,
F3CD202C2F600A800065392A /* HostNormalizerTests.swift */,
);
name = Network;
path = Network;
sourceTree = "<group>";
};
Expand All @@ -2289,7 +2258,6 @@
children = (
0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */,
);
name = TrackVisitManager;
path = TrackVisitManager;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -2440,7 +2408,6 @@
F3A8B9912A3A408C00E9C055 /* SDKVersionValidator.swift */,
F3A8B9992A3A471800E9C055 /* ABTestVariantsValidator.swift */,
F3A8B99D2A3A4FD600E9C055 /* ABTestValidator.swift */,
F3482F292A65DCFC002A41EC /* String+Extensions.swift */,
F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */,
F31A947F2BC7E61800E6C978 /* InappFrequencyValidator.swift */,
);
Expand Down Expand Up @@ -3016,7 +2983,6 @@
children = (
A1B2C3D4E5F6A7B8C9D0E1F3 /* MotionService.swift */,
);
name = Motion;
path = Motion;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -3591,6 +3557,7 @@
BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */,
F31DB4072F56A50E00DCEB85 /* NSError+Extensions.swift */,
326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */,
F3482F292A65DCFC002A41EC /* String+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,24 @@ extension String {
return hexValue.unicodeScalars.allSatisfy { hexCharacterSet.contains($0) }
}
}

extension String {
/// Truncates the string so its UTF-8 byte representation does not exceed `limit`.
/// Cuts at extended grapheme cluster boundaries to keep multi-byte characters intact.
func truncated(toUTF8ByteLimit limit: Int) -> String {
guard limit >= 0 else { return "" }
guard utf8.count > limit else { return self }

var result = ""
var byteCount = 0
for character in self {
let characterByteCount = character.utf8.count
if byteCount + characterByteCount > limit {
break
}
byteCount += characterByteCount
result.append(character)
}
return result
}
}
23 changes: 19 additions & 4 deletions Mindbox/InAppMessages/InappShowFailureManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ protocol InappShowFailureManagerProtocol {
}

final class InappShowFailureManager: InappShowFailureManagerProtocol {
/// Backend payload limit for errorDetails.
static let errorDetailsLimit = 1000
Comment thread
Vailence marked this conversation as resolved.

Comment on lines +19 to +21
Comment on lines +19 to +21
private struct InAppShowFailuresBody: Codable {
let failures: [InAppShowFailure]
}
Expand All @@ -36,7 +39,19 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol {
Logger.common(message: "[InappShowFailureManager] addFailure ignored, feature is disabled", category: .inAppMessages)
return
}


let truncatedDetails = details.map { original -> String in
let truncated = original.truncated(toUTF8ByteLimit: Self.errorDetailsLimit)
if truncated != original {
Comment on lines +43 to +45
Logger.common(
message: "[InappShowFailureManager] errorDetails truncated to \(truncated.utf8.count) bytes (limit \(Self.errorDetailsLimit)). inappId=\(inappId)",
level: .debug,
category: .inAppMessages
)
}
return truncated
}

queue.async { [self] in
if let existingIndex = failures.firstIndex(where: { $0.inappId == inappId }) {
guard shouldReplaceFailure(currentReason: failures[existingIndex].failureReason, newReason: reason) else {
Expand All @@ -48,13 +63,13 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol {
)
return
}
failures[existingIndex] = makeFailure(inappId: inappId, reason: reason, details: details)
failures[existingIndex] = makeFailure(inappId: inappId, reason: reason, details: truncatedDetails)
Logger.common(message: "[InappShowFailureManager] Failure reason updated. inappId=\(inappId), reason=\(reason.rawValue)",
category: .inAppMessages)
return
}
failures.append(makeFailure(inappId: inappId, reason: reason, details: details))

failures.append(makeFailure(inappId: inappId, reason: reason, details: truncatedDetails))
}
}

Expand Down
17 changes: 17 additions & 0 deletions MindboxTests/Extensions/StringExtensionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ final class StringExtensionsTests: XCTestCase {
}
}

func test_truncatedToUTF8ByteLimit_zeroLimit_returnsEmptyString() {
XCTAssertEqual("hello".truncated(toUTF8ByteLimit: 0), "")
}

func test_truncatedToUTF8ByteLimit_zeroLimit_onEmptyString_returnsEmptyString() {
XCTAssertEqual("".truncated(toUTF8ByteLimit: 0), "")
}

func test_truncatedToUTF8ByteLimit_negativeLimit_returnsEmptyString() {
XCTAssertEqual("hello".truncated(toUTF8ByteLimit: -1), "")
XCTAssertEqual("hello".truncated(toUTF8ByteLimit: -100), "")
}

func test_truncatedToUTF8ByteLimit_negativeLimit_onEmptyString_returnsEmptyString() {
XCTAssertEqual("".truncated(toUTF8ByteLimit: -1), "")
}

func test_parseTimeSpanToMillisNegative() throws {
let testCases: Array = [
"12345678901234567890.00:00:00.00",
Expand Down
129 changes: 129 additions & 0 deletions MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,135 @@ final class InappShowFailureManagerTests: XCTestCase {
assertCreatedEventsCountEventually(0)
}

func testAddFailure_errorDetailsBelowLimit_isNotTruncated() throws {
let details = String(repeating: "a", count: InappShowFailureManager.errorDetailsLimit - 1)

manager.addFailure(inappId: "inapp-below-limit", reason: .unknownError, details: details)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
XCTAssertEqual(failure.errorDetails?.count, InappShowFailureManager.errorDetailsLimit - 1)
XCTAssertEqual(failure.errorDetails, details)
}

func testAddFailure_errorDetailsAtLimit_isNotTruncated() throws {
let details = String(repeating: "b", count: InappShowFailureManager.errorDetailsLimit)

manager.addFailure(inappId: "inapp-at-limit", reason: .unknownError, details: details)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
XCTAssertEqual(failure.errorDetails?.count, InappShowFailureManager.errorDetailsLimit)
XCTAssertEqual(failure.errorDetails, details)
}

func testAddFailure_errorDetailsAboveLimit_isTruncatedToLimit() throws {
let limit = InappShowFailureManager.errorDetailsLimit
let details = String(repeating: "c", count: limit + 500)

manager.addFailure(inappId: "inapp-above-limit", reason: .unknownError, details: details)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
XCTAssertEqual(failure.errorDetails?.count, limit)
XCTAssertEqual(failure.errorDetails, String(details.prefix(limit)))
}

func testAddFailure_errorDetailsNil_remainsNil() throws {
manager.addFailure(inappId: "inapp-nil-details", reason: .unknownError, details: nil)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
XCTAssertNil(failure.errorDetails)
}

func testAddFailure_errorDetailsEmpty_remainsEmpty() throws {
manager.addFailure(inappId: "inapp-empty-details", reason: .unknownError, details: "")
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
XCTAssertEqual(failure.errorDetails, "")
}

func testAddFailure_errorDetailsMultibyte_truncatesByUTF8Bytes() throws {
let limit = InappShowFailureManager.errorDetailsLimit
// Cyrillic 'а' is 2 bytes in UTF-8: total = 2 * limit bytes.
let details = String(repeating: "а", count: limit)

manager.addFailure(inappId: "inapp-multibyte", reason: .unknownError, details: details)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
let truncated = try XCTUnwrap(failure.errorDetails)

XCTAssertEqual(truncated.utf8.count, limit)
XCTAssertEqual(truncated.count, limit / 2)
}

func testAddFailure_errorDetailsMultibyte_doesNotSplitCharacter() throws {
let limit = InappShowFailureManager.errorDetailsLimit
let asciiPrefix = String(repeating: "x", count: limit - 1)
// Cyrillic 'ё' is 2 bytes — appending it would overflow by 1 byte.
let details = asciiPrefix + "ё"

manager.addFailure(inappId: "inapp-no-split", reason: .unknownError, details: details)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
let truncated = try XCTUnwrap(failure.errorDetails)

XCTAssertEqual(truncated, asciiPrefix)
XCTAssertEqual(truncated.utf8.count, limit - 1)
}

func testAddFailure_errorDetailsEmoji_isNotSplit() throws {
let limit = InappShowFailureManager.errorDetailsLimit
// "🙂" is 4 UTF-8 bytes. Fill almost to the limit, then append an emoji.
let asciiPrefix = String(repeating: "y", count: limit - 2)
let details = asciiPrefix + "🙂"

manager.addFailure(inappId: "inapp-emoji", reason: .unknownError, details: details)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
let truncated = try XCTUnwrap(failure.errorDetails)

XCTAssertEqual(truncated, asciiPrefix)
XCTAssertLessThanOrEqual(truncated.utf8.count, limit)
}

func testAddFailure_priorityReplacement_truncatesNewDetails() throws {
let limit = InappShowFailureManager.errorDetailsLimit
let longDetails = String(repeating: "d", count: limit + 200)

manager.addFailure(inappId: "inapp-priority-truncate", reason: .productSegmentRequestFailed, details: "short")
manager.addFailure(inappId: "inapp-priority-truncate", reason: .customerSegmentRequestFailed, details: longDetails)
manager.sendFailures()

assertCreatedEventsCountEventually(1)
let event = try XCTUnwrap(databaseRepository.createdEvents.first)
let failure = try XCTUnwrap(decodeFailures(from: event)?.first)
XCTAssertEqual(failure.failureReason, .customerSegmentRequestFailed)
XCTAssertEqual(failure.errorDetails?.count, limit)
XCTAssertEqual(failure.errorDetails, String(longDetails.prefix(limit)))
}

func testSendFailures_whenFeatureDisabled_doesNotSendAndKeepsBufferedFailures() throws {
manager.addFailure(
inappId: "inapp-toggle-disabled",
Expand Down