diff --git a/Sources/ProcessOut/Sources/Core/DeviceMetadata/DefaultDeviceMetadataProvider.swift b/Sources/ProcessOut/Sources/Core/DeviceMetadata/DefaultDeviceMetadataProvider.swift index cc43642ce..14d2c7735 100644 --- a/Sources/ProcessOut/Sources/Core/DeviceMetadata/DefaultDeviceMetadataProvider.swift +++ b/Sources/ProcessOut/Sources/Core/DeviceMetadata/DefaultDeviceMetadataProvider.swift @@ -9,11 +9,18 @@ import UIKit actor DefaultDeviceMetadataProvider: DeviceMetadataProvider { - init(screen: UIScreen, device: UIDevice, bundle: Bundle, keychain: Keychain) { + init( + screen: UIScreen, + device: UIDevice, + bundle: Bundle, + keychain: Keychain, + timeZone: TimeZone = .autoupdatingCurrent + ) { self.screen = screen self.device = device self.bundle = bundle self.keychain = keychain + self.timeZone = timeZone } // MARK: - DeviceMetadataProvider @@ -28,7 +35,7 @@ actor DefaultDeviceMetadataProvider: DeviceMetadataProvider { appLanguage: bundle.preferredLocalizations.first!, // swiftlint:disable:this force_unwrapping appScreenWidth: Int(screen.nativeBounds.width), // Specified in pixels appScreenHeight: Int(screen.nativeBounds.height), - appTimeZoneOffset: TimeZone.current.secondsFromGMT() / 60, + appTimezoneOffset: -timeZone.secondsFromGMT() / 60, // UTC - local, e.g. -60 for UTC+1 channel: "ios", threedsSdk: "ios", platform: "ios" @@ -48,6 +55,7 @@ actor DefaultDeviceMetadataProvider: DeviceMetadataProvider { private let screen: UIScreen private let device: UIDevice private let bundle: Bundle + private let timeZone: TimeZone private let keychain: Keychain private lazy var machineName: String? = { diff --git a/Sources/ProcessOut/Sources/Core/DeviceMetadata/DeviceMetadata.swift b/Sources/ProcessOut/Sources/Core/DeviceMetadata/DeviceMetadata.swift index 4f932518c..d3f39bd4a 100644 --- a/Sources/ProcessOut/Sources/Core/DeviceMetadata/DeviceMetadata.swift +++ b/Sources/ProcessOut/Sources/Core/DeviceMetadata/DeviceMetadata.swift @@ -33,8 +33,9 @@ struct DeviceMetadata: Encodable, Sendable { /// Height of the screen in pixels. let appScreenHeight: Int - /// Time zone offset in minutes. - let appTimeZoneOffset: Int + /// Difference in minutes between UTC and device local time (UTC - local), e.g. -60 for UTC+1. + /// Follows JavaScript `Date.getTimezoneOffset()` and EMVCo browser time zone semantics. + let appTimezoneOffset: Int /// Device channel. Holds device system name. Legacy field, backend uses it for 3DS SDK /// selection when `threedsSdk` is not set. Kept for backward compatibility. diff --git a/Sources/ProcessOut/Sources/Services/Telemetry/DefaultTelemetryService.swift b/Sources/ProcessOut/Sources/Services/Telemetry/DefaultTelemetryService.swift index 0fe5f95cb..e8ab7a262 100644 --- a/Sources/ProcessOut/Sources/Services/Telemetry/DefaultTelemetryService.swift +++ b/Sources/ProcessOut/Sources/Services/Telemetry/DefaultTelemetryService.swift @@ -97,7 +97,7 @@ final class DefaultTelemetryService: TelemetryService, @unchecked Sendable { ) let device: Telemetry.DeviceMetadata = await { let metadata = await deviceMetadataProvider.deviceMetadata - return .init(language: metadata.appLanguage, model: metadata.model, timeZone: metadata.appTimeZoneOffset) + return .init(language: metadata.appLanguage, model: metadata.model, timeZone: metadata.appTimezoneOffset) }() return Telemetry.Metadata(application: application, device: device) } diff --git a/Tests/ProcessOutTests/Sources/Mocks/DeviceMetadataProvider/StubDeviceMetadataProvider.swift b/Tests/ProcessOutTests/Sources/Mocks/DeviceMetadataProvider/StubDeviceMetadataProvider.swift index 5f81d0be6..ffd871822 100644 --- a/Tests/ProcessOutTests/Sources/Mocks/DeviceMetadataProvider/StubDeviceMetadataProvider.swift +++ b/Tests/ProcessOutTests/Sources/Mocks/DeviceMetadataProvider/StubDeviceMetadataProvider.swift @@ -18,7 +18,7 @@ struct StubDeviceMetadataProvider: DeviceMetadataProvider { appLanguage: "en", appScreenWidth: 1, appScreenHeight: 2, - appTimeZoneOffset: 3, + appTimezoneOffset: 3, channel: "test", threedsSdk: "test", platform: "test" diff --git a/Tests/ProcessOutTests/Sources/Unit/Connectors/Http/DefaultHttpConnectorRequestMapperTests.swift b/Tests/ProcessOutTests/Sources/Unit/Connectors/Http/DefaultHttpConnectorRequestMapperTests.swift index dd175a1e7..396d64abf 100644 --- a/Tests/ProcessOutTests/Sources/Unit/Connectors/Http/DefaultHttpConnectorRequestMapperTests.swift +++ b/Tests/ProcessOutTests/Sources/Unit/Connectors/Http/DefaultHttpConnectorRequestMapperTests.swift @@ -64,7 +64,7 @@ struct DefaultHttpConnectorRequestMapperTests { // Then // swiftlint:disable:next line_length - let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimeZoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"}}"# + let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimezoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"}}"# #expect(urlRequest.httpBody == Data(expectedBody.utf8)) } @@ -81,7 +81,7 @@ struct DefaultHttpConnectorRequestMapperTests { // Then // swiftlint:disable:next line_length - let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimeZoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"},"key":"value"}"# + let expectedBody = #"{"device":{"appLanguage":"en","appScreenHeight":2,"appScreenWidth":1,"appTimezoneOffset":3,"channel":"test","id":"id","platform":"test","threedsSdk":"test"},"key":"value"}"# #expect(urlRequest.httpBody == Data(expectedBody.utf8)) } diff --git a/Tests/ProcessOutTests/Sources/Unit/Core/DeviceMetadata/DefaultDeviceMetadataProviderTests.swift b/Tests/ProcessOutTests/Sources/Unit/Core/DeviceMetadata/DefaultDeviceMetadataProviderTests.swift new file mode 100644 index 000000000..5511ee239 --- /dev/null +++ b/Tests/ProcessOutTests/Sources/Unit/Core/DeviceMetadata/DefaultDeviceMetadataProviderTests.swift @@ -0,0 +1,56 @@ +// +// DefaultDeviceMetadataProviderTests.swift +// ProcessOutTests +// +// Created by Andrii Vysotskyi on 06.11.2026. +// + +import UIKit +import Testing +@testable @_spi(PO) import ProcessOut + +@MainActor +struct DefaultDeviceMetadataProviderTests { + + @Test(arguments: [(3600, -60), (-18000, 300), (19800, -330), (0, 0)]) + func deviceMetadata_returnsUtcMinusLocalTimezoneOffset(secondsFromGmt: Int, expectedOffset: Int) async throws { + // Given + let timeZone = try #require(TimeZone(secondsFromGMT: secondsFromGmt)) + let sut = createProvider(timeZone: timeZone) + + // When + let metadata = await sut.deviceMetadata + + // Then + #expect(metadata.appTimezoneOffset == expectedOffset) + } + + @Test + func deviceMetadata_whenEncodedWithSnakeCase_usesAppTimezoneOffsetKey() async throws { + // Given + let timeZone = try #require(TimeZone(secondsFromGMT: 3600)) + let sut = createProvider(timeZone: timeZone) + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + // When + let data = try encoder.encode(await sut.deviceMetadata) + + // Then + let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["app_timezone_offset"] as? Int == -60) + #expect(json["app_time_zone_offset"] == nil) + } + + // MARK: - Private Methods + + private func createProvider(timeZone: TimeZone) -> DefaultDeviceMetadataProvider { + DefaultDeviceMetadataProvider( + screen: .main, + device: .current, + bundle: .main, + keychain: Keychain(service: "com.processout.tests"), + timeZone: timeZone + ) + } +}