diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index cf85e01b1..c5896eb3c 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -367,6 +367,7 @@ A153E04129BB0A8B003C34D4 /* InAppConfigurationWithOperations.json in Resources */ = {isa = PBXBuildFile; fileRef = A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */; }; A154E304299C189300F8F074 /* MBLoggerCoreDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E303299C189300F8F074 /* MBLoggerCoreDataManagerTests.swift */; }; A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */; }; + 974C7734AF47A6A1E1137E0E /* DateFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EC463C4B1A88855D9A47FAF /* DateFormatTests.swift */; }; A154E330299E0F1600F8F074 /* InAppGeoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */; }; A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E333299E110E00F8F074 /* EventRepositoryMock.swift */; }; A15D701629AF810E007131E7 /* SDKLogsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */; }; @@ -463,7 +464,6 @@ D2F7E24C2BADC4CA00B24BB8 /* MockSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F7E24B2BADC4CA00B24BB8 /* MockSessionManager.swift */; }; DEC482157E5249DBBFAEFC9A /* FeatureTogglesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */; }; EA395B77BB16CEFE6DC91D1D /* TransparentViewSyncOperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */; }; - F26DFF81C3FF57C3DE68DEDC /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */; }; F30005442CFF3F7D004BE915 /* ABTestStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30005432CFF3F7D004BE915 /* ABTestStubs.swift */; }; F306291A2BD27D7500EF6609 /* InappFrequencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */; }; F30654BB2F1A83520058808C /* MindboxWebViewFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30654BA2F1A83520058808C /* MindboxWebViewFacade.swift */; }; @@ -765,7 +765,6 @@ 31ED2DF025C4456600301FAD /* TestConfig_Invalid_1.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_1.plist; sourceTree = ""; }; 31ED2DF125C4456600301FAD /* TestConfig_Invalid_3.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_3.plist; sourceTree = ""; }; 31ED2DF925C4459400301FAD /* TestConfig_Invalid_4.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestConfig_Invalid_4.plist; sourceTree = ""; }; - 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; 33072F2D2664C24F001F1AB2 /* AreaResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreaResponse.swift; sourceTree = ""; }; 33072F2F2664C2E4001F1AB2 /* SubscriptionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionResponse.swift; sourceTree = ""; }; 33072F312664C357001F1AB2 /* ProductListResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListResponse.swift; sourceTree = ""; }; @@ -1098,6 +1097,7 @@ A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = InAppConfigurationWithOperations.json; sourceTree = ""; }; A154E303299C189300F8F074 /* MBLoggerCoreDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBLoggerCoreDataManagerTests.swift; sourceTree = ""; }; A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKLogManagerTests.swift; sourceTree = ""; }; + 1EC463C4B1A88855D9A47FAF /* DateFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatTests.swift; sourceTree = ""; }; A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppGeoResponse.swift; sourceTree = ""; }; A154E333299E110E00F8F074 /* EventRepositoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRepositoryMock.swift; sourceTree = ""; }; A154E33A299E5B6D00F8F074 /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; @@ -2811,6 +2811,7 @@ A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */, 47A4FA772E73741700569870 /* LoggerDatabaseLoaderTests.swift */, 47A4FA752E735C5200569870 /* LogStoreTrimmerTests.swift */, + 1EC463C4B1A88855D9A47FAF /* DateFormatTests.swift */, ); path = MindboxLogger; sourceTree = ""; @@ -3573,7 +3574,6 @@ BB4D7CC62BDEC51D008E3AB8 /* Notification+Extensions.swift */, BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */, F31DB4072F56A50E00DCEB85 /* NSError+Extensions.swift */, - 326423031CA9C6BF0E62BEFD /* Date+Extensions.swift */, F3482F292A65DCFC002A41EC /* String+Extensions.swift */, ); path = Extensions; @@ -4657,7 +4657,6 @@ B3A625502689F8B600B6A3B7 /* BenefitResponse.swift in Sources */, 84C65E6425D4FBBB008996FA /* MobileApplicationInfoUpdated.swift in Sources */, 84DEE8AD25CC036A00C98CC7 /* MBDatabaseRepository.swift in Sources */, - F26DFF81C3FF57C3DE68DEDC /* Date+Extensions.swift in Sources */, 6182078DDFC681D168546DAD /* HapticService.swift in Sources */, A1B2C3D4E5F6A7B8C9D0E1F2 /* MotionService.swift in Sources */, 6182078DDFC681D168546DAE /* HapticRequest.swift in Sources */, @@ -4698,6 +4697,7 @@ F367301D2B7B8B6A00DD0039 /* NotificationFormatTests.swift in Sources */, F35E0C4F2DF0535E00E8A768 /* InAppTrackingServiceTests.swift in Sources */, A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */, + 974C7734AF47A6A1E1137E0E /* DateFormatTests.swift in Sources */, 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */, D2F7E2482BADB9EF00B24BB8 /* UserVisitManagerTests.swift in Sources */, BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */, diff --git a/Mindbox/Extensions/Date+Extensions.swift b/Mindbox/Extensions/Date+Extensions.swift deleted file mode 100644 index 6d7658fa1..000000000 --- a/Mindbox/Extensions/Date+Extensions.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Date+Extensions.swift -// Mindbox -// -// Created by Mindbox on 09.03.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Foundation - -extension Date { - - private static let iso8601Formatter: DateFormatter = { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" - return formatter - }() - - private static let iso8601WithMillisecondsFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" - return formatter - }() - - var iso8601: String { - Self.iso8601Formatter.string(from: self) - } - - static func fromISO8601(_ string: String) -> Date? { - iso8601Formatter.date(from: string) - ?? iso8601WithMillisecondsFormatter.date(from: string) - } -} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift index 3da7bf9f2..a6270e24a 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Debug/MindboxWebViewFacade.swift @@ -8,6 +8,7 @@ import UIKit import WebKit +import MindboxLogger private enum PayloadKey { static let sdkVersion = "sdkVersion" @@ -238,7 +239,7 @@ extension MindboxWebViewFacade { ] if let firstInitDate = persistenceStorage.firstInitializationDateTime { - params[PayloadKey.firstInitializationDateTime] = firstInitDate.iso8601 + params[PayloadKey.firstInitializationDateTime] = firstInitDate.toString(withFormat: .utc) } return params diff --git a/Mindbox/Model/Common/MBDate.swift b/Mindbox/Model/Common/MBDate.swift index c4fc4cc19..da9dbc7b9 100644 --- a/Mindbox/Model/Common/MBDate.swift +++ b/Mindbox/Model/Common/MBDate.swift @@ -28,7 +28,8 @@ public final class DateTime: MBDate { } override func decodeWithFormat(_ rawString: String) -> Date? { - return Date.fromISO8601(rawString) + return rawString.toDate(withFormat: .utc) + ?? rawString.toDate(withFormat: .utcWithMillis) } } diff --git a/MindboxLogger/Shared/Extensions/Date+Extension.swift b/MindboxLogger/Shared/Extensions/Date+Extension.swift index 72174678a..7dc0cc3e1 100644 --- a/MindboxLogger/Shared/Extensions/Date+Extension.swift +++ b/MindboxLogger/Shared/Extensions/Date+Extension.swift @@ -15,18 +15,21 @@ public extension Date { func toFullString() -> String { let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" return dateFormatter.string(from: self as Date) } static var dateFormatter: DateFormatter { let formatter = DateFormatter() - formatter.dateFormat = "hh:mm:ss.SSSS" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm:ss.SSSS" return formatter } func toString(withFormat format: DateFormat) -> String { let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = format.value dateFormatter.timeZone = TimeZone(identifier: "UTC") return dateFormatter.string(from: self) @@ -36,6 +39,7 @@ public extension Date { public extension TimeInterval { private static let readableDateTimeFormatter: DateFormatter = { let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" return formatter }() diff --git a/MindboxLogger/Shared/Extensions/String+Extensions.swift b/MindboxLogger/Shared/Extensions/String+Extensions.swift index ecbcb0461..2f716d4ae 100644 --- a/MindboxLogger/Shared/Extensions/String+Extensions.swift +++ b/MindboxLogger/Shared/Extensions/String+Extensions.swift @@ -10,6 +10,7 @@ import Foundation public enum DateFormat: String { case api = "yyyy-MM-dd'T'HH:mm:ss" case utc = "yyyy-MM-dd'T'HH:mm:ss'Z'" + case utcWithMillis = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" var value: String { return self.rawValue @@ -19,6 +20,7 @@ public enum DateFormat: String { public extension String { func toDate(withFormat format: DateFormat) -> Date? { let dateFormatterGet = DateFormatter() + dateFormatterGet.locale = Locale(identifier: "en_US_POSIX") dateFormatterGet.dateFormat = format.value dateFormatterGet.timeZone = TimeZone(identifier: "UTC") diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index f9381ded6..78b1b9aee 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -27,4 +27,6 @@ extension Tag { @Tag static var trackVisit: Self @Tag static var operationsRouting: Self @Tag static var mbConfiguration: Self + + @Tag static var dateFormatting: Self } diff --git a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift index 0e670d3c7..5dc8134eb 100644 --- a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift @@ -9,6 +9,7 @@ import XCTest import UIKit @testable import Mindbox +@testable import MindboxLogger final class InappShowFailureManagerTests: XCTestCase { private var databaseRepository: InappShowFailureDatabaseRepositoryMock! @@ -65,7 +66,16 @@ final class InappShowFailureManagerTests: XCTestCase { let event = try XCTUnwrap(databaseRepository.createdEvents.first) let failure = try XCTUnwrap(decodeFailures(from: event)?.first) XCTAssertFalse(failure.dateTimeUtc.isEmpty) - XCTAssertNotNil(makeUTCFormatter().date(from: failure.dateTimeUtc)) + XCTAssertNotNil(failure.dateTimeUtc.toDate(withFormat: .utc)) + + let dateTimeUtc = failure.dateTimeUtc + XCTAssertEqual(dateTimeUtc.count, 20) + XCTAssertTrue(dateTimeUtc.hasSuffix("Z")) + XCTAssertFalse(dateTimeUtc.contains("AM")) + XCTAssertFalse(dateTimeUtc.contains("PM")) + XCTAssertFalse(dateTimeUtc.contains(" ")) + let pattern = #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"# + XCTAssertNotNil(dateTimeUtc.range(of: pattern, options: .regularExpression)) } func testAddFailure_duplicateInappId_isIgnored() throws { @@ -359,14 +369,6 @@ private extension InappShowFailureManagerTests { BodyDecoder(decodable: event.body)?.body.failures } - func makeUTCFormatter() -> DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" - return formatter - } - func applyFeatureToggle(shouldSendInAppShowError: Bool) { let settingsJSON = """ { diff --git a/MindboxTests/MindboxLogger/DateFormatTests.swift b/MindboxTests/MindboxLogger/DateFormatTests.swift new file mode 100644 index 000000000..15f1e7942 --- /dev/null +++ b/MindboxTests/MindboxLogger/DateFormatTests.swift @@ -0,0 +1,64 @@ +// +// DateFormatTests.swift +// MindboxTests +// +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import Testing +@testable import MindboxLogger + +@Suite("DateFormat ISO-8601 primitive", .tags(.dateFormatting)) +struct DateFormatTests { + + private let fixedDate = Date(timeIntervalSince1970: 1_747_017_155) // 2025-05-12T02:32:35Z + + @Test("Date.toString(withFormat: .utc) produces literal yyyy-MM-dd'T'HH:mm:ss'Z'") + func utcFormatLiteral() { + #expect(fixedDate.toString(withFormat: .utc) == "2025-05-12T02:32:35Z") + } + + @Test("Date.toString(withFormat: .api) produces literal yyyy-MM-dd'T'HH:mm:ss") + func apiFormatLiteral() { + #expect(fixedDate.toString(withFormat: .api) == "2025-05-12T02:32:35") + } + + @Test("String.toDate(withFormat: .utc) parses canonical UTC literal") + func utcParseRoundTrip() { + let parsed = "2025-05-12T02:32:35Z".toDate(withFormat: .utc) + #expect(parsed == fixedDate) + } + + @Test("String.toDate(withFormat: .utcWithMillis) parses millisecond-precision payloads") + func millisParse() { + let parsed = "2025-05-12T02:32:35.123Z".toDate(withFormat: .utcWithMillis) + #expect(parsed != nil) + let expected = Date(timeIntervalSince1970: 1_747_017_155.123) + if let parsed { + #expect(abs(parsed.timeIntervalSince(expected)) < 0.001) + } + } + + @Test("Formatter does not silently switch to 12h pattern under 12-hour preference") + func twelveHourPreferenceDoesNotLeakIntoOutput() { + // QA1480: when the user's region preference is 12h, DateFormatter rewrites + // HH:mm:ss into h:mm:ss a unless locale is en_US_POSIX. We assert the + // literal still matches the contract — no AM/PM, no whitespace, leading zero. + let serialized = fixedDate.toString(withFormat: .utc) + #expect(!serialized.contains("AM")) + #expect(!serialized.contains("PM")) + #expect(!serialized.contains(" ")) + #expect(serialized.hasSuffix("Z")) + #expect(serialized.count == 20) + } + + @Test("UTC and millis round-trip via DateTime decoder fallback") + func roundTripFallbackChain() { + let withMillis = "2025-05-12T02:32:35.000Z" + let plain = "2025-05-12T02:32:35Z" + #expect(plain.toDate(withFormat: .utc) != nil) + #expect(withMillis.toDate(withFormat: .utc) == nil) + #expect(withMillis.toDate(withFormat: .utcWithMillis) != nil) + } +}