diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 026c0f197..68b62b075 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -1437,39 +1437,9 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = InappConfigurationDataFacade; - sourceTree = ""; - }; - F397DE1C2CFF568800B72DA9 /* JSONs */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = JSONs; - sourceTree = ""; - }; - F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = InappSessionManagerTests; - sourceTree = ""; - }; + F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; + F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; + F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2280,7 +2250,6 @@ F3BA5E000130A000C0000006 /* OperationsURLRoutingTests.swift */, F3CD202C2F600A800065392A /* HostNormalizerTests.swift */, ); - name = Network; path = Network; sourceTree = ""; }; @@ -2289,7 +2258,6 @@ children = ( 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */, ); - name = TrackVisitManager; path = TrackVisitManager; sourceTree = ""; }; @@ -2440,7 +2408,6 @@ F3A8B9912A3A408C00E9C055 /* SDKVersionValidator.swift */, F3A8B9992A3A471800E9C055 /* ABTestVariantsValidator.swift */, F3A8B99D2A3A4FD600E9C055 /* ABTestValidator.swift */, - F3482F292A65DCFC002A41EC /* String+Extensions.swift */, F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */, F31A947F2BC7E61800E6C978 /* InappFrequencyValidator.swift */, ); @@ -3016,7 +2983,6 @@ children = ( A1B2C3D4E5F6A7B8C9D0E1F3 /* MotionService.swift */, ); - name = Motion; path = Motion; sourceTree = ""; }; @@ -3591,6 +3557,7 @@ BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */, F31DB4072F56A50E00DCEB85 /* NSError+Extensions.swift */, 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */, + F3482F292A65DCFC002A41EC /* String+Extensions.swift */, ); path = Extensions; sourceTree = ""; diff --git a/Mindbox/Validators/String+Extensions.swift b/Mindbox/Extensions/String+Extensions.swift similarity index 62% rename from Mindbox/Validators/String+Extensions.swift rename to Mindbox/Extensions/String+Extensions.swift index 1b48b8c5c..48cbf8155 100644 --- a/Mindbox/Validators/String+Extensions.swift +++ b/Mindbox/Extensions/String+Extensions.swift @@ -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 + } +} diff --git a/Mindbox/InAppMessages/InappShowFailureManager.swift b/Mindbox/InAppMessages/InappShowFailureManager.swift index 27b09e7f0..e35e0c80b 100644 --- a/Mindbox/InAppMessages/InappShowFailureManager.swift +++ b/Mindbox/InAppMessages/InappShowFailureManager.swift @@ -16,6 +16,9 @@ protocol InappShowFailureManagerProtocol { } final class InappShowFailureManager: InappShowFailureManagerProtocol { + /// Backend payload limit for errorDetails. + static let errorDetailsLimit = 1000 + private struct InAppShowFailuresBody: Codable { let failures: [InAppShowFailure] } @@ -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 { + 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 { @@ -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)) } } diff --git a/MindboxTests/Extensions/StringExtensionsTests.swift b/MindboxTests/Extensions/StringExtensionsTests.swift index 6abdda11c..1485bb4ca 100644 --- a/MindboxTests/Extensions/StringExtensionsTests.swift +++ b/MindboxTests/Extensions/StringExtensionsTests.swift @@ -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", diff --git a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift index a1defff9e..0e670d3c7 100644 --- a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift @@ -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",