diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 62239b1d..988885de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -exclude: Tests/KlaviyoSwiftTests/__Snapshots__ +exclude: Tests/.*/__Snapshots__ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.3.0 diff --git a/Examples/KlaviyoSwiftExamples/CocoapodsExample/Podfile.lock b/Examples/KlaviyoSwiftExamples/CocoapodsExample/Podfile.lock index a76b87a0..b92f66b2 100644 --- a/Examples/KlaviyoSwiftExamples/CocoapodsExample/Podfile.lock +++ b/Examples/KlaviyoSwiftExamples/CocoapodsExample/Podfile.lock @@ -21,4 +21,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0e01c771c2241e458c117e9ad0fc5219a42e2cf3 -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/Examples/KlaviyoSwiftExamples/Shared/AppDelegate.swift b/Examples/KlaviyoSwiftExamples/Shared/AppDelegate.swift index f3d48055..f4ebb702 100644 --- a/Examples/KlaviyoSwiftExamples/Shared/AppDelegate.swift +++ b/Examples/KlaviyoSwiftExamples/Shared/AppDelegate.swift @@ -10,7 +10,7 @@ import KlaviyoSwift import UIKit -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Private members @@ -156,7 +156,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { case .checkout: // this is where we could present the checkout view break - case .debug: // sending debug should show the deeplink URL in code let debugViewController = DebugViewController() diff --git a/Examples/KlaviyoSwiftExamples/Shared/DebugViewController.swift b/Examples/KlaviyoSwiftExamples/Shared/DebugViewController.swift index fed6dcdd..7bd9df5d 100644 --- a/Examples/KlaviyoSwiftExamples/Shared/DebugViewController.swift +++ b/Examples/KlaviyoSwiftExamples/Shared/DebugViewController.swift @@ -1,5 +1,5 @@ // -// ViewController.swift +// DebugViewController.swift // SPMExample // // Created by Ajay Subramanya on 2/28/23. diff --git a/KlaviyoSwift.podspec b/KlaviyoSwift.podspec index e9963e2c..4eba5d7c 100644 --- a/KlaviyoSwift.podspec +++ b/KlaviyoSwift.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |s| s.swift_version = '5.7' s.platform = :ios s.ios.deployment_target = '13.0' - s.source_files = 'Sources/KlaviyoSwift/**/*.swift' + s.source_files = 'Sources/**/**/*.swift' s.resource_bundles = {"KlaviyoSwift" => ["Sources/KlaviyoSwift/PrivacyInfo.xcprivacy"]} s.dependency 'AnyCodable-FlightSchool' end diff --git a/Package.swift b/Package.swift index 6b53c5d8..1e616680 100644 --- a/Package.swift +++ b/Package.swift @@ -25,14 +25,22 @@ let package = Package( ], targets: [ .target( - name: "KlaviyoSwift", + name: "KlaviyoCore", dependencies: [.product(name: "AnyCodable", package: "AnyCodable")], + path: "Sources/KlaviyoCore"), + .testTarget( + name: "KlaviyoCoreTests", + dependencies: [ + "KlaviyoCore", + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "CasePaths", package: "swift-case-paths") + ]), + .target( + name: "KlaviyoSwift", + dependencies: [.product(name: "AnyCodable", package: "AnyCodable"), "KlaviyoCore"], path: "Sources/KlaviyoSwift", resources: [.copy("PrivacyInfo.xcprivacy")]), - .target( - name: "KlaviyoSwiftExtension", - dependencies: [], - path: "Sources/KlaviyoSwiftExtension"), .testTarget( name: "KlaviyoSwiftTests", dependencies: [ @@ -40,9 +48,14 @@ let package = Package( .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "CustomDump", package: "swift-custom-dump"), .product(name: "CasePaths", package: "swift-case-paths"), - .product(name: "CombineSchedulers", package: "combine-schedulers") + .product(name: "CombineSchedulers", package: "combine-schedulers"), + "KlaviyoCore" ], exclude: [ "__Snapshots__" - ]) + ]), + .target( + name: "KlaviyoSwiftExtension", + dependencies: [], + path: "Sources/KlaviyoSwiftExtension") ]) diff --git a/Sources/KlaviyoSwift/AppContextInfo.swift b/Sources/KlaviyoCore/AppContextInfo.swift similarity index 69% rename from Sources/KlaviyoSwift/AppContextInfo.swift rename to Sources/KlaviyoCore/AppContextInfo.swift index 623d7303..336e656d 100644 --- a/Sources/KlaviyoSwift/AppContextInfo.swift +++ b/Sources/KlaviyoCore/AppContextInfo.swift @@ -7,18 +7,18 @@ import Foundation import UIKit -struct AppContextInfo { +public struct AppContextInfo { private static let info = Bundle.main.infoDictionary - private static let defaultExecutable: String = (info?["CFBundleExecutable"] as? String) ?? + public static let defaultExecutable: String = (info?["CFBundleExecutable"] as? String) ?? (ProcessInfo.processInfo.arguments.first?.split(separator: "/").last.map(String.init)) ?? "Unknown" - private static let defaultBundleId: String = info?["CFBundleIdentifier"] as? String ?? "Unknown" - private static let defaultAppVersion: String = info?["CFBundleShortVersionString"] as? String ?? "Unknown" - private static let defaultAppBuild: String = info?["CFBundleVersion"] as? String ?? "Unknown" - private static let defaultAppName: String = info?["CFBundleName"] as? String ?? "Unknown" - private static let defaultOSVersion = ProcessInfo.processInfo.operatingSystemVersion - private static let defaultManufacturer = "Apple" - private static let defaultOSName = "iOS" - private static let defaultDeviceModel: String = { + public static let defaultBundleId: String = info?["CFBundleIdentifier"] as? String ?? "Unknown" + public static let defaultAppVersion: String = info?["CFBundleShortVersionString"] as? String ?? "Unknown" + public static let defaultAppBuild: String = info?["CFBundleVersion"] as? String ?? "Unknown" + public static let defaultAppName: String = info?["CFBundleName"] as? String ?? "Unknown" + public static let defaultOSVersion = ProcessInfo.processInfo.operatingSystemVersion + public static let defaultManufacturer = "Apple" + public static let defaultOSName = "iOS" + public static let defaultDeviceModel: String = { var size = 0 var deviceModel = "" sysctlbyname("hw.machine", nil, &size, nil, 0) @@ -52,16 +52,16 @@ struct AppContextInfo { "\(osName) \(osVersion)" } - init(executable: String = defaultExecutable, - bundleId: String = defaultBundleId, - appVersion: String = defaultAppVersion, - appBuild: String = defaultAppBuild, - appName: String = defaultAppName, - version: OperatingSystemVersion = defaultOSVersion, - osName: String = defaultOSName, - manufacturer: String = defaultManufacturer, - deviceModel: String = defaultDeviceModel, - deviceId: String = UIDevice.current.identifierForVendor?.uuidString ?? "") { + public init(executable: String = defaultExecutable, + bundleId: String = defaultBundleId, + appVersion: String = defaultAppVersion, + appBuild: String = defaultAppBuild, + appName: String = defaultAppName, + version: OperatingSystemVersion = defaultOSVersion, + osName: String = defaultOSName, + manufacturer: String = defaultManufacturer, + deviceModel: String = defaultDeviceModel, + deviceId: String = UIDevice.current.identifierForVendor?.uuidString ?? "") { self.executable = executable self.bundleId = bundleId self.appVersion = appVersion diff --git a/Sources/KlaviyoSwift/AppLifeCycleEvents.swift b/Sources/KlaviyoCore/AppLifeCycleEvents.swift similarity index 68% rename from Sources/KlaviyoSwift/AppLifeCycleEvents.swift rename to Sources/KlaviyoCore/AppLifeCycleEvents.swift index 97fa8b74..175c36f7 100644 --- a/Sources/KlaviyoSwift/AppLifeCycleEvents.swift +++ b/Sources/KlaviyoCore/AppLifeCycleEvents.swift @@ -9,18 +9,27 @@ import Combine import Foundation import UIKit -enum LifeCycleErrors: Error { +public enum LifeCycleErrors: Error { case invalidReachaibilityStatus } -struct AppLifeCycleEvents { - var lifeCycleEvents: () -> AnyPublisher = { +public enum LifeCycleEvents { + case terminated + case foregrounded + case backgrounded + case reachabilityChanged(status: Reachability.NetworkStatus) +} + +public struct AppLifeCycleEvents { + public var lifeCycleEvents: () -> AnyPublisher + + public init(lifeCycleEvents: @escaping () -> AnyPublisher = { let terminated = environment .notificationCenterPublisher(UIApplication.willTerminateNotification) .handleEvents(receiveOutput: { _ in environment.stopReachability() }) - .map { _ in KlaviyoAction.stop } + .map { _ in LifeCycleEvents.terminated } let foregrounded = environment .notificationCenterPublisher(UIApplication.didBecomeActiveNotification) .handleEvents(receiveOutput: { _ in @@ -30,23 +39,20 @@ struct AppLifeCycleEvents { environment.emitDeveloperWarning("failure to start reachability notifier") } }) - .map { _ in KlaviyoAction.start } + .map { _ in LifeCycleEvents.foregrounded } let backgrounded = environment .notificationCenterPublisher(UIApplication.didEnterBackgroundNotification) .handleEvents(receiveOutput: { _ in environment.stopReachability() }) - .map { _ in KlaviyoAction.stop } + .map { _ in LifeCycleEvents.backgrounded } // The below is a bit convoluted since network status can be nil. let reachability = environment .notificationCenterPublisher(ReachabilityChangedNotification) - .compactMap { _ -> KlaviyoAction? in - guard let status = environment.reachabilityStatus() else { - return nil - } - return KlaviyoAction.networkConnectivityChanged(status) + .compactMap { _ in + let status = environment.reachabilityStatus() ?? .reachableViaWWAN + return LifeCycleEvents.reachabilityChanged(status: status) } - .eraseToAnyPublisher() return terminated .merge(with: reachability) @@ -60,6 +66,8 @@ struct AppLifeCycleEvents { }) .receive(on: RunLoop.main) .eraseToAnyPublisher() + }) { + self.lifeCycleEvents = lifeCycleEvents } static let production = Self() diff --git a/Sources/KlaviyoCore/KlaviyoEnvironment.swift b/Sources/KlaviyoCore/KlaviyoEnvironment.swift new file mode 100644 index 00000000..c6f2cae8 --- /dev/null +++ b/Sources/KlaviyoCore/KlaviyoEnvironment.swift @@ -0,0 +1,193 @@ +// +// KlaviyoEnvironment.swift +// KlaviyoSwift +// +// Created by Noah Durell on 9/28/22. +// + +import AnyCodable +import Combine +import Foundation +import UIKit + +public var environment = KlaviyoEnvironment.production + +public struct KlaviyoEnvironment { + public init( + archiverClient: ArchiverClient, + fileClient: FileClient, + dataFromUrl: @escaping (URL) throws -> Data, + logger: LoggerClient, + appLifeCycle: AppLifeCycleEvents, + notificationCenterPublisher: @escaping (NSNotification.Name) -> AnyPublisher, + getNotificationSettings: @escaping () async -> PushEnablement, + getBackgroundSetting: @escaping () -> PushBackground, + startReachability: @escaping () throws -> Void, + stopReachability: @escaping () -> Void, + reachabilityStatus: @escaping () -> Reachability.NetworkStatus?, + randomInt: @escaping () -> Int, + raiseFatalError: @escaping (String) -> Void, + emitDeveloperWarning: @escaping (String) -> Void, + networkSession: @escaping () -> NetworkSession, + apiURL: @escaping () -> String, + encodeJSON: @escaping (AnyEncodable) throws -> Data, + decoder: DataDecoder, + uuid: @escaping () -> UUID, + date: @escaping () -> Date, + timeZone: @escaping () -> String, + appContextInfo: @escaping () -> AppContextInfo, + klaviyoAPI: KlaviyoAPI, + timer: @escaping (Double) -> AnyPublisher, + SDKName: @escaping () -> String, + SDKVersion: @escaping () -> String) { + self.archiverClient = archiverClient + self.fileClient = fileClient + self.dataFromUrl = dataFromUrl + self.logger = logger + self.appLifeCycle = appLifeCycle + self.notificationCenterPublisher = notificationCenterPublisher + self.getNotificationSettings = getNotificationSettings + self.getBackgroundSetting = getBackgroundSetting + self.startReachability = startReachability + self.stopReachability = stopReachability + self.reachabilityStatus = reachabilityStatus + self.randomInt = randomInt + self.raiseFatalError = raiseFatalError + self.emitDeveloperWarning = emitDeveloperWarning + self.networkSession = networkSession + self.apiURL = apiURL + self.encodeJSON = encodeJSON + self.decoder = decoder + self.uuid = uuid + self.date = date + self.timeZone = timeZone + self.appContextInfo = appContextInfo + self.klaviyoAPI = klaviyoAPI + self.timer = timer + sdkName = SDKName + sdkVersion = SDKVersion + } + + static let productionHost = "https://a.klaviyo.com" + public static let encoder = { () -> JSONEncoder in + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + static let decoder = { () -> JSONDecoder in + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private static let reachabilityService = Reachability(hostname: URL(string: productionHost)!.host!) + + public var archiverClient: ArchiverClient + public var fileClient: FileClient + public var dataFromUrl: (URL) throws -> Data + + public var logger: LoggerClient + + public var appLifeCycle: AppLifeCycleEvents + + public var notificationCenterPublisher: (NSNotification.Name) -> AnyPublisher + public var getNotificationSettings: () async -> PushEnablement + public var getBackgroundSetting: () -> PushBackground + + public var startReachability: () throws -> Void + public var stopReachability: () -> Void + public var reachabilityStatus: () -> Reachability.NetworkStatus? + + public var randomInt: () -> Int + + public var raiseFatalError: (String) -> Void + public var emitDeveloperWarning: (String) -> Void + + public var networkSession: () -> NetworkSession + public var apiURL: () -> String + public var encodeJSON: (AnyEncodable) throws -> Data + public var decoder: DataDecoder + public var uuid: () -> UUID + public var date: () -> Date + public var timeZone: () -> String + public var appContextInfo: () -> AppContextInfo + public var klaviyoAPI: KlaviyoAPI + public var timer: (Double) -> AnyPublisher + + public var sdkName: () -> String + public var sdkVersion: () -> String + + public static var production = KlaviyoEnvironment( + archiverClient: ArchiverClient.production, + fileClient: FileClient.production, + dataFromUrl: { url in try Data(contentsOf: url) }, + logger: LoggerClient.production, + appLifeCycle: AppLifeCycleEvents.production, + notificationCenterPublisher: { name in + NotificationCenter.default.publisher(for: name) + .eraseToAnyPublisher() + }, + getNotificationSettings: { + let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() + return PushEnablement.create(from: notificationSettings.authorizationStatus) + }, + getBackgroundSetting: { .create(from: UIApplication.shared.backgroundRefreshStatus) }, + startReachability: { + try reachabilityService?.startNotifier() + }, + stopReachability: { + reachabilityService?.stopNotifier() + }, + reachabilityStatus: { + reachabilityService?.currentReachabilityStatus + }, + randomInt: { Int.random(in: 0...10) }, + raiseFatalError: { msg in + #if DEBUG + fatalError(msg) + #endif + }, + emitDeveloperWarning: { runtimeWarn($0) }, + networkSession: createNetworkSession, + apiURL: { KlaviyoEnvironment.productionHost }, + encodeJSON: { encodable in try encoder.encode(encodable) }, + decoder: DataDecoder.production, + uuid: { UUID() }, + date: { Date() }, + timeZone: { TimeZone.autoupdatingCurrent.identifier }, + appContextInfo: { AppContextInfo() }, + klaviyoAPI: KlaviyoAPI(), + timer: { interval in + Timer.publish(every: interval, on: .main, in: .default) + .autoconnect() + .eraseToAnyPublisher() + }, + SDKName: { __klaviyoSwiftName }, + SDKVersion: { __klaviyoSwiftVersion }) +} + +public var networkSession: NetworkSession! +public func createNetworkSession() -> NetworkSession { + if networkSession == nil { + networkSession = NetworkSession.production + } + return networkSession +} + +public enum KlaviyoDecodingError: Error { + case invalidType +} + +public struct DataDecoder { + public init(jsonDecoder: JSONDecoder) { + self.jsonDecoder = jsonDecoder + } + + public var jsonDecoder: JSONDecoder + public static let production = Self(jsonDecoder: KlaviyoEnvironment.decoder) + + public func decode(_ data: Data) throws -> T { + try jsonDecoder.decode(T.self, from: data) + } +} diff --git a/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift b/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift new file mode 100644 index 00000000..f7e21d41 --- /dev/null +++ b/Sources/KlaviyoCore/Models/APIModels/CreateEventPayload.swift @@ -0,0 +1,134 @@ +// +// CreateEventPayload.swift +// +// +// Created by Ajay Subramanya on 8/5/24. +// + +import AnyCodable +import Foundation + +public struct CreateEventPayload: Equatable, Codable { + public struct Event: Equatable, Codable { + public struct Attributes: Equatable, Codable { + public struct Metric: Equatable, Codable { + public let data: MetricData + + public struct MetricData: Equatable, Codable { + var type: String = "metric" + + public let attributes: MetricAttributes + + public init(name: String) { + attributes = .init(name: name) + } + + public struct MetricAttributes: Equatable, Codable { + public let name: String + } + } + + public init(name: String) { + data = .init(name: name) + } + } + + public struct Profile: Equatable, Codable { + public let data: ProfilePayload + + public init(data: ProfilePayload) { + self.data = data + } + } + + public let metric: Metric + public var properties: AnyCodable + public let profile: Profile + public let time: Date + public let value: Double? + public let uniqueId: String + public init(name: String, + properties: [String: Any]? = nil, + email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + anonymousId: String? = nil, + value: Double? = nil, + time: Date? = nil, + uniqueId: String? = nil) { + metric = Metric(name: name) + self.properties = AnyCodable(properties ?? [:]) + self.value = value + self.time = time ?? environment.date() + self.uniqueId = uniqueId ?? environment.uuid().uuidString + profile = Profile( + data: ProfilePayload( + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + anonymousId: anonymousId ?? "") + ) + } + + enum CodingKeys: String, CodingKey { + case metric + case properties + case profile + case time + case value + case uniqueId = "unique_id" + } + } + + var type = "event" + public var attributes: Attributes + public init(name: String, + properties: [String: Any]? = nil, + email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + anonymousId: String? = nil, + value: Double? = nil, + time: Date? = nil, + uniqueId: String? = nil, + pushToken: String? = nil) { + attributes = Attributes( + name: name, + properties: properties?.appendMetadataToProperties(pushToken: pushToken), + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + anonymousId: anonymousId, + value: value, + time: time, + uniqueId: uniqueId) + } + } + + public var data: Event + public init(data: Event) { + self.data = data + } +} + +extension Dictionary where Key == String, Value == Any { + fileprivate func appendMetadataToProperties(pushToken: String?) -> [String: Any]? { + let context = environment.appContextInfo() + let metadata: [String: Any] = [ + "Device ID": context.deviceId, + "Device Manufacturer": context.manufacturer, + "Device Model": context.deviceModel, + "OS Name": context.osName, + "OS Version": context.osVersion, + "SDK Name": environment.sdkName(), + "SDK Version": environment.sdkVersion(), + "App Name": context.appName, + "App ID": context.bundleId, + "App Version": context.appVersion, + "App Build": context.appBuild, + "Push Token": pushToken ?? "" + ] + + return merging(metadata) { _, new in new } + } +} diff --git a/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift b/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift new file mode 100644 index 00000000..ce198546 --- /dev/null +++ b/Sources/KlaviyoCore/Models/APIModels/CreateProfilePayload.swift @@ -0,0 +1,17 @@ +// +// CreateProfilePayload.swift +// +// +// Created by Ajay Subramanya on 8/5/24. +// + +import AnyCodable +import Foundation + +public struct CreateProfilePayload: Equatable, Codable { + public init(data: ProfilePayload) { + self.data = data + } + + public var data: ProfilePayload +} diff --git a/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift b/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift new file mode 100644 index 00000000..2711a771 --- /dev/null +++ b/Sources/KlaviyoCore/Models/APIModels/ProfilePayload.swift @@ -0,0 +1,128 @@ +// +// ProfilePayload.swift +// +// +// Created by Ajay Subramanya on 8/6/24. +// + +import AnyCodable +import Foundation + +/** + Internal structure which has details not needed by the API. + */ +public struct ProfilePayload: Equatable, Codable { + var type = "profile" + public struct Attributes: Equatable, Codable { + public let anonymousId: String + public let email: String? + public let phoneNumber: String? + public let externalId: String? + public var firstName: String? + public var lastName: String? + public var organization: String? + public var title: String? + public var image: String? + public var location: Location? + public var properties: AnyCodable + enum CodingKeys: String, CodingKey { + case email + case phoneNumber = "phone_number" + case externalId = "external_id" + case anonymousId = "anonymous_id" + case firstName = "first_name" + case lastName = "last_name" + case organization + case title + case image + case location + case properties + } + + public init(email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + firstName: String? = nil, + lastName: String? = nil, + organization: String? = nil, + title: String? = nil, + image: String? = nil, + location: Location? = nil, + properties: [String: Any]? = nil, + anonymousId: String) { + self.email = email + self.phoneNumber = phoneNumber + self.externalId = externalId + self.firstName = firstName + self.lastName = lastName + self.organization = organization + self.title = title + self.image = image + self.location = location + self.properties = AnyCodable(properties ?? [:]) + self.anonymousId = anonymousId + } + + public struct Location: Equatable, Codable { + public var address1: String? + public var address2: String? + public var city: String? + public var country: String? + public var latitude: Double? + public var longitude: Double? + public var region: String? + public var zip: String? + public var timezone: String? + public init(address1: String? = nil, + address2: String? = nil, + city: String? = nil, + country: String? = nil, + latitude: Double? = nil, + longitude: Double? = nil, + region: String? = nil, + zip: String? = nil, + timezone: String? = nil) { + self.address1 = address1 + self.address2 = address2 + self.city = city + self.country = country + self.latitude = latitude + self.longitude = longitude + self.region = region + self.zip = zip + self.timezone = timezone ?? environment.timeZone() + } + } + } + + public var attributes: Attributes + + public init(email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + firstName: String? = nil, + lastName: String? = nil, + organization: String? = nil, + title: String? = nil, + image: String? = nil, + location: Attributes.Location? = nil, + properties: [String: Any]? = nil, + anonymousId: String) { + attributes = Attributes( + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + firstName: firstName, + lastName: lastName, + organization: organization, + title: title, + image: image, + location: location, + properties: properties, + anonymousId: anonymousId) + } + + public init(attributes: Attributes) { + self.attributes = attributes + } +} diff --git a/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift b/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift new file mode 100644 index 00000000..441f5c88 --- /dev/null +++ b/Sources/KlaviyoCore/Models/APIModels/PushTokenPayload.swift @@ -0,0 +1,128 @@ +// +// PushTokenPayload.swift +// +// +// Created by Ajay Subramanya on 8/5/24. +// + +import Foundation + +public struct PushTokenPayload: Equatable, Codable { + public let data: PushToken + + public struct PushToken: Equatable, Codable { + var type = "push-token" + public var attributes: Attributes + + public init(pushToken: String, + enablement: String, + background: String, + profile: ProfilePayload) { + attributes = Attributes( + pushToken: pushToken, + enablement: enablement, + background: background, + profile: profile) + } + + public struct Attributes: Equatable, Codable { + public let profile: Profile + public let token: String + public let enablementStatus: String + public let backgroundStatus: String + public let deviceMetadata: MetaData + public let platform: String = "ios" + public let vendor: String = "APNs" + + enum CodingKeys: String, CodingKey { + case token + case platform + case enablementStatus = "enablement_status" + case profile + case vendor + case backgroundStatus = "background" + case deviceMetadata = "device_metadata" + } + + public init(pushToken: String, + enablement: String, + background: String, + profile: ProfilePayload) { + token = pushToken + + enablementStatus = enablement + backgroundStatus = background + self.profile = Profile(data: profile) + deviceMetadata = MetaData(context: environment.appContextInfo()) + } + + public struct Profile: Equatable, Codable { + public let data: ProfilePayload + + public init(data: ProfilePayload) { + self.data = data + } + } + + public struct MetaData: Equatable, Codable { + public let deviceId: String + public let deviceModel: String + public let manufacturer: String + public let osName: String + public let osVersion: String + public let appId: String + public let appName: String + public let appVersion: String + public let appBuild: String + public let environment: String + public let klaviyoSdk: String + public let sdkVersion: String + + enum CodingKeys: String, CodingKey { + case deviceId = "device_id" + case klaviyoSdk = "klaviyo_sdk" + case sdkVersion = "sdk_version" + case deviceModel = "device_model" + case osName = "os_name" + case osVersion = "os_version" + case manufacturer + case appName = "app_name" + case appVersion = "app_version" + case appBuild = "app_build" + case appId = "app_id" + case environment + } + + public init(context: AppContextInfo) { + deviceId = context.deviceId + deviceModel = context.deviceModel + manufacturer = context.manufacturer + osName = context.osName + osVersion = context.osVersion + appId = context.bundleId + appName = context.appName + appVersion = context.appVersion + appBuild = context.appBuild + environment = context.environment + klaviyoSdk = KlaviyoCore.environment.sdkName() + sdkVersion = KlaviyoCore.environment.sdkVersion() + } + } + } + } + + public init(data: PushTokenPayload.PushToken) { + self.data = data + } + + public init(pushToken: String, + enablement: String, + background: String, + profile: ProfilePayload) { + data = PushToken( + pushToken: pushToken, + enablement: enablement, + background: background, + profile: profile) + } +} diff --git a/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift b/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift new file mode 100644 index 00000000..4e17044b --- /dev/null +++ b/Sources/KlaviyoCore/Models/APIModels/UnregisterPushTokenPayload.swift @@ -0,0 +1,113 @@ +// +// UnregisterPushTokenPayload.swift +// +// +// Created by Ajay Subramanya on 8/5/24. +// + +import Foundation + +public struct UnregisterPushTokenPayload: Equatable, Codable { + public let data: PushToken + + public struct PushToken: Equatable, Codable { + var type = "push-token-unregister" + public let attributes: Attributes + + public init(pushToken: String, + email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + anonymousId: String) { + attributes = Attributes( + pushToken: pushToken, + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + anonymousId: anonymousId) + } + + public struct Attributes: Equatable, Codable { + public let profile: Profile + public let token: String + public let platform: String = "ios" + public let vendor: String = "APNs" + + enum CodingKeys: String, CodingKey { + case token + case platform + case profile + case vendor + } + + public init(pushToken: String, + email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + firstName: String? = nil, + lastName: String? = nil, + organization: String? = nil, + title: String? = nil, + image: String? = nil, + location: ProfilePayload.Attributes.Location? = nil, + properties: [String: Any]? = nil, + anonymousId: String) { + token = pushToken + profile = Profile( + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + firstName: firstName, + lastName: lastName, + organization: organization, + title: title, + image: image, + location: location, + properties: properties, + anonymousId: anonymousId) + } + + public struct Profile: Equatable, Codable { + public let data: ProfilePayload + + public init(email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + firstName: String? = nil, + lastName: String? = nil, + organization: String? = nil, + title: String? = nil, + image: String? = nil, + location: ProfilePayload.Attributes.Location? = nil, + properties: [String: Any]? = nil, + anonymousId: String) { + data = ProfilePayload(attributes: ProfilePayload.Attributes( + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + firstName: firstName, + lastName: lastName, + organization: organization, + title: title, + image: image, + location: location, + properties: properties, + anonymousId: anonymousId)) + } + } + } + } + + public init(pushToken: String, + email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + anonymousId: String) { + data = PushToken( + pushToken: pushToken, + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + anonymousId: anonymousId) + } +} diff --git a/Sources/KlaviyoCore/Models/KlaviyoAPIError.swift b/Sources/KlaviyoCore/Models/KlaviyoAPIError.swift new file mode 100644 index 00000000..e633c9ac --- /dev/null +++ b/Sources/KlaviyoCore/Models/KlaviyoAPIError.swift @@ -0,0 +1,20 @@ +// +// KlaviyoAPIError.swift +// +// +// Created by Ajay Subramanya on 8/8/24. +// + +import Foundation + +public enum KlaviyoAPIError: Error { + case httpError(Int, Data) + case rateLimitError(backOff: Int) + case missingOrInvalidResponse(URLResponse?) + case networkError(Error) + case internalError(String) + case internalRequestError(Error) + case unknownError(Error) + case dataEncodingError(KlaviyoRequest) + case invalidData +} diff --git a/Sources/KlaviyoCore/Models/PushBackground.swift b/Sources/KlaviyoCore/Models/PushBackground.swift new file mode 100644 index 00000000..ee0d58ae --- /dev/null +++ b/Sources/KlaviyoCore/Models/PushBackground.swift @@ -0,0 +1,27 @@ +// +// PushBackground.swift +// +// +// Created by Ajay Subramanya on 8/8/24. +// + +import UIKit + +public enum PushBackground: String, Codable { + case available = "AVAILABLE" + case restricted = "RESTRICTED" + case denied = "DENIED" + + public static func create(from status: UIBackgroundRefreshStatus) -> PushBackground { + switch status { + case .available: + return PushBackground.available + case .restricted: + return PushBackground.restricted + case .denied: + return PushBackground.denied + @unknown default: + return PushBackground.available + } + } +} diff --git a/Sources/KlaviyoCore/Models/PushEnablement.swift b/Sources/KlaviyoCore/Models/PushEnablement.swift new file mode 100644 index 00000000..1a321596 --- /dev/null +++ b/Sources/KlaviyoCore/Models/PushEnablement.swift @@ -0,0 +1,31 @@ +// +// PushEnablement.swift +// +// +// Created by Ajay Subramanya on 8/8/24. +// + +import UIKit + +public enum PushEnablement: String, Codable { + case notDetermined = "NOT_DETERMINED" + case denied = "DENIED" + case authorized = "AUTHORIZED" + case provisional = "PROVISIONAL" + case ephemeral = "EPHEMERAL" + + public static func create(from status: UNAuthorizationStatus) -> PushEnablement { + switch status { + case .denied: + return PushEnablement.denied + case .authorized: + return PushEnablement.authorized + case .provisional: + return PushEnablement.provisional + case .ephemeral: + return PushEnablement.ephemeral + default: + return PushEnablement.notDetermined + } + } +} diff --git a/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift b/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift new file mode 100644 index 00000000..135b76a6 --- /dev/null +++ b/Sources/KlaviyoCore/Networking/KlaviyoAPI.swift @@ -0,0 +1,71 @@ +// +// KlaviyoAPI.swift +// +// +// Created by Noah Durell on 11/8/22. +// + +import AnyCodable +import Foundation + +public struct KlaviyoAPI { + public var send: (KlaviyoRequest, Int) async -> Result + + public init(send: @escaping (KlaviyoRequest, Int) async -> Result = { request, attemptNumber in + let start = environment.date() + + var urlRequest: URLRequest + do { + urlRequest = try request.urlRequest(attemptNumber) + } catch { + requestHandler(request, nil, .error(.requestFailed(error))) + return .failure(.internalRequestError(error)) + } + + requestHandler(request, urlRequest, .started) + + var response: URLResponse + var data: Data + do { + (data, response) = try await environment.networkSession().data(urlRequest) + } catch { + requestHandler(request, urlRequest, .error(.requestFailed(error))) + return .failure(KlaviyoAPIError.networkError(error)) + } + + let end = environment.date() + let duration = end.timeIntervalSince(start) + + guard let httpResponse = response as? HTTPURLResponse else { + return .failure(.missingOrInvalidResponse(response)) + } + + if httpResponse.statusCode == 429, httpResponse.statusCode == 503 { + let exponentialBackOff = Int(pow(2.0, Double(attemptNumber))) + var nextBackoff: Int = exponentialBackOff + if let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After") { + nextBackoff = Int(retryAfter) ?? exponentialBackOff + } + + let jitter = environment.randomInt() + let nextBackOffWithJitter = nextBackoff + jitter + + requestHandler(request, urlRequest, .error(.rateLimited(retryAfter: nextBackOffWithJitter))) + return .failure(KlaviyoAPIError.rateLimitError(backOff: nextBackOffWithJitter)) + } + + guard 200..<300 ~= httpResponse.statusCode else { + requestHandler(request, urlRequest, .error(.httpError(statusCode: httpResponse.statusCode, duration: duration))) + return .failure(KlaviyoAPIError.httpError(httpResponse.statusCode, data)) + } + + requestHandler(request, urlRequest, .completed(data: data, duration: duration)) + + return .success(data) + }) { + self.send = send + } + + // For internal testing use only + public static var requestHandler: (KlaviyoRequest, URLRequest?, RequestStatus) -> Void = { _, _, _ in } +} diff --git a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift new file mode 100644 index 00000000..1528c5e1 --- /dev/null +++ b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift @@ -0,0 +1,17 @@ +// +// KlaviyoEndpoint.swift +// Internal models typically used at the networking layer. +// NOTE: Ensure that new request types are equatable and encodable. +// +// Created by Noah Durell on 11/25/22. +// + +import AnyCodable +import Foundation + +public enum KlaviyoEndpoint: Equatable, Codable { + case createProfile(CreateProfilePayload) + case createEvent(CreateEventPayload) + case registerPushToken(PushTokenPayload) + case unregisterPushToken(UnregisterPushTokenPayload) +} diff --git a/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift b/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift new file mode 100644 index 00000000..ac36f024 --- /dev/null +++ b/Sources/KlaviyoCore/Networking/KlaviyoRequest.swift @@ -0,0 +1,82 @@ +// +// KlaviyoRequest.swift +// +// +// Created by Ajay Subramanya on 8/5/24. +// + +import AnyCodable +import Foundation + +public struct KlaviyoRequest: Equatable, Codable { + public let apiKey: String + public let endpoint: KlaviyoEndpoint + public var uuid: String + + public init( + apiKey: String, + endpoint: KlaviyoEndpoint, + uuid: String = environment.uuid().uuidString) { + self.apiKey = apiKey + self.endpoint = endpoint + self.uuid = uuid + } + + public func urlRequest(_ attemptNumber: Int = 1) throws -> URLRequest { + guard let url = url else { + throw KlaviyoAPIError.internalError("Invalid url string. API URL: \(environment.apiURL())") + } + var request = URLRequest(url: url) + // We only support post right now + guard let body = try? encodeBody() else { + throw KlaviyoAPIError.dataEncodingError(self) + } + request.httpBody = body + request.httpMethod = "POST" + request.setValue("\(attemptNumber)/50", forHTTPHeaderField: "X-Klaviyo-Attempt-Count") + + return request + } + + var url: URL? { + switch endpoint { + case .createProfile, .createEvent, .registerPushToken, .unregisterPushToken: + if !environment.apiURL().isEmpty { + return URL(string: "\(environment.apiURL())/\(path)/?company_id=\(apiKey)") + } + return nil + } + } + + var path: String { + switch endpoint { + case .createProfile: + return "client/profiles" + + case .createEvent: + return "client/events" + + case .registerPushToken: + return "client/push-tokens" + + case .unregisterPushToken: + return "client/push-token-unregister" + } + } + + func encodeBody() throws -> Data { + switch endpoint { + case let .createProfile(payload): + return try environment.encodeJSON(AnyEncodable(payload)) + + case let .createEvent(payload): + return try environment.encodeJSON(AnyEncodable(payload)) + + case let .registerPushToken(payload): + return try environment.encodeJSON(AnyEncodable(payload)) + + case let .unregisterPushToken(payload): + return try environment.encodeJSON(AnyEncodable(payload)) + } + } +} diff --git a/Sources/KlaviyoSwift/NetworkSession.swift b/Sources/KlaviyoCore/Networking/NetworkSession.swift similarity index 78% rename from Sources/KlaviyoSwift/NetworkSession.swift rename to Sources/KlaviyoCore/Networking/NetworkSession.swift index d39868d4..59e70580 100644 --- a/Sources/KlaviyoSwift/NetworkSession.swift +++ b/Sources/KlaviyoCore/Networking/NetworkSession.swift @@ -7,7 +7,7 @@ import Foundation -func createEmphemeralSession(protocolClasses: [AnyClass] = URLProtocolOverrides.protocolClasses) -> URLSession { +public func createEmphemeralSession(protocolClasses: [AnyClass] = URLProtocolOverrides.protocolClasses) -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.httpAdditionalHeaders = [ "Accept-Encoding": NetworkSession.acceptedEncodings, @@ -21,21 +21,25 @@ func createEmphemeralSession(protocolClasses: [AnyClass] = URLProtocolOverrides. return URLSession(configuration: configuration) } -struct NetworkSession { +public struct NetworkSession { + public var data: (URLRequest) async throws -> (Data, URLResponse) + + public init(data: @escaping (URLRequest) async throws -> (Data, URLResponse)) { + self.data = data + } + fileprivate static let currentApiRevision = "2023-07-15" fileprivate static let applicationJson = "application/json" fileprivate static let acceptedEncodings = ["br", "gzip", "deflate"] fileprivate static let mobileHeader = "1" - static let defaultUserAgent = { () -> String in - let appContext = environment.analytics.appContextInfo() - let klaivyoSDKVersion = "klaviyo-ios/\(__klaviyoSwiftVersion)" + public static let defaultUserAgent = { () -> String in + let appContext = environment.appContextInfo() + let klaivyoSDKVersion = "klaviyo-ios/\(environment.sdkVersion())" return "\(appContext.executable)/\(appContext.appVersion) (\(appContext.bundleId); build:\(appContext.appBuild); \(appContext.osVersionName)) \(klaivyoSDKVersion)" }() - var data: (URLRequest) async throws -> (Data, URLResponse) - - static let production = { () -> NetworkSession in + public static let production = { () -> NetworkSession in let session = createEmphemeralSession() return NetworkSession(data: { request async throws -> (Data, URLResponse) in diff --git a/Sources/KlaviyoCore/Networking/PrivateMethods.swift b/Sources/KlaviyoCore/Networking/PrivateMethods.swift new file mode 100644 index 00000000..d52c5791 --- /dev/null +++ b/Sources/KlaviyoCore/Networking/PrivateMethods.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Ajay Subramanya on 8/29/24. +// + +import Foundation + +/// Used to override SDK defailts for INTERNAL USE ONLY +/// - Parameter url: The URL to use for Klaviyo client APIs, This is used internally to test the SDK against different backends, DO NOT use this in your apps. +/// - Parameter name: The name of the SDK, defaults to swift but react native will pass it's own name. DO NOT override this in your apps as our backend will not accept unsupported values here and your network requests will fail. +/// - Parameter version: The version of the swift SDK default to hard coded values here but react native will pass it's own values here. DO NOT override this in your apps. +@_spi(KlaviyoPrivate) +@available(*, deprecated, message: "This function is for internal use only, and should NOT be used in production applications") +public func overrideSDKDefaults(url: String? = nil, name: String? = nil, version: String? = nil) { + if let url = url { + environment.apiURL = { url } + } + + if let name = name { + environment.sdkName = { name } + } + + if let version = version { + environment.sdkVersion = { version } + } +} diff --git a/Sources/KlaviyoSwift/SDKRequestIterator.swift b/Sources/KlaviyoCore/Networking/SDKRequestIterator.swift similarity index 96% rename from Sources/KlaviyoSwift/SDKRequestIterator.swift rename to Sources/KlaviyoCore/Networking/SDKRequestIterator.swift index e6dd2fd1..4e6d2c8f 100644 --- a/Sources/KlaviyoSwift/SDKRequestIterator.swift +++ b/Sources/KlaviyoCore/Networking/SDKRequestIterator.swift @@ -1,5 +1,5 @@ // -// File.swift +// SDKRequestIterator.swift // // // Created by Noah Durell on 2/13/23. @@ -46,7 +46,7 @@ public struct SDKRequest: Identifiable, Equatable { case saveToken(token: String, info: ProfileInfo) case unregisterToken(token: String, info: ProfileInfo) - static func fromEndpoint(request: KlaviyoAPI.KlaviyoRequest) -> RequestType { + static func fromEndpoint(request: KlaviyoRequest) -> RequestType { switch request.endpoint { case let .createProfile(payload): @@ -86,7 +86,7 @@ public struct SDKRequest: Identifiable, Equatable { case requestError(String, Double) } - static func fromAPIRequest(request: KlaviyoAPI.KlaviyoRequest, urlRequest: URLRequest?, response: SDKRequest.Response) -> SDKRequest { + static func fromAPIRequest(request: KlaviyoRequest, urlRequest: URLRequest?, response: SDKRequest.Response) -> SDKRequest { let type = RequestType.fromEndpoint(request: request) let method = urlRequest?.httpMethod ?? "Unknown" let url = urlRequest?.url?.description ?? "Unknown" @@ -128,7 +128,6 @@ public struct SDKRequest: Identifiable, Equatable { } } -@_spi(KlaviyoPrivate) public enum RequestStatus { public enum RequestError: Error { case requestFailed(Error) diff --git a/Sources/KlaviyoSwift/ArchivalUtils.swift b/Sources/KlaviyoCore/Utils/ArchivalUtils.swift similarity index 73% rename from Sources/KlaviyoSwift/ArchivalUtils.swift rename to Sources/KlaviyoCore/Utils/ArchivalUtils.swift index 4c9c1c95..cd9c79e3 100644 --- a/Sources/KlaviyoSwift/ArchivalUtils.swift +++ b/Sources/KlaviyoCore/Utils/ArchivalUtils.swift @@ -1,5 +1,5 @@ // -// ArchiveUtils.swift +// ArchivalUtils.swift // KlaviyoSwift // // Created by Noah Durell on 9/26/22. @@ -7,11 +7,18 @@ import Foundation -struct ArchiverClient { - var archivedData: (Any, Bool) throws -> Data - var unarchivedMutableArray: (Data) throws -> NSMutableArray? +public struct ArchiverClient { + public init( + archivedData: @escaping (Any, Bool) throws -> Data, + unarchivedMutableArray: @escaping (Data) throws -> NSMutableArray?) { + self.archivedData = archivedData + self.unarchivedMutableArray = unarchivedMutableArray + } + + public var archivedData: (Any, Bool) throws -> Data + public var unarchivedMutableArray: (Data) throws -> NSMutableArray? - static let production = ArchiverClient( + public static let production = ArchiverClient( archivedData: NSKeyedArchiver.archivedData(withRootObject:requiringSecureCoding:), unarchivedMutableArray: { data in try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, NSDictionary.self, @@ -24,7 +31,7 @@ struct ArchiverClient { }) } -func archiveQueue(queue: NSArray, to fileURL: URL) { +public func archiveQueue(queue: NSArray, to fileURL: URL) { guard let archiveData = try? environment.archiverClient.archivedData(queue, true) else { print("unable to archive the data to \(fileURL)") return @@ -37,12 +44,12 @@ func archiveQueue(queue: NSArray, to fileURL: URL) { } } -func unarchiveFromFile(fileURL: URL) -> NSMutableArray? { +public func unarchiveFromFile(fileURL: URL) -> NSMutableArray? { guard environment.fileClient.fileExists(fileURL.path) else { print("Archive file not found.") return nil } - guard let archivedData = try? environment.data(fileURL) else { + guard let archivedData = try? environment.dataFromUrl(fileURL) else { print("Unable to read archived data.") return nil } diff --git a/Sources/KlaviyoSwift/FileUtils.swift b/Sources/KlaviyoCore/Utils/FileUtils.swift similarity index 65% rename from Sources/KlaviyoSwift/FileUtils.swift rename to Sources/KlaviyoCore/Utils/FileUtils.swift index ff8737e1..04693de4 100644 --- a/Sources/KlaviyoSwift/FileUtils.swift +++ b/Sources/KlaviyoCore/Utils/FileUtils.swift @@ -11,16 +11,28 @@ func write(data: Data, url: URL) throws { try data.write(to: url, options: .atomic) } -struct FileClient { - static let production = FileClient( +public struct FileClient { + public init( + write: @escaping (Data, URL) throws -> Void, + fileExists: @escaping (String) -> Bool, + removeItem: @escaping (String) throws -> Void, + libraryDirectory: @escaping () -> URL) { + self.write = write + self.fileExists = fileExists + self.removeItem = removeItem + self.libraryDirectory = libraryDirectory + } + + public var write: (Data, URL) throws -> Void + public var fileExists: (String) -> Bool + public var removeItem: (String) throws -> Void + public var libraryDirectory: () -> URL + + public static let production = FileClient( write: write(data:url:), fileExists: FileManager.default.fileExists(atPath:), removeItem: FileManager.default.removeItem(atPath:), libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! }) - var write: (Data, URL) throws -> Void - var fileExists: (String) -> Bool - var removeItem: (String) throws -> Void - var libraryDirectory: () -> URL } /** @@ -30,7 +42,7 @@ struct FileClient { - Parameter data: name representing the event queue to locate (will be either people or events) - Returns: filePath string representing the file location */ -func filePathForData(apiKey: String, data: String) -> URL { +public func filePathForData(apiKey: String, data: String) -> URL { let fileName = "klaviyo-\(apiKey)-\(data).plist" let directory = environment.fileClient.libraryDirectory() let filePath = directory.appendingPathComponent(fileName, isDirectory: false) @@ -43,7 +55,7 @@ func filePathForData(apiKey: String, data: String) -> URL { - Parameter at: path of file to be removed - Returns: whether or not the file was removed */ -func removeFile(at url: URL) -> Bool { +public func removeFile(at url: URL) -> Bool { if environment.fileClient.fileExists(url.path) { do { try environment.fileClient.removeItem(url.path) diff --git a/Sources/KlaviyoCore/Utils/LoggerClient.swift b/Sources/KlaviyoCore/Utils/LoggerClient.swift new file mode 100644 index 00000000..24778205 --- /dev/null +++ b/Sources/KlaviyoCore/Utils/LoggerClient.swift @@ -0,0 +1,40 @@ +// +// LoggerClient.swift +// KlaviyoSwift +// +// Created by Noah Durell on 10/21/22. +// + +import Foundation +#if canImport(os) +import os +#endif + +public struct LoggerClient { + public init(error: @escaping (String) -> Void) { + self.error = error + } + + public var error: (String) -> Void + public static let production = Self(error: { message in os_log("%{public}s", type: .error, message) }) +} + +@usableFromInline +@inline(__always) +func runtimeWarn( + _ message: @autoclosure () -> String, + category: String? = environment.sdkName(), + file: StaticString? = nil, + line: UInt? = nil) { + #if DEBUG + let message = message() + let category = category ?? "Runtime Warning" + #if canImport(os) + os_log( + .fault, + log: OSLog(subsystem: "com.apple.runtime-issues", category: category), + "%@", + message) + #endif + #endif +} diff --git a/Sources/KlaviyoSwift/Version.swift b/Sources/KlaviyoCore/Utils/Version.swift similarity index 100% rename from Sources/KlaviyoSwift/Version.swift rename to Sources/KlaviyoCore/Utils/Version.swift diff --git a/Sources/KlaviyoSwift/Vendor/ReachabilitySwift.swift b/Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift similarity index 55% rename from Sources/KlaviyoSwift/Vendor/ReachabilitySwift.swift rename to Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift index d4115afd..3f383bb9 100644 --- a/Sources/KlaviyoSwift/Vendor/ReachabilitySwift.swift +++ b/Sources/KlaviyoCore/Vendor/ReachabilitySwift.swift @@ -1,32 +1,32 @@ /* -Copyright (c) 2014, Ashley Mills -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -*/ + Copyright (c) 2014, Ashley Mills + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + */ -import SystemConfiguration import Foundation +import SystemConfiguration enum ReachabilityError: Error { case FailedToCreateWithAddress(sockaddr_in) @@ -35,10 +35,9 @@ enum ReachabilityError: Error { case UnableToSetDispatchQueue } -let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") - -func callback(reachability:SCNetworkReachability, flags: SCNetworkReachabilityFlags, info: UnsafeMutableRawPointer?) { +public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") +func callback(reachability: SCNetworkReachability, flags: SCNetworkReachabilityFlags, info: UnsafeMutableRawPointer?) { guard let info = info else { return } let reachability = Unmanaged.fromOpaque(info).takeUnretainedValue() @@ -48,16 +47,39 @@ func callback(reachability:SCNetworkReachability, flags: SCNetworkReachabilityFl } } -class Reachability { - - typealias NetworkReachable = (Reachability) -> () - typealias NetworkUnreachable = (Reachability) -> () +public class Reachability { + public init( + whenReachable: Reachability.NetworkReachable? = nil, + whenUnreachable: Reachability.NetworkUnreachable? = nil, + reachableOnWWAN: Bool = false, + notificationCenter: NotificationCenter = .default, + previousFlags: SCNetworkReachabilityFlags? = nil, + isRunningOnDevice: Bool = { + #if targetEnvironment(simulator) + return false + #else + return true + #endif + }(), + notifierRunning: Bool = false, + reachabilityRef: SCNetworkReachability? = nil) { + self.whenReachable = whenReachable + self.whenUnreachable = whenUnreachable + self.reachableOnWWAN = reachableOnWWAN + self.notificationCenter = notificationCenter + self.previousFlags = previousFlags + self.isRunningOnDevice = isRunningOnDevice + self.notifierRunning = notifierRunning + self.reachabilityRef = reachabilityRef + } - enum NetworkStatus: CustomStringConvertible { + public typealias NetworkReachable = (Reachability) -> Void + public typealias NetworkUnreachable = (Reachability) -> Void + public enum NetworkStatus: CustomStringConvertible { case notReachable, reachableViaWiFi, reachableViaWWAN - var description: String { + public var description: String { switch self { case .reachableViaWWAN: return "Cellular" case .reachableViaWiFi: return "WiFi" @@ -71,10 +93,10 @@ class Reachability { var reachableOnWWAN: Bool // The notification center on which "reachability changed" events are being posted - var notificationCenter: NotificationCenter = NotificationCenter.default + var notificationCenter: NotificationCenter = .default var currentReachabilityString: String { - return "\(currentReachabilityStatus)" + "\(currentReachabilityStatus)" } var currentReachabilityStatus: NetworkStatus { @@ -90,20 +112,20 @@ class Reachability { return .notReachable } - fileprivate var previousFlags: SCNetworkReachabilityFlags? + private var previousFlags: SCNetworkReachabilityFlags? - fileprivate var isRunningOnDevice: Bool = { + private var isRunningOnDevice: Bool = { #if targetEnvironment(simulator) - return false + return false #else - return true + return true #endif }() - fileprivate var notifierRunning = false - fileprivate var reachabilityRef: SCNetworkReachability? + private var notifierRunning = false + private var reachabilityRef: SCNetworkReachability? - fileprivate let reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability") + private let reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability") required init(reachabilityRef: SCNetworkReachability) { reachableOnWWAN = true @@ -111,14 +133,12 @@ class Reachability { } convenience init?(hostname: String) { - guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { return nil } self.init(reachabilityRef: ref) } convenience init?() { - var zeroAddress = sockaddr() zeroAddress.sa_len = UInt8(MemoryLayout.size) zeroAddress.sa_family = sa_family_t(AF_INET) @@ -140,10 +160,9 @@ class Reachability { } extension Reachability { - // MARK: - *** Notifier methods *** - func startNotifier() throws { + func startNotifier() throws { guard let reachabilityRef = reachabilityRef, !notifierRunning else { return } var context = SCNetworkReachabilityContext(version: 0, info: nil, retain: nil, release: nil, copyDescription: nil) @@ -175,8 +194,8 @@ extension Reachability { } // MARK: - *** Connection test methods *** - var isReachable: Bool { + var isReachable: Bool { guard isReachableFlagSet else { return false } if isConnectionRequiredAndTransientFlagSet { @@ -195,11 +214,10 @@ extension Reachability { var isReachableViaWWAN: Bool { // Check we're not on the simulator, we're REACHABLE and check we're on WWAN - return isRunningOnDevice && isReachableFlagSet && isOnWWANFlagSet + isRunningOnDevice && isReachableFlagSet && isOnWWANFlagSet } var isReachableViaWiFi: Bool { - // Check we're reachable guard isReachableFlagSet else { return false } @@ -211,7 +229,6 @@ extension Reachability { } var description: String { - let W = isRunningOnDevice ? (isOnWWANFlagSet ? "W" : "-") : "X" let R = isReachableFlagSet ? "R" : "-" let c = isConnectionRequiredFlagSet ? "c" : "-" @@ -226,10 +243,8 @@ extension Reachability { } } -fileprivate extension Reachability { - - func reachabilityChanged() { - +extension Reachability { + fileprivate func reachabilityChanged() { let flags = reachabilityFlags guard previousFlags != flags else { return } @@ -237,51 +252,60 @@ fileprivate extension Reachability { let block = isReachable ? whenReachable : whenUnreachable block?(self) - self.notificationCenter.post(name: ReachabilityChangedNotification, object:self) + notificationCenter.post(name: ReachabilityChangedNotification, object: self) previousFlags = flags } - var isOnWWANFlagSet: Bool { + private var isOnWWANFlagSet: Bool { #if os(iOS) - return reachabilityFlags.contains(.isWWAN) + return reachabilityFlags.contains(.isWWAN) #else - return false + return false #endif } - var isReachableFlagSet: Bool { - return reachabilityFlags.contains(.reachable) - } - var isConnectionRequiredFlagSet: Bool { - return reachabilityFlags.contains(.connectionRequired) + + private var isReachableFlagSet: Bool { + reachabilityFlags.contains(.reachable) } - var isInterventionRequiredFlagSet: Bool { - return reachabilityFlags.contains(.interventionRequired) + + private var isConnectionRequiredFlagSet: Bool { + reachabilityFlags.contains(.connectionRequired) } - var isConnectionOnTrafficFlagSet: Bool { - return reachabilityFlags.contains(.connectionOnTraffic) + + private var isInterventionRequiredFlagSet: Bool { + reachabilityFlags.contains(.interventionRequired) } - var isConnectionOnDemandFlagSet: Bool { - return reachabilityFlags.contains(.connectionOnDemand) + + private var isConnectionOnTrafficFlagSet: Bool { + reachabilityFlags.contains(.connectionOnTraffic) } - var isConnectionOnTrafficOrDemandFlagSet: Bool { - return !reachabilityFlags.intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty + + private var isConnectionOnDemandFlagSet: Bool { + reachabilityFlags.contains(.connectionOnDemand) } - var isTransientConnectionFlagSet: Bool { - return reachabilityFlags.contains(.transientConnection) + + private var isConnectionOnTrafficOrDemandFlagSet: Bool { + !reachabilityFlags.intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty } - var isLocalAddressFlagSet: Bool { - return reachabilityFlags.contains(.isLocalAddress) + + private var isTransientConnectionFlagSet: Bool { + reachabilityFlags.contains(.transientConnection) } - var isDirectFlagSet: Bool { - return reachabilityFlags.contains(.isDirect) + + private var isLocalAddressFlagSet: Bool { + reachabilityFlags.contains(.isLocalAddress) } - var isConnectionRequiredAndTransientFlagSet: Bool { - return reachabilityFlags.intersection([.connectionRequired, .transientConnection]) == [.connectionRequired, .transientConnection] + + private var isDirectFlagSet: Bool { + reachabilityFlags.contains(.isDirect) } - var reachabilityFlags: SCNetworkReachabilityFlags { + private var isConnectionRequiredAndTransientFlagSet: Bool { + reachabilityFlags.intersection([.connectionRequired, .transientConnection]) == [.connectionRequired, .transientConnection] + } + private var reachabilityFlags: SCNetworkReachabilityFlags { guard let reachabilityRef = reachabilityRef else { return SCNetworkReachabilityFlags() } var flags = SCNetworkReachabilityFlags() diff --git a/Sources/KlaviyoSwift/InternalAPIModels.swift b/Sources/KlaviyoSwift/InternalAPIModels.swift deleted file mode 100644 index 3b437644..00000000 --- a/Sources/KlaviyoSwift/InternalAPIModels.swift +++ /dev/null @@ -1,402 +0,0 @@ -// -// InternalAPIModels.swift -// Internal models typically used at the networking layer. -// NOTE: Ensure that new request types are equatable and encodable. -// -// Created by Noah Durell on 11/25/22. -// - -import AnyCodable -import Foundation - -extension KlaviyoAPI.KlaviyoRequest { - private static let _appContextInfo = environment.analytics.appContextInfo() - - enum KlaviyoEndpoint: Equatable, Codable { - struct CreateProfilePayload: Equatable, Codable { - /** - Internal structure which has details not needed by the API. - */ - struct Profile: Equatable, Codable { - var type = "profile" - struct Attributes: Equatable, Codable { - let email: String? - let phoneNumber: String? - let externalId: String? - let anonymousId: String - var firstName: String? - var lastName: String? - var organization: String? - var title: String? - var image: String? - var location: KlaviyoSwift.Profile.Location? - var properties: AnyCodable - enum CodingKeys: String, CodingKey { - case email - case phoneNumber = "phone_number" - case externalId = "external_id" - case anonymousId = "anonymous_id" - case firstName = "first_name" - case lastName = "last_name" - case organization - case title - case image - case location - case properties - } - - init(attributes: KlaviyoSwift.Profile, - anonymousId: String) { - email = attributes.email - phoneNumber = attributes.phoneNumber - externalId = attributes.externalId - firstName = attributes.firstName - lastName = attributes.lastName - organization = attributes.organization - title = attributes.title - image = attributes.image - location = attributes.location - properties = AnyCodable(attributes.properties) - self.anonymousId = anonymousId - } - } - - var attributes: Attributes - init(profile: KlaviyoSwift.Profile, anonymousId: String) { - attributes = Attributes( - attributes: profile, - anonymousId: anonymousId) - } - - init(attributes: Attributes) { - self.attributes = attributes - } - } - - var data: Profile - } - - struct CreateEventPayload: Equatable, Codable { - struct Event: Equatable, Codable { - struct Attributes: Equatable, Codable { - struct Metric: Equatable, Codable { - let data: MetricData - - struct MetricData: Equatable, Codable { - var type: String = "metric" - - let attributes: MetricAttributes - - init(name: String) { - attributes = .init(name: name) - } - - struct MetricAttributes: Equatable, Codable { - let name: String - } - } - - init(name: String) { - data = .init(name: name) - } - } - - struct Profile: Equatable, Codable { - let data: CreateProfilePayload.Profile - - init(attributes: KlaviyoSwift.Profile, - anonymousId: String) { - data = .init(profile: attributes, anonymousId: anonymousId) - } - } - - let metric: Metric - var properties: AnyCodable - let profile: Profile - let time: Date - let value: Double? - let uniqueId: String - init(attributes: KlaviyoSwift.Event, - anonymousId: String? = nil) { - metric = Metric(name: attributes.metric.name.value) - properties = AnyCodable(attributes.properties) - value = attributes.value - time = attributes.time - uniqueId = attributes.uniqueId - - profile = .init(attributes: .init( - email: attributes.identifiers?.email, - phoneNumber: attributes.identifiers?.phoneNumber, - externalId: attributes.identifiers?.externalId), - anonymousId: anonymousId ?? "") - } - - enum CodingKeys: String, CodingKey { - case metric - case properties - case profile - case time - case value - case uniqueId = "unique_id" - } - } - - var type = "event" - var attributes: Attributes - init(event: KlaviyoSwift.Event, - anonymousId: String? = nil) { - attributes = .init(attributes: event, anonymousId: anonymousId) - } - } - - mutating func appendMetadataToProperties() { - let context = KlaviyoAPI.KlaviyoRequest._appContextInfo - let metadata = [ - "Device ID": context.deviceId, - "Device Manufacturer": context.manufacturer, - "Device Model": context.deviceModel, - "OS Name": context.osName, - "OS Version": context.osVersion, - "SDK Name": __klaviyoSwiftName, - "SDK Version": __klaviyoSwiftVersion, - "App Name": context.appName, - "App ID": context.bundleId, - "App Version": context.appVersion, - "App Build": context.appBuild, - "Push Token": environment.analytics.state().pushTokenData?.pushToken as Any - ] - let originalProperties = data.attributes.properties.value as? [String: Any] ?? [:] - data.attributes.properties = AnyCodable(originalProperties.merging(metadata) { _, new in new }) - } - - var data: Event - init(data: Event) { - self.data = data - } - } - - struct PushTokenPayload: Equatable, Codable { - let data: PushToken - - init(pushToken: String, - enablement: String, - background: String, - profile: KlaviyoSwift.Profile, - anonymousId: String) { - data = .init( - pushToken: pushToken, - enablement: enablement, - background: background, - profile: profile, - anonymousId: anonymousId) - } - - struct PushToken: Equatable, Codable { - var type = "push-token" - var attributes: Attributes - - init(pushToken: String, - enablement: String, - background: String, - profile: KlaviyoSwift.Profile, - anonymousId: String) { - attributes = .init( - pushToken: pushToken, - enablement: enablement, - background: background, - profile: profile, - anonymousId: anonymousId) - } - - struct Attributes: Equatable, Codable { - let profile: Profile - let token: String - let enablementStatus: String - let backgroundStatus: String - let deviceMetadata: MetaData - let platform: String = "ios" - let vendor: String = "APNs" - - enum CodingKeys: String, CodingKey { - case token - case platform - case enablementStatus = "enablement_status" - case profile - case vendor - case backgroundStatus = "background" - case deviceMetadata = "device_metadata" - } - - init(pushToken: String, - enablement: String, - background: String, - profile: KlaviyoSwift.Profile, - anonymousId: String) { - token = pushToken - - enablementStatus = enablement - backgroundStatus = background - self.profile = .init(attributes: profile, anonymousId: anonymousId) - deviceMetadata = .init(context: KlaviyoAPI.KlaviyoRequest._appContextInfo) - } - - struct Profile: Equatable, Codable { - let data: CreateProfilePayload.Profile - - init(attributes: KlaviyoSwift.Profile, - anonymousId: String) { - data = .init(profile: attributes, anonymousId: anonymousId) - } - } - - struct MetaData: Equatable, Codable { - let deviceId: String - let deviceModel: String - let manufacturer: String - let osName: String - let osVersion: String - let appId: String - let appName: String - let appVersion: String - let appBuild: String - let environment: String - let klaviyoSdk: String - let sdkVersion: String - - enum CodingKeys: String, CodingKey { - case deviceId = "device_id" - case klaviyoSdk = "klaviyo_sdk" - case sdkVersion = "sdk_version" - case deviceModel = "device_model" - case osName = "os_name" - case osVersion = "os_version" - case manufacturer - case appName = "app_name" - case appVersion = "app_version" - case appBuild = "app_build" - case appId = "app_id" - case environment - } - - init(context: AppContextInfo) { - deviceId = context.deviceId - deviceModel = context.deviceModel - manufacturer = context.manufacturer - osName = context.osName - osVersion = context.osVersion - appId = context.bundleId - appName = context.appName - appVersion = context.appVersion - appBuild = context.appBuild - environment = context.environment - klaviyoSdk = __klaviyoSwiftName - sdkVersion = __klaviyoSwiftVersion - } - } - } - } - } - - struct UnregisterPushTokenPayload: Equatable, Codable { - let data: PushToken - - init(pushToken: String, - profile: KlaviyoSwift.Profile, - anonymousId: String) { - data = .init( - pushToken: pushToken, - profile: profile, - anonymousId: anonymousId) - } - - struct PushToken: Equatable, Codable { - var type = "push-token-unregister" - var attributes: Attributes - - init(pushToken: String, - profile: KlaviyoSwift.Profile, - anonymousId: String) { - attributes = .init( - pushToken: pushToken, - profile: profile, - anonymousId: anonymousId) - } - - struct Attributes: Equatable, Codable { - let profile: Profile - let token: String - let platform: String = "ios" - let vendor: String = "APNs" - - enum CodingKeys: String, CodingKey { - case token - case platform - case profile - case vendor - } - - init(pushToken: String, - profile: KlaviyoSwift.Profile, - anonymousId: String) { - token = pushToken - self.profile = .init(attributes: profile, anonymousId: anonymousId) - } - - struct Profile: Equatable, Codable { - let data: CreateProfilePayload.Profile - - init(attributes: KlaviyoSwift.Profile, - anonymousId: String) { - data = .init(profile: attributes, anonymousId: anonymousId) - } - } - } - } - } - - case createProfile(CreateProfilePayload) - case createEvent(CreateEventPayload) - case registerPushToken(PushTokenPayload) - case unregisterPushToken(UnregisterPushTokenPayload) - } -} - -extension Profile.Location: Codable { - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - address1 = try values.decode(String.self, forKey: .address1) - address2 = try values.decode(String.self, forKey: .address2) - city = try values.decode(String.self, forKey: .city) - latitude = try values.decode(Double.self, forKey: .latitude) - longitude = try values.decode(Double.self, forKey: .longitude) - region = try values.decode(String.self, forKey: .region) - self.zip = try values.decode(String.self, forKey: .zip) - timezone = try values.decode(String.self, forKey: .timezone) - country = try values.decode(String.self, forKey: .country) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(address1, forKey: .address1) - try container.encode(address2, forKey: .address2) - try container.encode(city, forKey: .city) - try container.encode(latitude, forKey: .latitude) - try container.encode(longitude, forKey: .longitude) - try container.encode(region, forKey: .region) - try container.encode(zip, forKey: .zip) - try container.encode(timezone, forKey: .timezone) - try container.encode(country, forKey: .country) - } - - enum CodingKeys: CodingKey { - case address1 - case address2 - case city - case country - case latitude - case longitude - case region - case zip - case timezone - } -} diff --git a/Sources/KlaviyoSwift/Klaviyo.swift b/Sources/KlaviyoSwift/Klaviyo.swift index 35558d5e..b2ffe4a5 100644 --- a/Sources/KlaviyoSwift/Klaviyo.swift +++ b/Sources/KlaviyoSwift/Klaviyo.swift @@ -7,12 +7,13 @@ import AnyCodable import Foundation +import KlaviyoCore import UIKit func dispatchOnMainThread(action: KlaviyoAction) { Task { await MainActor.run { - environment.analytics.send(action) + klaviyoSwiftEnvironment.send(action) } } } @@ -31,7 +32,7 @@ public struct KlaviyoSDK { public init() {} private var state: KlaviyoState { - environment.analytics.state() + klaviyoSwiftEnvironment.state() } /// Returns the email for the current user, if any. diff --git a/Sources/KlaviyoSwift/KlaviyoAPI.swift b/Sources/KlaviyoSwift/KlaviyoAPI.swift deleted file mode 100644 index f7b0bccb..00000000 --- a/Sources/KlaviyoSwift/KlaviyoAPI.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// KlaviyoAPI.swift -// -// -// Created by Noah Durell on 11/8/22. -// - -import AnyCodable -import Foundation - -@_spi(KlaviyoPrivate) -public func setKlaviyoAPIURL(url: String) { - environment.analytics.apiURL = url -} - -struct KlaviyoAPI { - struct KlaviyoRequest: Equatable, Codable { - public let apiKey: String - public let endpoint: KlaviyoEndpoint - public var uuid = environment.analytics.uuid().uuidString - } - - enum KlaviyoAPIError: Error { - case httpError(Int, Data) - case rateLimitError(Int?) - case missingOrInvalidResponse(URLResponse?) - case networkError(Error) - case internalError(String) - case internalRequestError(Error) - case unknownError(Error) - case dataEncodingError(KlaviyoRequest) - case invalidData - } - - // For internal testing use only - static var requestHandler: (KlaviyoRequest, URLRequest?, RequestStatus) -> Void = { _, _, _ in } - - var send: (KlaviyoRequest, Int) async -> Result = { request, attemptNumber in - let start = Date() - - var urlRequest: URLRequest - do { - urlRequest = try request.urlRequest(attemptNumber) - } catch { - requestHandler(request, nil, .error(.requestFailed(error))) - return .failure(.internalRequestError(error)) - } - - requestHandler(request, urlRequest, .started) - - var response: URLResponse - var data: Data - do { - (data, response) = try await environment.analytics.networkSession().data(urlRequest) - } catch { - requestHandler(request, urlRequest, .error(.requestFailed(error))) - return .failure(KlaviyoAPIError.networkError(error)) - } - - let end = Date() - let duration = end.timeIntervalSince(start) - - guard let httpResponse = response as? HTTPURLResponse else { - return .failure(.missingOrInvalidResponse(response)) - } - - if httpResponse.statusCode == 429 { - let retryAfter = Int(httpResponse.value(forHTTPHeaderField: "Retry-After") ?? "0") ?? 0 - requestHandler(request, urlRequest, .error(.rateLimited(retryAfter: retryAfter))) - return .failure(KlaviyoAPIError.rateLimitError(retryAfter)) - } - - guard 200..<300 ~= httpResponse.statusCode else { - requestHandler(request, urlRequest, .error(.httpError(statusCode: httpResponse.statusCode, duration: duration))) - return .failure(KlaviyoAPIError.httpError(httpResponse.statusCode, data)) - } - - requestHandler(request, urlRequest, .completed(data: data, duration: duration)) - - return .success(data) - } -} - -extension KlaviyoAPI.KlaviyoRequest { - func urlRequest(_ attemptNumber: Int = StateManagementConstants.initialAttempt) throws -> URLRequest { - guard let url = url else { - throw KlaviyoAPI.KlaviyoAPIError.internalError("Invalid url string. API URL: \(environment.analytics.apiURL)") - } - var request = URLRequest(url: url) - // We only support post right now - guard let body = try? encodeBody() else { - throw KlaviyoAPI.KlaviyoAPIError.dataEncodingError(self) - } - request.httpBody = body - request.httpMethod = "POST" - request.setValue("\(attemptNumber)/50", forHTTPHeaderField: "X-Klaviyo-Attempt-Count") - - return request - } - - var url: URL? { - switch endpoint { - case .createProfile, .createEvent, .registerPushToken, .unregisterPushToken: - if !environment.analytics.apiURL.isEmpty { - return URL(string: "\(environment.analytics.apiURL)/\(path)/?company_id=\(apiKey)") - } - return nil - } - } - - var path: String { - switch endpoint { - case .createProfile: - return "client/profiles" - - case .createEvent: - return "client/events" - - case .registerPushToken: - return "client/push-tokens" - - case .unregisterPushToken: - return "client/push-token-unregister" - } - } - - func encodeBody() throws -> Data { - switch endpoint { - case let .createProfile(payload): - return try environment.analytics.encodeJSON(AnyEncodable(payload)) - - case var .createEvent(payload): - payload.appendMetadataToProperties() - return try environment.analytics.encodeJSON(AnyEncodable(payload)) - - case let .registerPushToken(payload): - return try environment.analytics.encodeJSON(AnyEncodable(payload)) - - case let .unregisterPushToken(payload): - return try environment.analytics.encodeJSON(AnyEncodable(payload)) - } - } -} diff --git a/Sources/KlaviyoSwift/KlaviyoEnvironment.swift b/Sources/KlaviyoSwift/KlaviyoEnvironment.swift deleted file mode 100644 index 63e2ba64..00000000 --- a/Sources/KlaviyoSwift/KlaviyoEnvironment.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// KlaviyoEnvironment.swift -// KlaviyoSwift -// -// Created by Noah Durell on 9/28/22. -// - -import AnyCodable -import Combine -import Foundation -import UIKit - -var environment = KlaviyoEnvironment.production - -struct KlaviyoEnvironment { - fileprivate static let productionHost = "https://a.klaviyo.com" - static let encoder = { () -> JSONEncoder in - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return encoder - }() - - static let decoder = { () -> JSONDecoder in - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() - - private static let reachabilityService = Reachability(hostname: URL(string: productionHost)!.host!) - - var archiverClient: ArchiverClient - var fileClient: FileClient - var data: (URL) throws -> Data - var logger: LoggerClient - var analytics: AnalyticsEnvironment - var getUserDefaultString: (String) -> String? - var appLifeCycle: AppLifeCycleEvents - var notificationCenterPublisher: (NSNotification.Name) -> AnyPublisher - var getNotificationSettings: () async -> KlaviyoState.PushEnablement - var getBackgroundSetting: () -> KlaviyoState.PushBackground - var legacyIdentifier: () -> String - var startReachability: () throws -> Void - var stopReachability: () -> Void - var reachabilityStatus: () -> Reachability.NetworkStatus? - var randomInt: () -> Int - var stateChangePublisher: () -> AnyPublisher - var raiseFatalError: (String) -> Void - var emitDeveloperWarning: (String) -> Void - static var production = KlaviyoEnvironment( - archiverClient: ArchiverClient.production, - fileClient: FileClient.production, - data: { url in try Data(contentsOf: url) }, - logger: LoggerClient.production, - analytics: AnalyticsEnvironment.production, - getUserDefaultString: { key in UserDefaults.standard.string(forKey: key) }, - appLifeCycle: AppLifeCycleEvents.production, - notificationCenterPublisher: { name in - NotificationCenter.default.publisher(for: name) - .eraseToAnyPublisher() - }, - getNotificationSettings: { - let notificationSettings = await UNUserNotificationCenter.current().notificationSettings() - return KlaviyoState.PushEnablement.create(from: notificationSettings.authorizationStatus) - }, - getBackgroundSetting: { .create(from: UIApplication.shared.backgroundRefreshStatus) }, - legacyIdentifier: { "iOS:\(UIDevice.current.identifierForVendor!.uuidString)" }, - startReachability: { - try reachabilityService?.startNotifier() - }, - stopReachability: { - reachabilityService?.stopNotifier() - }, - reachabilityStatus: { - reachabilityService?.currentReachabilityStatus - }, - randomInt: { Int.random(in: 0...10) }, - stateChangePublisher: StateChangePublisher().publisher, raiseFatalError: { msg in - #if DEBUG - fatalError(msg) - #endif - }, emitDeveloperWarning: { runtimeWarn($0) }) -} - -private var networkSession: NetworkSession! -func createNetworkSession() -> NetworkSession { - if networkSession == nil { - networkSession = NetworkSession.production - } - return networkSession -} - -enum KlaviyoDecodingError: Error { - case invalidType -} - -struct DataDecoder { - func decode(_ data: Data) throws -> T { - try jsonDecoder.decode(T.self, from: data) - } - - var jsonDecoder: JSONDecoder - static let production = Self(jsonDecoder: KlaviyoEnvironment.decoder) -} - -struct AnalyticsEnvironment { - var networkSession: () -> NetworkSession - var apiURL: String - var encodeJSON: (AnyEncodable) throws -> Data - var decoder: DataDecoder - var uuid: () -> UUID - var date: () -> Date - var timeZone: () -> String - var appContextInfo: () -> AppContextInfo - var klaviyoAPI: KlaviyoAPI - var timer: (Double) -> AnyPublisher - var send: (KlaviyoAction) -> Task? - var state: () -> KlaviyoState - var statePublisher: () -> AnyPublisher - static let production: AnalyticsEnvironment = { - let store = Store.production - return AnalyticsEnvironment( - networkSession: createNetworkSession, - apiURL: KlaviyoEnvironment.productionHost, - encodeJSON: { encodable in try KlaviyoEnvironment.encoder.encode(encodable) }, - decoder: DataDecoder.production, - uuid: { UUID() }, - date: { Date() }, - timeZone: { TimeZone.autoupdatingCurrent.identifier }, - appContextInfo: { AppContextInfo() }, - klaviyoAPI: KlaviyoAPI(), - timer: { interval in - Timer.publish(every: interval, on: .main, in: .default) - .autoconnect() - .eraseToAnyPublisher() - }, - send: { action in - store.send(action) - }, - state: { store.state.value }, - statePublisher: { store.state.eraseToAnyPublisher() }) - }() -} diff --git a/Sources/KlaviyoSwift/KlaviyoModels.swift b/Sources/KlaviyoSwift/KlaviyoModels.swift deleted file mode 100644 index f9b25080..00000000 --- a/Sources/KlaviyoSwift/KlaviyoModels.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// KlaviyoModels.swift -// -// -// Created by Noah Durell on 11/25/22. -// - -import AnyCodable -import Foundation - -public struct Event: Equatable { - public enum EventName: Equatable { - case OpenedPush - case OpenedAppMetric - case ViewedProductMetric - case AddedToCartMetric - case StartedCheckoutMetric - case CustomEvent(String) - } - - public struct Metric: Equatable { - public let name: EventName - - public init(name: EventName) { - self.name = name - } - } - - struct Identifiers: Equatable { - public let email: String? - public let phoneNumber: String? - public let externalId: String? - public init(email: String? = nil, - phoneNumber: String? = nil, - externalId: String? = nil) { - self.email = email - self.phoneNumber = phoneNumber - self.externalId = externalId - } - } - - public let metric: Metric - public var properties: [String: Any] { - _properties.value as! [String: Any] - } - - private let _properties: AnyCodable - public var time: Date - public let value: Double? - public let uniqueId: String - let identifiers: Identifiers? - - init(name: EventName, - properties: [String: Any]? = nil, - identifiers: Identifiers? = nil, - value: Double? = nil, - time: Date? = nil, - uniqueId: String? = nil) { - metric = .init(name: name) - _properties = AnyCodable(properties ?? [:]) - self.time = time ?? environment.analytics.date() - self.value = value - self.uniqueId = uniqueId ?? environment.analytics.uuid().uuidString - self.identifiers = identifiers - } - - public init(name: EventName, - properties: [String: Any]? = nil, - value: Double? = nil, - uniqueId: String? = nil) { - metric = .init(name: name) - _properties = AnyCodable(properties ?? [:]) - identifiers = nil - self.value = value - time = environment.analytics.date() - self.uniqueId = uniqueId ?? environment.analytics.uuid().uuidString - } -} - -public struct Profile: Equatable { - public enum ProfileKey: Equatable, Hashable, Codable { - case firstName - case lastName - case address1 - case address2 - case title - case organization - case city - case region - case country - case zip - case image - case latitude - case longitude - case custom(customKey: String) - } - - public struct Location: Equatable { - public var address1: String? - public var address2: String? - public var city: String? - public var country: String? - public var latitude: Double? - public var longitude: Double? - public var region: String? - public var zip: String? - public var timezone: String? - public init(address1: String? = nil, - address2: String? = nil, - city: String? = nil, - country: String? = nil, - latitude: Double? = nil, - longitude: Double? = nil, - region: String? = nil, - zip: String? = nil, - timezone: String? = nil) { - self.address1 = address1 - self.address2 = address2 - self.city = city - self.country = country - self.latitude = latitude - self.longitude = longitude - self.region = region - self.zip = zip - self.timezone = timezone ?? environment.analytics.timeZone() - } - } - - public let email: String? - public let phoneNumber: String? - public let externalId: String? - public let firstName: String? - public let lastName: String? - public let organization: String? - public let title: String? - public let image: String? - public let location: Location? - public var properties: [String: Any] { - _properties.value as! [String: Any] - } - - let _properties: AnyCodable - - public init(email: String? = nil, - phoneNumber: String? = nil, - externalId: String? = nil, - firstName: String? = nil, - lastName: String? = nil, - organization: String? = nil, - title: String? = nil, - image: String? = nil, - location: Location? = nil, - properties: [String: Any]? = nil) { - self.email = email - self.phoneNumber = phoneNumber - self.externalId = externalId - self.firstName = firstName - self.lastName = lastName - self.organization = organization - self.title = title - self.image = image - self.location = location - _properties = AnyCodable(properties ?? [:]) - } -} - -extension Event.EventName { - public var value: String { - switch self { - case .OpenedPush: return "$opened_push" - case .OpenedAppMetric: return "Opened App" - case .ViewedProductMetric: return "Viewed Product" - case .AddedToCartMetric: return "Added to Cart" - case .StartedCheckoutMetric: return "Started Checkout" - case let .CustomEvent(value): return "\(value)" - } - } -} - -struct ErrorResponse: Codable { - let errors: [ErrorDetail] -} - -struct ErrorDetail: Codable { - let id: String - let status: Int - let code: String - let title: String - let detail: String - let source: ErrorSource -} - -struct ErrorSource: Codable { - let pointer: String -} diff --git a/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift b/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift new file mode 100644 index 00000000..ff8b9e0f --- /dev/null +++ b/Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift @@ -0,0 +1,30 @@ +// +// KlaviyoSwiftEnvironment.swift +// +// +// Created by Ajay Subramanya on 8/8/24. +// + +import Combine +import Foundation + +var klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.production + +struct KlaviyoSwiftEnvironment { + var send: (KlaviyoAction) -> Task? + var state: () -> KlaviyoState + var statePublisher: () -> AnyPublisher + var stateChangePublisher: () -> AnyPublisher + + static let production: KlaviyoSwiftEnvironment = { + let store = Store.production + + return KlaviyoSwiftEnvironment( + send: { action in + store.send(action) + }, + state: { store.state.value }, + statePublisher: { store.state.eraseToAnyPublisher() }, + stateChangePublisher: StateChangePublisher().publisher) + }() +} diff --git a/Sources/KlaviyoSwift/LoggerClient.swift b/Sources/KlaviyoSwift/LoggerClient.swift deleted file mode 100644 index 8ee9f423..00000000 --- a/Sources/KlaviyoSwift/LoggerClient.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// LoggerClient.swift -// KlaviyoSwift -// -// Created by Noah Durell on 10/21/22. -// - -import Foundation -import os - -struct LoggerClient { - var error: (String) -> Void - static let production = Self(error: { message in os_log("%{public}s", type: .error, message) }) -} diff --git a/Sources/KlaviyoSwift/Models/Error.swift b/Sources/KlaviyoSwift/Models/Error.swift new file mode 100644 index 00000000..a419cbc5 --- /dev/null +++ b/Sources/KlaviyoSwift/Models/Error.swift @@ -0,0 +1,25 @@ +// +// Error.swift +// +// +// Created by Ajay Subramanya on 8/6/24. +// + +import Foundation + +struct ErrorResponse: Codable { + let errors: [ErrorDetail] +} + +struct ErrorDetail: Codable { + let id: String + let status: Int + let code: String + let title: String + let detail: String + let source: ErrorSource +} + +struct ErrorSource: Codable { + let pointer: String +} diff --git a/Sources/KlaviyoSwift/Models/Event.swift b/Sources/KlaviyoSwift/Models/Event.swift new file mode 100644 index 00000000..e403a07b --- /dev/null +++ b/Sources/KlaviyoSwift/Models/Event.swift @@ -0,0 +1,98 @@ +// +// Event.swift +// +// +// Created by Ajay Subramanya on 8/6/24. +// + +import AnyCodable +import Foundation +import KlaviyoCore + +public struct Event: Equatable { + public enum EventName: Equatable { + case OpenedPush + case OpenedAppMetric + case ViewedProductMetric + case AddedToCartMetric + case StartedCheckoutMetric + case CustomEvent(String) + } + + public struct Metric: Equatable { + public let name: EventName + + public init(name: EventName) { + self.name = name + } + } + + struct Identifiers: Equatable { + public let email: String? + public let phoneNumber: String? + public let externalId: String? + public init(email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil) { + self.email = email + self.phoneNumber = phoneNumber + self.externalId = externalId + } + } + + public let metric: Metric + public var properties: [String: Any] { + _properties.value as! [String: Any] + } + + private let _properties: AnyCodable + public let time: Date + public let value: Double? + public let uniqueId: String + let identifiers: Identifiers? + + init(name: EventName, + properties: [String: Any]? = nil, + identifiers: Identifiers? = nil, + value: Double? = nil, + time: Date = environment.date(), + uniqueId: String = environment.uuid().uuidString) { + metric = .init(name: name) + _properties = AnyCodable(properties ?? [:]) + self.time = time + self.value = value + self.uniqueId = uniqueId + self.identifiers = identifiers + } + + /// Create a new event to track a profile's activity, the SDK will associate the event with any identified/anonymous profile in the SDK state + /// - Parameters: + /// - name: Name of the event. Must be less than 128 characters., pick from ``Event.EventName`` which can also contain custom events + /// - properties: Properties of this event. + /// - value: A numeric, monetary value to associate with this event. For example, the dollar amount of a purchase. + /// - uniqueId: A unique identifier for an event + public init(name: EventName, + properties: [String: Any]? = nil, + value: Double? = nil, + uniqueId: String? = nil) { + metric = .init(name: name) + _properties = AnyCodable(properties ?? [:]) + identifiers = nil + self.value = value + time = environment.date() + self.uniqueId = uniqueId ?? environment.uuid().uuidString + } +} + +extension Event.EventName { + public var value: String { + switch self { + case .OpenedPush: return "$opened_push" + case .OpenedAppMetric: return "Opened App" + case .ViewedProductMetric: return "Viewed Product" + case .AddedToCartMetric: return "Added to Cart" + case .StartedCheckoutMetric: return "Started Checkout" + case let .CustomEvent(value): return "\(value)" + } + } +} diff --git a/Sources/KlaviyoSwift/Models/LifecycleEventsExtension.swift b/Sources/KlaviyoSwift/Models/LifecycleEventsExtension.swift new file mode 100644 index 00000000..0bc16ffd --- /dev/null +++ b/Sources/KlaviyoSwift/Models/LifecycleEventsExtension.swift @@ -0,0 +1,24 @@ +// +// LifecycleEventsExtension.swift +// +// +// Created by Ajay Subramanya on 8/13/24. +// + +import Foundation +import KlaviyoCore + +extension LifeCycleEvents { + var transformToKlaviyoAction: KlaviyoAction { + switch self { + case .terminated: + return .stop + case .foregrounded: + return .start + case .backgrounded: + return .stop + case let .reachabilityChanged(status): + return .networkConnectivityChanged(status) + } + } +} diff --git a/Sources/KlaviyoSwift/Models/Profile.swift b/Sources/KlaviyoSwift/Models/Profile.swift new file mode 100644 index 00000000..60791d7a --- /dev/null +++ b/Sources/KlaviyoSwift/Models/Profile.swift @@ -0,0 +1,120 @@ +// +// Profile.swift +// +// +// Created by Ajay Subramanya on 8/6/24. +// + +import AnyCodable +import Foundation +import KlaviyoCore + +public struct Profile: Equatable { + public enum ProfileKey: Equatable, Hashable, Codable { + case firstName + case lastName + case address1 + case address2 + case title + case organization + case city + case region + case country + case zip + case image + case latitude + case longitude + case custom(customKey: String) + } + + public struct Location: Equatable { + public var address1: String? + public var address2: String? + public var city: String? + public var country: String? + public var latitude: Double? + public var longitude: Double? + public var region: String? + public var zip: String? + public var timezone: String? + + /// - Parameters: + /// - address1: First line of street address + /// - address2: Second line of street address + /// - city: city name + /// - country: country name + /// - latitude: Latitude coordinate. We recommend providing a precision of four decimal places. + /// - longitude: Longitude coordinate. We recommend providing a precision of four decimal places. + /// - region: Region within a country, such as state or province + /// - zip: Zip code + /// - timezone: Time zone name. We recommend using time zones from the IANA Time Zone Database. + public init(address1: String? = nil, + address2: String? = nil, + city: String? = nil, + country: String? = nil, + latitude: Double? = nil, + longitude: Double? = nil, + region: String? = nil, + zip: String? = nil, + timezone: String? = nil) { + self.address1 = address1 + self.address2 = address2 + self.city = city + self.country = country + self.latitude = latitude + self.longitude = longitude + self.region = region + self.zip = zip + self.timezone = timezone ?? environment.timeZone() + } + } + + public let email: String? + public let phoneNumber: String? + public let externalId: String? + public let firstName: String? + public let lastName: String? + public let organization: String? + public let title: String? + public let image: String? + public let location: Location? + public var properties: [String: Any] { + _properties.value as! [String: Any] + } + + let _properties: AnyCodable + + /// Create or update properties about a profile without tracking an associated event. + /// - Parameters: + /// - email: Individual's email address + /// - phoneNumber: Individual's phone number in E.164 format + /// - externalId: A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system, such as a point-of-sale system. Format varies based on the external system. + /// - firstName: Individual's first name + /// - lastName: Individual's last name + /// - organization: Individual's organization name + /// - title: Individual's title + /// - image: URL pointing to the location of a profile image + /// - location: Individual location + /// - properties: An object containing key/value pairs for any custom properties assigned to this profile + public init(email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + firstName: String? = nil, + lastName: String? = nil, + organization: String? = nil, + title: String? = nil, + image: String? = nil, + location: Location? = nil, + properties: [String: Any]? = nil) { + self.email = email + self.phoneNumber = phoneNumber + self.externalId = externalId + self.firstName = firstName + self.lastName = lastName + self.organization = organization + self.title = title + self.image = image + self.location = location + _properties = AnyCodable(properties ?? [:]) + } +} diff --git a/Sources/KlaviyoSwift/Models/ProfileAPIExtension.swift b/Sources/KlaviyoSwift/Models/ProfileAPIExtension.swift new file mode 100644 index 00000000..0fa3c7ac --- /dev/null +++ b/Sources/KlaviyoSwift/Models/ProfileAPIExtension.swift @@ -0,0 +1,51 @@ +// +// ProfileAPIExtension.swift +// +// +// Created by Ajay Subramanya on 8/6/24. +// + +import Foundation +import KlaviyoCore + +extension String { + fileprivate func returnNilIfEmpty() -> String? { + isEmpty ? nil : self + } +} + +extension Profile { + func toAPIModel( + email: String? = nil, + phoneNumber: String? = nil, + externalId: String? = nil, + anonymousId: String) -> ProfilePayload { + ProfilePayload( + email: email ?? self.email?.returnNilIfEmpty(), + phoneNumber: phoneNumber ?? self.phoneNumber?.returnNilIfEmpty(), + externalId: externalId ?? self.externalId?.returnNilIfEmpty(), + firstName: firstName, + lastName: lastName, + organization: organization, + title: title, + image: image, + location: location?.toAPILocation, + properties: properties, + anonymousId: anonymousId) + } +} + +extension Profile.Location { + var toAPILocation: ProfilePayload.Attributes.Location { + ProfilePayload.Attributes.Location( + address1: address1, + address2: address2, + city: city, + country: country, + latitude: latitude, + longitude: longitude, + region: region, + zip: zip, + timezone: timezone) + } +} diff --git a/Sources/KlaviyoSwift/APIRequestErrorHandling.swift b/Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift similarity index 90% rename from Sources/KlaviyoSwift/APIRequestErrorHandling.swift rename to Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift index 2c8a20ab..2aa88ade 100644 --- a/Sources/KlaviyoSwift/APIRequestErrorHandling.swift +++ b/Sources/KlaviyoSwift/StateManagement/APIRequestErrorHandling.swift @@ -6,8 +6,9 @@ // import Foundation +import KlaviyoCore -struct ErrorHandlingConstants { +enum ErrorHandlingConstants { static let maxRetries = 50 static let maxBackoff = 60 * 3 // 3 minutes } @@ -33,11 +34,6 @@ enum InvalidField: Equatable { } } -private func addJitter(to value: Int) -> Int { - let jitter = environment.randomInt() - return value + jitter -} - private func parseError(_ data: Data) -> [InvalidField]? { var invalidFields: [InvalidField]? do { @@ -54,8 +50,8 @@ private func parseError(_ data: Data) -> [InvalidField]? { } func handleRequestError( - request: KlaviyoAPI.KlaviyoRequest, - error: KlaviyoAPI.KlaviyoAPIError, + request: KlaviyoRequest, + error: KlaviyoAPIError, retryInfo: RetryInfo) -> KlaviyoAction { switch error { case let .httpError(statuscode, data): @@ -102,9 +98,6 @@ func handleRequestError( case let .rateLimitError(retryAfter): var requestRetryCount = 0 var totalRetryCount = 0 - let exponentialBackOff = Int(pow(2.0, Double(totalRetryCount))) - - let nextBackoff = addJitter(to: retryAfter ?? exponentialBackOff) switch retryInfo { case let .retry(count): requestRetryCount = count + 1 @@ -118,7 +111,7 @@ func handleRequestError( request, .retryWithBackoff( requestCount: requestRetryCount, totalRetryCount: totalRetryCount, - currentBackoff: nextBackoff)) + currentBackoff: retryAfter)) case .missingOrInvalidResponse: runtimeWarn("Missing or invalid response from api.") diff --git a/Sources/KlaviyoSwift/KlaviyoState.swift b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift similarity index 79% rename from Sources/KlaviyoSwift/KlaviyoState.swift rename to Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift index dbf6a907..142f323c 100644 --- a/Sources/KlaviyoSwift/KlaviyoState.swift +++ b/Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift @@ -7,10 +7,10 @@ import AnyCodable import Foundation +import KlaviyoCore import UIKit -typealias DeviceMetadata = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload.PushToken.Attributes.MetaData -typealias CreateProfilePayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateProfilePayload +typealias DeviceMetadata = PushTokenPayload.PushToken.Attributes.MetaData struct KlaviyoState: Equatable, Codable { enum InitializationState: Equatable, Codable { @@ -42,48 +42,6 @@ struct KlaviyoState: Equatable, Codable { } } - enum PushEnablement: String, Codable { - case notDetermined = "NOT_DETERMINED" - case denied = "DENIED" - case authorized = "AUTHORIZED" - case provisional = "PROVISIONAL" - case ephemeral = "EPHEMERAL" - - static func create(from status: UNAuthorizationStatus) -> PushEnablement { - switch status { - case .denied: - return PushEnablement.denied - case .authorized: - return PushEnablement.authorized - case .provisional: - return PushEnablement.provisional - case .ephemeral: - return PushEnablement.ephemeral - default: - return PushEnablement.notDetermined - } - } - } - - enum PushBackground: String, Codable { - case available = "AVAILABLE" - case restricted = "RESTRICTED" - case denied = "DENIED" - - static func create(from status: UIBackgroundRefreshStatus) -> PushBackground { - switch status { - case .available: - return PushBackground.available - case .restricted: - return PushBackground.restricted - case .denied: - return PushBackground.denied - @unknown default: - return PushBackground.available - } - } - } - // state related stuff var apiKey: String? var email: String? @@ -93,8 +51,8 @@ struct KlaviyoState: Equatable, Codable { var pushTokenData: PushTokenData? // queueing related stuff - var queue: [KlaviyoAPI.KlaviyoRequest] - var requestsInFlight: [KlaviyoAPI.KlaviyoRequest] = [] + var queue: [KlaviyoRequest] + var requestsInFlight: [KlaviyoRequest] = [] var initalizationState = InitializationState.uninitialized var flushing = false var flushInterval = StateManagementConstants.wifiFlushInterval @@ -112,7 +70,7 @@ struct KlaviyoState: Equatable, Codable { case pushTokenData } - mutating func enqueueRequest(request: KlaviyoAPI.KlaviyoRequest) { + mutating func enqueueRequest(request: KlaviyoRequest) { guard queue.count + 1 < StateManagementConstants.maxQueueSize else { return } @@ -168,7 +126,7 @@ struct KlaviyoState: Equatable, Codable { switch request.endpoint { case let .createProfile(payload): let updatedPayload = updateRequestAndStateWithPendingProfile(profile: payload) - let request = KlaviyoAPI.KlaviyoRequest(apiKey: apiKey, endpoint: .createProfile(updatedPayload)) + let request = KlaviyoRequest(apiKey: apiKey, endpoint: .createProfile(updatedPayload)) enqueueRequest(request: request) default: environment.raiseFatalError("Unexpected request type. \(request.endpoint)") @@ -258,7 +216,7 @@ struct KlaviyoState: Equatable, Codable { mutating func reset(preserveTokenData: Bool = true) { if isIdentified { // If we are still anonymous we want to preserve our anonymous id so we can merge this profile with the new profile. - anonymousId = environment.analytics.uuid().uuidString + anonymousId = environment.uuid().uuidString } let previousPushTokenData = pushTokenData pendingProfile = nil @@ -271,14 +229,15 @@ struct KlaviyoState: Equatable, Codable { if let apiKey = apiKey, let anonymousId = anonymousId, let tokenData = previousPushTokenData { - let request = KlaviyoAPI.KlaviyoRequest( + let payload = PushTokenPayload( + pushToken: tokenData.pushToken, + enablement: tokenData.pushEnablement.rawValue, + background: tokenData.pushBackground.rawValue, + profile: Profile().toAPIModel(anonymousId: anonymousId)) + + let request = KlaviyoRequest( apiKey: apiKey, - endpoint: .registerPushToken(.init( - pushToken: tokenData.pushToken, - enablement: tokenData.pushEnablement.rawValue, - background: tokenData.pushBackground.rawValue, - profile: .init(), anonymousId: anonymousId) - )) + endpoint: KlaviyoEndpoint.registerPushToken(payload)) enqueueRequest(request: request) } @@ -289,7 +248,7 @@ struct KlaviyoState: Equatable, Codable { guard let pushTokenData = pushTokenData else { return true } - let currentDeviceMetadata = DeviceMetadata(context: environment.analytics.appContextInfo()) + let currentDeviceMetadata = DeviceMetadata(context: environment.appContextInfo()) let newPushTokenData = PushTokenData( pushToken: newToken, pushEnablement: enablement, @@ -299,22 +258,20 @@ struct KlaviyoState: Equatable, Codable { return pushTokenData != newPushTokenData } - func buildProfileRequest(apiKey: String, anonymousId: String, properties: [String: Any] = [:]) -> KlaviyoAPI.KlaviyoRequest { - let payload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateProfilePayload( - data: .init( - profile: Profile( - email: email, - phoneNumber: phoneNumber, - externalId: externalId, - properties: properties), - anonymousId: anonymousId) - ) - let endpoint = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.createProfile(payload) + func buildProfileRequest(apiKey: String, anonymousId: String, properties: [String: Any] = [:]) -> KlaviyoRequest { + let payload = ProfilePayload( + email: email, + phoneNumber: phoneNumber, + externalId: externalId, + properties: properties, + anonymousId: anonymousId) + + let endpoint = KlaviyoEndpoint.createProfile(CreateProfilePayload(data: payload)) - return KlaviyoAPI.KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) } - mutating func buildTokenRequest(apiKey: String, anonymousId: String, pushToken: String, enablement: PushEnablement) -> KlaviyoAPI.KlaviyoRequest { + mutating func buildTokenRequest(apiKey: String, anonymousId: String, pushToken: String, enablement: PushEnablement) -> KlaviyoRequest { var profile: Profile if let pendingProfile = pendingProfile { @@ -332,19 +289,20 @@ struct KlaviyoState: Equatable, Codable { pushToken: pushToken, enablement: enablement.rawValue, background: environment.getBackgroundSetting().rawValue, - profile: profile, - anonymousId: anonymousId) - let endpoint = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.registerPushToken(payload) - return KlaviyoAPI.KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + profile: profile.toAPIModel(anonymousId: anonymousId)) + let endpoint = KlaviyoEndpoint.registerPushToken(payload) + return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) } - func buildUnregisterRequest(apiKey: String, anonymousId: String, pushToken: String) -> KlaviyoAPI.KlaviyoRequest { + func buildUnregisterRequest(apiKey: String, anonymousId: String, pushToken: String) -> KlaviyoRequest { let payload = UnregisterPushTokenPayload( pushToken: pushToken, - profile: .init(email: email, phoneNumber: phoneNumber, externalId: externalId), + email: email, + phoneNumber: phoneNumber, + externalId: externalId, anonymousId: anonymousId) - let endpoint = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.unregisterPushToken(payload) - return KlaviyoAPI.KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + let endpoint = KlaviyoEndpoint.unregisterPushToken(payload) + return KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) } } @@ -367,7 +325,7 @@ private func klaviyoStateFile(apiKey: String) -> URL { private func storeKlaviyoState(state: KlaviyoState, file: URL) { do { - try environment.fileClient.write(environment.analytics.encodeJSON(AnyEncodable(state)), file) + try environment.fileClient.write(environment.encodeJSON(AnyEncodable(state)), file) } catch { environment.logger.error("Unable to save klaviyo state.") } @@ -397,12 +355,12 @@ func loadKlaviyoStateFromDisk(apiKey: String) -> KlaviyoState { guard environment.fileClient.fileExists(fileName.path) else { return createAndStoreInitialState(with: apiKey, at: fileName) } - guard let stateData = try? environment.data(fileName) else { + guard let stateData = try? environment.dataFromUrl(fileName) else { environment.logger.error("Klaviyo state file invalid starting from scratch.") removeStateFile(at: fileName) return createAndStoreInitialState(with: apiKey, at: fileName) } - guard var state: KlaviyoState = try? environment.analytics.decoder.decode(stateData) else { + guard var state: KlaviyoState = try? environment.decoder.decode(stateData) else { environment.logger.error("Unable to decode existing state file. Removing.") removeStateFile(at: fileName) return createAndStoreInitialState(with: apiKey, at: fileName) @@ -411,14 +369,14 @@ func loadKlaviyoStateFromDisk(apiKey: String) -> KlaviyoState { // Clear existing state since we are using a new api state. state = KlaviyoState( apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: []) } return state } private func createAndStoreInitialState(with apiKey: String, at file: URL) -> KlaviyoState { - let anonymousId = environment.analytics.uuid().uuidString + let anonymousId = environment.uuid().uuidString let state = KlaviyoState(apiKey: apiKey, anonymousId: anonymousId, queue: [], requestsInFlight: []) storeKlaviyoState(state: state, file: file) return state diff --git a/Sources/KlaviyoSwift/StateChangePublisher.swift b/Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift similarity index 97% rename from Sources/KlaviyoSwift/StateChangePublisher.swift rename to Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift index 03fd67dd..5fb7e865 100644 --- a/Sources/KlaviyoSwift/StateChangePublisher.swift +++ b/Sources/KlaviyoSwift/StateManagement/StateChangePublisher.swift @@ -18,7 +18,7 @@ public struct StateChangePublisher { } private static func createStatePublisher() -> AnyPublisher { - environment.analytics.statePublisher() + klaviyoSwiftEnvironment.statePublisher() .filter { state in state.initalizationState == .initialized } .removeDuplicates() .eraseToAnyPublisher() diff --git a/Sources/KlaviyoSwift/StateManagement.swift b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift similarity index 87% rename from Sources/KlaviyoSwift/StateManagement.swift rename to Sources/KlaviyoSwift/StateManagement/StateManagement.swift index 853498e5..af3f739e 100644 --- a/Sources/KlaviyoSwift/StateManagement.swift +++ b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift @@ -1,5 +1,5 @@ // -// KlaviyoStateManagement.swift +// StateManagement.swift // // Klaviyo Swift SDK // @@ -13,9 +13,7 @@ import AnyCodable import Foundation - -typealias PushTokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload -typealias UnregisterPushTokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.UnregisterPushTokenPayload +import KlaviyoCore enum StateManagementConstants { static let cellularFlushInterval = 30.0 @@ -47,16 +45,16 @@ enum KlaviyoAction: Equatable { case setExternalId(String) /// call when a new push token needs to be set. If this token is the same we don't perform a network request to register the token - case setPushToken(String, KlaviyoState.PushEnablement) + case setPushToken(String, PushEnablement) /// call this to sync the user's local push notification authorization setting with the user's profile on the Klaviyo back-end. - case setPushEnablement(KlaviyoState.PushEnablement) + case setPushEnablement(PushEnablement) /// called when the user wants to reset the existing profile from state case resetProfile /// dequeues requests that completed and contuinues to flush other requests if they exist. - case deQueueCompletedResults(KlaviyoAPI.KlaviyoRequest) + case deQueueCompletedResults(KlaviyoRequest) /// when the network connectivity change we want to use a different flush interval to flush out the pending requests case networkConnectivityChanged(Reachability.NetworkStatus) @@ -77,7 +75,7 @@ enum KlaviyoAction: Equatable { case cancelInFlightRequests /// called when there is a network or rate limit error - case requestFailed(KlaviyoAPI.KlaviyoRequest, RetryInfo) + case requestFailed(KlaviyoRequest, RetryInfo) /// when there is an event to be sent to klaviyo it's added to the queue case enqueueEvent(Event) @@ -91,7 +89,7 @@ enum KlaviyoAction: Equatable { /// resets the state for profile properties before dequeing the request /// this is done in the case where there is http request failure due to /// the data that was passed to the client endpoint - case resetStateAndDequeue(KlaviyoAPI.KlaviyoRequest, [InvalidField]) + case resetStateAndDequeue(KlaviyoRequest, [InvalidField]) var requiresInitialization: Bool { switch self { @@ -193,8 +191,8 @@ struct KlaviyoReducer: ReducerProtocol { } await send(.start) } - .merge(with: environment.appLifeCycle.lifeCycleEvents().eraseToEffect()) - .merge(with: environment.stateChangePublisher().eraseToEffect()) + .merge(with: environment.appLifeCycle.lifeCycleEvents().map(\.transformToKlaviyoAction).eraseToEffect()) + .merge(with: klaviyoSwiftEnvironment.stateChangePublisher().eraseToEffect()) case let .setEmail(email): guard case .initialized = state.initalizationState else { @@ -295,7 +293,7 @@ struct KlaviyoReducer: ReducerProtocol { let settings = await environment.getNotificationSettings() await send(KlaviyoAction.setPushEnablement(settings)) }, - environment.analytics.timer(state.flushInterval) + environment.timer(state.flushInterval) .map { _ in KlaviyoAction.flushQueue } @@ -306,8 +304,8 @@ struct KlaviyoReducer: ReducerProtocol { case let .deQueueCompletedResults(completedRequest): if case let .registerPushToken(payload) = completedRequest.endpoint { let requestData = payload.data.attributes - let enablement = KlaviyoState.PushEnablement(rawValue: requestData.enablementStatus) ?? .authorized - let backgroundStatus = KlaviyoState.PushBackground(rawValue: requestData.backgroundStatus) ?? .available + let enablement = PushEnablement(rawValue: requestData.enablementStatus) ?? .authorized + let backgroundStatus = PushBackground(rawValue: requestData.backgroundStatus) ?? .available state.pushTokenData = KlaviyoState.PushTokenData( pushToken: requestData.token, pushEnablement: enablement, @@ -343,10 +341,9 @@ struct KlaviyoReducer: ReducerProtocol { } return .run { [numAttempts] send in - let result = await environment.analytics.klaviyoAPI.send(request, numAttempts) + let result = await environment.klaviyoAPI.send(request, numAttempts) switch result { case .success: - // TODO: may want to inspect response further. await send(.deQueueCompletedResults(request)) case let .failure(error): await send(handleRequestError(request: request, error: error, retryInfo: retryInfo)) @@ -379,7 +376,7 @@ struct KlaviyoReducer: ReducerProtocol { case .reachableViaWWAN: state.flushInterval = StateManagementConstants.cellularFlushInterval } - return environment.analytics.timer(state.flushInterval) + return environment.timer(state.flushInterval) .map { _ in KlaviyoAction.flushQueue }.eraseToEffect() @@ -415,10 +412,24 @@ struct KlaviyoReducer: ReducerProtocol { } event = event.updateEventWithState(state: &state) - state.enqueueRequest(request: .init(apiKey: apiKey, - endpoint: .createEvent( - .init(data: .init(event: event, anonymousId: anonymousId)) - ))) + + let payload = CreateEventPayload( + data: CreateEventPayload.Event( + name: event.metric.name.value, + properties: event.properties, + email: event.identifiers?.email, + phoneNumber: event.identifiers?.phoneNumber, + externalId: event.identifiers?.externalId, + anonymousId: anonymousId, + value: event.value, + time: event.time, + uniqueId: event.uniqueId, + pushToken: state.pushTokenData?.pushToken)) + + let endpoint = KlaviyoEndpoint.createEvent(payload) + let request = KlaviyoRequest(apiKey: apiKey, endpoint: endpoint) + + state.enqueueRequest(request: request) /* if we receive an opened push event we want to flush the queue right away so that @@ -442,24 +453,27 @@ struct KlaviyoReducer: ReducerProtocol { else { return .none } - let request: KlaviyoAPI.KlaviyoRequest! + let request: KlaviyoRequest! + + let profilePayload = profile.toAPIModel( + email: state.email, + phoneNumber: state.phoneNumber, + externalId: state.externalId, + anonymousId: anonymousId) if let tokenData = pushTokenData { - request = KlaviyoAPI.KlaviyoRequest( + let payload = PushTokenPayload( + pushToken: tokenData.pushToken, + enablement: tokenData.pushEnablement.rawValue, + background: tokenData.pushBackground.rawValue, + profile: profilePayload) + request = KlaviyoRequest( apiKey: apiKey, - endpoint: .registerPushToken(.init( - pushToken: tokenData.pushToken, - enablement: tokenData.pushEnablement.rawValue, - background: tokenData.pushBackground.rawValue, - profile: profile.profile(from: state), - anonymousId: anonymousId) - )) + endpoint: KlaviyoEndpoint.registerPushToken(payload)) } else { - request = KlaviyoAPI.KlaviyoRequest( + request = KlaviyoRequest( apiKey: apiKey, - endpoint: .createProfile( - .init(data: .init(profile: profile.profile(from: state), anonymousId: anonymousId)) - )) + endpoint: KlaviyoEndpoint.createProfile(CreateProfilePayload(data: profilePayload))) } state.enqueueRequest(request: request) @@ -483,7 +497,7 @@ struct KlaviyoReducer: ReducerProtocol { return .none case let .resetStateAndDequeue(request, invalidFields): - invalidFields.forEach { invalidField in + for invalidField in invalidFields { switch invalidField { case .email: state.email = nil @@ -522,19 +536,3 @@ extension Event { uniqueId: uniqueId) } } - -extension Profile { - fileprivate func profile(from state: KlaviyoState) -> Profile { - Profile( - email: state.email, - phoneNumber: state.phoneNumber, - externalId: state.externalId, - firstName: firstName, - lastName: lastName, - organization: organization, - title: title, - image: image, - location: location, - properties: properties) - } -} diff --git a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift index 5a510d55..17a8d210 100644 --- a/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift +++ b/Sources/KlaviyoSwift/Vendor/ComposableArchitecture/Misc.swift @@ -51,7 +51,7 @@ final class Box { @inline(__always) func runtimeWarn( _ message: @autoclosure () -> String, - category: String? = __klaviyoSwiftName, + category: String? = "", file: StaticString? = nil, line: UInt? = nil ) { diff --git a/Tests/KlaviyoSwiftTests/ArchivalUtilsTests.swift b/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift similarity index 97% rename from Tests/KlaviyoSwiftTests/ArchivalUtilsTests.swift rename to Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift index b5a0f349..d4074acb 100644 --- a/Tests/KlaviyoSwiftTests/ArchivalUtilsTests.swift +++ b/Tests/KlaviyoCoreTests/ArchivalUtilsTests.swift @@ -5,7 +5,8 @@ // Created by Noah Durell on 9/26/22. // -@testable import KlaviyoSwift +import Combine +import KlaviyoCore import XCTest class ArchivalUtilsTests: XCTestCase { @@ -62,7 +63,7 @@ class ArchivalUtilsTests: XCTestCase { } func testUnarchiveInvalidData() throws { - environment.data = { _ in throw FakeFileError.fake } + environment.dataFromUrl = { _ in throw FakeFileError.fake } let archiveResult = unarchiveFromFile(fileURL: TEST_URL) diff --git a/Tests/KlaviyoCoreTests/EncodableTests.swift b/Tests/KlaviyoCoreTests/EncodableTests.swift new file mode 100644 index 00000000..a88cc7e8 --- /dev/null +++ b/Tests/KlaviyoCoreTests/EncodableTests.swift @@ -0,0 +1,58 @@ +// +// EncodableTests.swift +// +// +// Created by Noah Durell on 11/14/22. +// + +import KlaviyoCore +import SnapshotTesting +import XCTest + +final class EncodableTests: XCTestCase { + let testEncoder = KlaviyoEnvironment.encoder + + override func setUpWithError() throws { + environment = KlaviyoEnvironment.test() + testEncoder.outputFormatting = .prettyPrinted.union(.sortedKeys) + } + + func testProfilePayload() throws { + let payload = CreateProfilePayload(data: .test) + assertSnapshot(matching: payload, as: .json(KlaviyoEnvironment.encoder)) + } + + func testEventPayload() throws { + let payloadData = CreateEventPayload.Event(name: "test", properties: SAMPLE_PROPERTIES, anonymousId: "anon-id") + let createEventPayload = CreateEventPayload(data: payloadData) + assertSnapshot(matching: createEventPayload, as: .json(KlaviyoEnvironment.encoder)) + } + + func testTokenPayload() throws { + let tokenPayload = PushTokenPayload( + pushToken: "foo", + enablement: "AUTHORIZED", + background: "AVAILABLE", + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) + assertSnapshot(matching: tokenPayload, as: .json(KlaviyoEnvironment.encoder)) + } + + func testUnregisterTokenPayload() throws { + let tokenPayload = UnregisterPushTokenPayload( + pushToken: "foo", + email: "foo", + phoneNumber: "foo", + anonymousId: "foo") + assertSnapshot(matching: tokenPayload, as: .json) + } + + func testKlaviyoRequest() throws { + let tokenPayload = PushTokenPayload( + pushToken: "foo", + enablement: "AUTHORIZED", + background: "AVAILABLE", + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) + assertSnapshot(matching: request, as: .json) + } +} diff --git a/Tests/KlaviyoSwiftTests/FileUtilsTests.swift b/Tests/KlaviyoCoreTests/FileUtilsTests.swift similarity index 97% rename from Tests/KlaviyoSwiftTests/FileUtilsTests.swift rename to Tests/KlaviyoCoreTests/FileUtilsTests.swift index b33d55aa..3251bafe 100644 --- a/Tests/KlaviyoSwiftTests/FileUtilsTests.swift +++ b/Tests/KlaviyoCoreTests/FileUtilsTests.swift @@ -5,7 +5,7 @@ // Created by Noah Durell on 9/29/22. // -@testable import KlaviyoSwift +import KlaviyoCore import XCTest class FileUtilsTests: XCTestCase { diff --git a/Tests/KlaviyoSwiftTests/KlaviyoAPITests.swift b/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift similarity index 62% rename from Tests/KlaviyoSwiftTests/KlaviyoAPITests.swift rename to Tests/KlaviyoCoreTests/KlaviyoAPITests.swift index c05a58b8..2245b924 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoAPITests.swift +++ b/Tests/KlaviyoCoreTests/KlaviyoAPITests.swift @@ -5,22 +5,23 @@ // Created by Noah Durell on 11/16/22. // -@testable import KlaviyoSwift +import KlaviyoCore import SnapshotTesting import XCTest @MainActor final class KlaviyoAPITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - environment = KlaviyoEnvironment.test() } func testInvalidURL() async throws { - environment.analytics.apiURL = "" + environment.apiURL = { "" } - await sendAndAssert(with: .init(apiKey: "foo", endpoint: .createProfile(.init(data: .init(profile: .test, anonymousId: "foo"))))) { result in + await sendAndAssert(with: KlaviyoRequest( + apiKey: "foo", + endpoint: .createProfile(CreateProfilePayload(data: .test))) + ) { result in switch result { case let .failure(error): assertSnapshot(matching: error, as: .description) @@ -31,9 +32,9 @@ final class KlaviyoAPITests: XCTestCase { } func testEncodingError() async throws { - environment.analytics.encodeJSON = { _ in throw EncodingError.invalidValue("foo", .init(codingPath: [], debugDescription: "invalid")) + environment.encodeJSON = { _ in throw EncodingError.invalidValue("foo", .init(codingPath: [], debugDescription: "invalid")) } - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(.init(data: .init(profile: .init(), anonymousId: "foo")))) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) await sendAndAssert(with: request) { result in switch result { @@ -46,10 +47,10 @@ final class KlaviyoAPITests: XCTestCase { } func testNetworkError() async throws { - environment.analytics.networkSession = { NetworkSession.test(data: { _ in + environment.networkSession = { NetworkSession.test(data: { _ in throw NSError(domain: "network error", code: 0) }) } - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(.init(data: .init(profile: .init(), anonymousId: "foo")))) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) await sendAndAssert(with: request) { result in switch result { @@ -62,10 +63,10 @@ final class KlaviyoAPITests: XCTestCase { } func testInvalidStatusCode() async throws { - environment.analytics.networkSession = { NetworkSession.test(data: { _ in + environment.networkSession = { NetworkSession.test(data: { _ in (Data(), .non200Response) }) } - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(.init(data: .init(profile: .init(), anonymousId: "foo")))) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) await sendAndAssert(with: request) { result in switch result { @@ -78,11 +79,11 @@ final class KlaviyoAPITests: XCTestCase { } func testSuccessfulResponseWithProfile() async throws { - environment.analytics.networkSession = { NetworkSession.test(data: { request in + environment.networkSession = { NetworkSession.test(data: { request in assertSnapshot(matching: request, as: .dump) return (Data(), .validResponse) }) } - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(.init(data: .init(profile: .init(), anonymousId: "foo")))) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(CreateProfilePayload(data: .test))) await sendAndAssert(with: request) { result in switch result { @@ -95,11 +96,11 @@ final class KlaviyoAPITests: XCTestCase { } func testSuccessfulResponseWithEvent() async throws { - environment.analytics.networkSession = { NetworkSession.test(data: { request in + environment.networkSession = { NetworkSession.test(data: { request in assertSnapshot(matching: request, as: .dump) return (Data(), .validResponse) }) } - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(.init(data: .init(event: .test)))) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(CreateEventPayload(data: CreateEventPayload.Event(name: "test")))) await sendAndAssert(with: request) { result in switch result { case let .success(data): @@ -111,11 +112,11 @@ final class KlaviyoAPITests: XCTestCase { } func testSuccessfulResponseWithStoreToken() async throws { - environment.analytics.networkSession = { NetworkSession.test(data: { request in + environment.networkSession = { NetworkSession.test(data: { request in assertSnapshot(matching: request, as: .dump) return (Data(), .validResponse) }) } - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test)) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test)) await sendAndAssert(with: request) { result in switch result { @@ -127,8 +128,8 @@ final class KlaviyoAPITests: XCTestCase { } } - func sendAndAssert(with request: KlaviyoAPI.KlaviyoRequest, - assertion: (Result) -> Void) async { + func sendAndAssert(with request: KlaviyoRequest, + assertion: (Result) -> Void) async { let result = await KlaviyoAPI().send(request, 0) assertion(result) } diff --git a/Tests/KlaviyoSwiftTests/NetworkSessionTests.swift b/Tests/KlaviyoCoreTests/NetworkSessionTests.swift similarity index 87% rename from Tests/KlaviyoSwiftTests/NetworkSessionTests.swift rename to Tests/KlaviyoCoreTests/NetworkSessionTests.swift index 86a8c04a..0423d516 100644 --- a/Tests/KlaviyoSwiftTests/NetworkSessionTests.swift +++ b/Tests/KlaviyoCoreTests/NetworkSessionTests.swift @@ -5,7 +5,7 @@ // Created by Noah Durell on 11/18/22. // -@testable import KlaviyoSwift +import KlaviyoCore import SnapshotTesting import XCTest @@ -26,7 +26,7 @@ class NetworkSessionTests: XCTestCase { func testSessionDataTask() async throws { URLProtocolOverrides.protocolClasses = [SimpleMockURLProtocol.self] let session = NetworkSession.production - let sampleRequest = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test)) + let sampleRequest = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(.test)) let (data, response) = try await session.data(sampleRequest.urlRequest()) assertSnapshot(matching: data, as: .dump) diff --git a/Tests/KlaviyoSwiftTests/SimpleMockURLProtocol.swift b/Tests/KlaviyoCoreTests/SimpleMockURLProtocol.swift similarity index 100% rename from Tests/KlaviyoSwiftTests/SimpleMockURLProtocol.swift rename to Tests/KlaviyoCoreTests/SimpleMockURLProtocol.swift diff --git a/Tests/KlaviyoCoreTests/TestUtils.swift b/Tests/KlaviyoCoreTests/TestUtils.swift new file mode 100644 index 00000000..4f1c0c78 --- /dev/null +++ b/Tests/KlaviyoCoreTests/TestUtils.swift @@ -0,0 +1,200 @@ +// +// TestUtils.swift +// +// +// Created by Ajay Subramanya on 8/15/24. +// + +import AnyCodable +import Combine +import Foundation +import KlaviyoCore + +enum FakeFileError: Error { + case fake +} + +let ARCHIVED_RETURNED_DATA = Data() +let SAMPLE_DATA: NSMutableArray = [ + [ + "properties": [ + "foo": "bar" + ] + ] +] +let TEST_URL = URL(string: "fake_url")! +let TEST_RETURN_DATA = Data() + +let TEST_FAILURE_JSON_INVALID_PHONE_NUMBER = """ +{ + "errors": [ + { + "id": "9997bd4f-7d5f-4f01-bbd1-df0065ef4faa", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid phone number format (Example of a valid format: +12345678901)", + "source": { + "pointer": "/data/attributes/phone_number" + }, + "meta": {} + } + ] +} +""" + +let TEST_FAILURE_JSON_INVALID_EMAIL = """ +{ + "errors": [ + { + "id": "dce2d180-0f36-4312-aa6d-92d025c17147", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid email address", + "source": { + "pointer": "/data/attributes/email" + }, + "meta": {} + } + ] +} +""" + +let SAMPLE_PROPERTIES = [ + "blob": "blob", + "stuff": 2, + "hello": [ + "sub": "dict" + ] +] as [String: Any] + +extension ArchiverClient { + static let test = ArchiverClient( + archivedData: { _, _ in ARCHIVED_RETURNED_DATA }, + unarchivedMutableArray: { _ in SAMPLE_DATA }) +} + +extension KlaviyoEnvironment { + static var lastLog: String? + static var test = { + KlaviyoEnvironment( + archiverClient: ArchiverClient.test, + fileClient: FileClient.test, + dataFromUrl: { _ in TEST_RETURN_DATA }, + logger: LoggerClient.test, + appLifeCycle: AppLifeCycleEvents.test, + notificationCenterPublisher: { _ in Empty().eraseToAnyPublisher() }, + getNotificationSettings: { .authorized }, + getBackgroundSetting: { .available }, + startReachability: {}, + stopReachability: {}, + reachabilityStatus: { nil }, + randomInt: { 0 }, + raiseFatalError: { _ in }, + emitDeveloperWarning: { _ in }, + networkSession: { NetworkSession.test() }, + apiURL: { "dead_beef" }, + encodeJSON: { _ in TEST_RETURN_DATA }, + decoder: DataDecoder(jsonDecoder: TestJSONDecoder()), + uuid: { UUID(uuidString: "00000000-0000-0000-0000-000000000001")! }, + date: { Date(timeIntervalSince1970: 1_234_567_890) }, + timeZone: { "EST" }, + appContextInfo: { AppContextInfo.test }, + klaviyoAPI: KlaviyoAPI.test(), + timer: { _ in Just(Date()).eraseToAnyPublisher() }, + SDKName: { __klaviyoSwiftName }, + SDKVersion: { __klaviyoSwiftVersion }) + } +} + +extension FileClient { + static let test = FileClient( + write: { _, _ in }, + fileExists: { _ in true }, + removeItem: { _ in }, + libraryDirectory: { TEST_URL }) +} + +extension KlaviyoAPI { + static let test = { KlaviyoAPI(send: { _, _ in .success(TEST_RETURN_DATA) }) } +} + +extension LoggerClient { + static var lastLoggedMessage: String? + static let test = LoggerClient { message in + lastLoggedMessage = message + } +} + +extension AppLifeCycleEvents { + static let test = Self(lifeCycleEvents: { Empty().eraseToAnyPublisher() }) +} + +extension NetworkSession { + static let successfulRepsonse = HTTPURLResponse(url: TEST_URL, statusCode: 200, httpVersion: nil, headerFields: nil)! + static let DEFAULT_CALLBACK: (URLRequest) async throws -> (Data, URLResponse) = { _ in + (Data(), successfulRepsonse) + } + + static func test(data: @escaping (URLRequest) async throws -> (Data, URLResponse) = DEFAULT_CALLBACK) -> NetworkSession { + NetworkSession(data: data) + } +} + +class TestJSONDecoder: JSONDecoder { + override func decode(_: T.Type, from _: Data) throws -> T where T: Decodable { + AppLifeCycleEvents.test as! T + } +} + +extension AppContextInfo { + static let test = Self(executable: "FooApp", + bundleId: "com.klaviyo.fooapp", + appVersion: "1.2.3", + appBuild: "1", + appName: "FooApp", + version: OperatingSystemVersion(majorVersion: 1, minorVersion: 1, patchVersion: 1), + osName: "iOS", + manufacturer: "Orange", + deviceModel: "jPhone 1,1", + deviceId: "fe-fi-fo-fum") +} + +extension URLResponse { + static let non200Response = HTTPURLResponse(url: TEST_URL, statusCode: 500, httpVersion: nil, headerFields: nil)! + static let validResponse = HTTPURLResponse(url: TEST_URL, statusCode: 200, httpVersion: nil, headerFields: nil)! +} + +extension PushTokenPayload { + static let test = PushTokenPayload( + pushToken: "foo", + enablement: "AUTHORIZED", + background: "AVAILABLE", + profile: ProfilePayload(properties: [:], anonymousId: "anon-id")) +} + +extension ProfilePayload { + static let location = ProfilePayload.Attributes.Location( + address1: "blob", + address2: "blob", + city: "blob city", + country: "Blobland", + latitude: 1, + longitude: 1, + region: "BL", + zip: "0BLOB") + + static let test = ProfilePayload( + email: "blobemail", + phoneNumber: "+15555555555", + externalId: "blobid", + firstName: "Blob", + lastName: "Junior", + organization: "Blobco", + title: "Jelly", + image: "foo", + location: location, + properties: [:], + anonymousId: "foo") +} diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testEventPayloadWithMetadata.1.json b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testEventPayload.1.json similarity index 90% rename from Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testEventPayloadWithMetadata.1.json rename to Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testEventPayload.1.json index 2d758300..0fe6338e 100644 --- a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testEventPayloadWithMetadata.1.json +++ b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testEventPayload.1.json @@ -4,7 +4,7 @@ "metric" : { "data" : { "attributes" : { - "name" : "blob" + "name" : "test" }, "type" : "metric" } @@ -25,7 +25,6 @@ "App ID" : "com.klaviyo.fooapp", "App Name" : "FooApp", "App Version" : "1.2.3", - "Application ID" : "com.klaviyo.fooapp", "blob" : "blob", "Device ID" : "fe-fi-fo-fum", "Device Manufacturer" : "Orange", @@ -35,7 +34,7 @@ }, "OS Name" : "iOS", "OS Version" : "1.1.1", - "Push Token" : null, + "Push Token" : "", "SDK Name" : "swift", "SDK Version" : "3.2.0", "stuff" : 2 diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testKlaviyoRequest.1.json b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testKlaviyoRequest.1.json similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testKlaviyoRequest.1.json rename to Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testKlaviyoRequest.1.json diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testProfilePayload.1.json b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testProfilePayload.1.json similarity index 86% rename from Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testProfilePayload.1.json rename to Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testProfilePayload.1.json index cc974d65..8a051b50 100644 --- a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testProfilePayload.1.json +++ b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testProfilePayload.1.json @@ -21,11 +21,7 @@ "organization" : "Blobco", "phone_number" : "+15555555555", "properties" : { - "blob" : "blob", - "hello" : { - "sub" : "dict" - }, - "stuff" : 2 + }, "title" : "Jelly" }, diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testTokenPayload.1.json b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testTokenPayload.1.json similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testTokenPayload.1.json rename to Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testTokenPayload.1.json diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testUnregisterTokenPayload.1.json b/Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testUnregisterTokenPayload.1.json similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testUnregisterTokenPayload.1.json rename to Tests/KlaviyoCoreTests/__Snapshots__/EncodableTests/testUnregisterTokenPayload.1.json diff --git a/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testEncodingError.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testEncodingError.1.txt new file mode 100644 index 00000000..ae591ad9 --- /dev/null +++ b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testEncodingError.1.txt @@ -0,0 +1,49 @@ +▿ KlaviyoAPIError + ▿ internalRequestError: KlaviyoAPIError + ▿ dataEncodingError: KlaviyoRequest + - apiKey: "foo" + ▿ endpoint: KlaviyoEndpoint + ▿ createProfile: CreateProfilePayload + ▿ data: ProfilePayload + ▿ attributes: Attributes + - anonymousId: "foo" + ▿ email: Optional + - some: "blobemail" + ▿ externalId: Optional + - some: "blobid" + ▿ firstName: Optional + - some: "Blob" + ▿ image: Optional + - some: "foo" + ▿ lastName: Optional + - some: "Junior" + ▿ location: Optional + ▿ some: Location + ▿ address1: Optional + - some: "blob" + ▿ address2: Optional + - some: "blob" + ▿ city: Optional + - some: "blob city" + ▿ country: Optional + - some: "Blobland" + ▿ latitude: Optional + - some: 1.0 + ▿ longitude: Optional + - some: 1.0 + ▿ region: Optional + - some: "BL" + ▿ timezone: Optional + - some: "EST" + ▿ zip: Optional + - some: "0BLOB" + ▿ organization: Optional + - some: "Blobco" + ▿ phoneNumber: Optional + - some: "+15555555555" + ▿ properties: [:] + - value: 0 key/value pairs + ▿ title: Optional + - some: "Jelly" + - type: "profile" + - uuid: "00000000-0000-0000-0000-000000000001" diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testInvalidStatusCode.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testInvalidStatusCode.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testInvalidStatusCode.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testInvalidStatusCode.1.txt diff --git a/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testInvalidURL.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testInvalidURL.1.txt new file mode 100644 index 00000000..675c1934 --- /dev/null +++ b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testInvalidURL.1.txt @@ -0,0 +1 @@ +internalRequestError(KlaviyoCore.KlaviyoAPIError.internalError("Invalid url string. API URL: ")) \ No newline at end of file diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testNetworkError.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testNetworkError.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testNetworkError.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testNetworkError.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.2.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.2.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.2.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithEvent.2.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.2.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.2.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.2.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithProfile.2.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.2.txt b/Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.2.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.2.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/KlaviyoAPITests/testSuccessfulResponseWithStoreToken.2.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testDefaultUserAgent.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testDefaultUserAgent.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testDefaultUserAgent.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testDefaultUserAgent.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.1.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.1.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.1.txt diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.2.txt b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.2.txt similarity index 100% rename from Tests/KlaviyoSwiftTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.2.txt rename to Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testSessionDataTask.2.txt diff --git a/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift b/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift index 69b6f63e..56adc017 100644 --- a/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift +++ b/Tests/KlaviyoSwiftTests/APIRequestErrorHandlingTests.swift @@ -7,6 +7,7 @@ @testable import KlaviyoSwift import Foundation +import KlaviyoCore import XCTest let TIMEOUT_NANOSECONDS: UInt64 = 10_000_000_000 // 10 seconds @@ -26,7 +27,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.httpError(500, TEST_RETURN_DATA)) } + environment.klaviyoAPI.send = { _, _ in .failure(.httpError(500, TEST_RETURN_DATA)) } _ = await store.send(.sendRequest) @@ -43,7 +44,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.httpError(400, TEST_FAILURE_JSON_INVALID_PHONE_NUMBER.data(using: .utf8)!)) } + environment.klaviyoAPI.send = { _, _ in .failure(.httpError(400, TEST_FAILURE_JSON_INVALID_PHONE_NUMBER.data(using: .utf8)!)) } _ = await store.send(.sendRequest) @@ -66,7 +67,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.httpError(400, TEST_FAILURE_JSON_INVALID_EMAIL.data(using: .utf8)!)) } + environment.klaviyoAPI.send = { _, _ in .failure(.httpError(400, TEST_FAILURE_JSON_INVALID_EMAIL.data(using: .utf8)!)) } _ = await store.send(.sendRequest) @@ -92,7 +93,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request, request2] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } + environment.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } _ = await store.send(.sendRequest) @@ -113,7 +114,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request, request2] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } + environment.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } _ = await store.send(.sendRequest) @@ -136,7 +137,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request, request2] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } + environment.klaviyoAPI.send = { _, _ in .failure(.networkError(NSError(domain: "foo", code: NSURLErrorCancelled))) } _ = await store.send(.sendRequest) @@ -159,7 +160,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.internalError("internal error!")) } + environment.klaviyoAPI.send = { _, _ in .failure(.internalError("internal error!")) } _ = await store.send(.sendRequest) @@ -181,7 +182,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.internalRequestError(KlaviyoAPI.KlaviyoAPIError.internalError("foo"))) } + environment.klaviyoAPI.send = { _, _ in .failure(.internalRequestError(KlaviyoAPIError.internalError("foo"))) } _ = await store.send(.sendRequest) @@ -203,7 +204,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.unknownError(KlaviyoAPI.KlaviyoAPIError.internalError("foo"))) } + environment.klaviyoAPI.send = { _, _ in .failure(.unknownError(KlaviyoAPIError.internalError("foo"))) } _ = await store.send(.sendRequest) @@ -224,7 +225,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.dataEncodingError(request)) } + environment.klaviyoAPI.send = { _, _ in .failure(.dataEncodingError(request)) } _ = await store.send(.sendRequest) @@ -245,7 +246,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.invalidData) } + environment.klaviyoAPI.send = { _, _ in .failure(.invalidData) } _ = await store.send(.sendRequest) @@ -266,15 +267,15 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(nil)) } + environment.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(backOff: 30)) } _ = await store.send(.sendRequest) - await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 1)), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 30)), timeout: TIMEOUT_NANOSECONDS) { $0.flushing = false $0.queue = [request] $0.requestsInFlight = [] - $0.retryInfo = .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 1) + $0.retryInfo = .retryWithBackoff(requestCount: 2, totalRetryCount: 2, currentBackoff: 30) } } @@ -286,15 +287,15 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(nil)) } + environment.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(backOff: 30)) } _ = await store.send(.sendRequest) - await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 3, totalRetryCount: 3, currentBackoff: 1)), timeout: TIMEOUT_NANOSECONDS) { + await store.receive(.requestFailed(request, .retryWithBackoff(requestCount: 3, totalRetryCount: 3, currentBackoff: 30)), timeout: TIMEOUT_NANOSECONDS) { $0.flushing = false $0.queue = [request] $0.requestsInFlight = [] - $0.retryInfo = .retryWithBackoff(requestCount: 3, totalRetryCount: 3, currentBackoff: 1) + $0.retryInfo = .retryWithBackoff(requestCount: 3, totalRetryCount: 3, currentBackoff: 30) } } @@ -305,7 +306,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(20)) } + environment.klaviyoAPI.send = { _, _ in .failure(.rateLimitError(backOff: 20)) } _ = await store.send(.sendRequest) @@ -327,7 +328,7 @@ class APIRequestErrorHandlingTests: XCTestCase { initialState.requestsInFlight = [request] let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) - environment.analytics.klaviyoAPI.send = { _, _ in .failure(.missingOrInvalidResponse(nil)) } + environment.klaviyoAPI.send = { _, _ in .failure(.missingOrInvalidResponse(nil)) } _ = await store.send(.sendRequest) diff --git a/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift b/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift index 64e53866..782ee5df 100644 --- a/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift +++ b/Tests/KlaviyoSwiftTests/AppLifeCycleEventsTests.swift @@ -8,6 +8,7 @@ @testable import KlaviyoSwift import Combine import Foundation +import KlaviyoCore import XCTest class AppLifeCycleEventsTests: XCTestCase { @@ -50,7 +51,7 @@ class AppLifeCycleEventsTests: XCTestCase { stopActionExpection.assertForOverFulfill = true var receivedAction: KlaviyoAction? let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in - receivedAction = action + receivedAction = action.transformToKlaviyoAction stopActionExpection.fulfill() } @@ -81,7 +82,7 @@ class AppLifeCycleEventsTests: XCTestCase { stopActionExpection.assertForOverFulfill = true var receivedAction: KlaviyoAction? let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in - receivedAction = action + receivedAction = action.transformToKlaviyoAction stopActionExpection.fulfill() } @@ -119,7 +120,7 @@ class AppLifeCycleEventsTests: XCTestCase { stopActionExpection.assertForOverFulfill = true var receivedAction: KlaviyoAction? let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in - receivedAction = action + receivedAction = action.transformToKlaviyoAction stopActionExpection.fulfill() } @@ -149,7 +150,7 @@ class AppLifeCycleEventsTests: XCTestCase { let expection = XCTestExpectation(description: "Start reachability is called.") environment.startReachability = { expection.fulfill() - throw KlaviyoAPI.KlaviyoAPIError.internalError("foo") + throw KlaviyoAPIError.internalError("foo") } let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { _ in } @@ -206,7 +207,7 @@ class AppLifeCycleEventsTests: XCTestCase { let reachabilityAction = XCTestExpectation(description: "Reachabilty changed is received.") var receivedAction: KlaviyoAction? let cancellable = AppLifeCycleEvents().lifeCycleEvents().sink { action in - receivedAction = action + receivedAction = action.transformToKlaviyoAction reachabilityAction.fulfill() } diff --git a/Tests/KlaviyoSwiftTests/EncodableTests.swift b/Tests/KlaviyoSwiftTests/EncodableTests.swift index fb99191f..c5961ce1 100644 --- a/Tests/KlaviyoSwiftTests/EncodableTests.swift +++ b/Tests/KlaviyoSwiftTests/EncodableTests.swift @@ -2,9 +2,12 @@ // EncodableTests.swift // // -// Created by Noah Durell on 11/14/22. +// Created by Ajay Subramanya on 8/15/24. // +import Foundation + +@testable import KlaviyoCore @testable import KlaviyoSwift import SnapshotTesting import XCTest @@ -17,66 +20,24 @@ final class EncodableTests: XCTestCase { testEncoder.outputFormatting = .prettyPrinted.union(.sortedKeys) } - func testProfilePayload() throws { - let profile = Profile.test - let data = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateProfilePayload.Profile(profile: profile, anonymousId: "foo") - let payload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateProfilePayload(data: data) - assertSnapshot(matching: payload, as: .json(KlaviyoEnvironment.encoder)) - } - - func testEventPayloadWithoutMetadata() throws { - let event = Event.test - let createEventPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateEventPayload(data: .init(event: event, anonymousId: "anon-id")) - assertSnapshot(matching: createEventPayload, as: .json(KlaviyoEnvironment.encoder)) - } - - func testEventPayloadWithMetadata() throws { - let event = Event.test - var createEventPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateEventPayload(data: .init(event: event, anonymousId: "anon-id")) - createEventPayload.appendMetadataToProperties() - assertSnapshot(matching: createEventPayload, as: .json(KlaviyoEnvironment.encoder)) - } - - func testTokenPayload() throws { - let tokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload( - pushToken: "foo", - enablement: "AUTHORIZED", - background: "AVAILABLE", - profile: .init(email: "foo", phoneNumber: "foo"), - anonymousId: "foo") - assertSnapshot(matching: tokenPayload, as: .json(KlaviyoEnvironment.encoder)) - } - - func testUnregisterTokenPayload() throws { - let tokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.UnregisterPushTokenPayload( - pushToken: "foo", - profile: .init(email: "foo", phoneNumber: "foo"), - anonymousId: "foo") - assertSnapshot(matching: tokenPayload, as: .json) - } - func testKlaviyoState() throws { - let tokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload( - pushToken: "foo", - enablement: "AUTHORIZED", - background: "AVAILABLE", - profile: .init(email: "foo", phoneNumber: "foo"), - anonymousId: "foo") - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) - let klaviyoState = KlaviyoState(email: "foo", anonymousId: "foo", - phoneNumber: "foo", pushTokenData: .init(pushToken: "foo", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.analytics.appContextInfo())), - queue: [request], requestsInFlight: [request]) - assertSnapshot(matching: klaviyoState, as: .json) - } - - func testKlaviyoRequest() throws { - let tokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload( + let tokenPayload = PushTokenPayload( pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: .init(email: "foo", phoneNumber: "foo"), - anonymousId: "foo") - let request = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) - assertSnapshot(matching: request, as: .json) + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) + let request = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload), uuid: KlaviyoEnvironment.test().uuid().uuidString) + let klaviyoState = KlaviyoState( + email: "foo", + anonymousId: "foo", + phoneNumber: "foo", + pushTokenData: KlaviyoState.PushTokenData( + pushToken: "foo", + pushEnablement: .authorized, + pushBackground: .available, + deviceData: .init(context: KlaviyoEnvironment.test().appContextInfo())), + queue: [request], + requestsInFlight: [request]) + assertSnapshot(matching: klaviyoState, as: .json(KlaviyoEnvironment.encoder)) } } diff --git a/Tests/KlaviyoSwiftTests/KlaviyoModelsTest.swift b/Tests/KlaviyoSwiftTests/KlaviyoModelsTest.swift new file mode 100644 index 00000000..690a72e4 --- /dev/null +++ b/Tests/KlaviyoSwiftTests/KlaviyoModelsTest.swift @@ -0,0 +1,93 @@ +// +// KlaviyoModelsTest.swift +// +// +// Created by Ajay Subramanya on 8/23/24. +// + +@testable import KlaviyoSwift +import Foundation +import XCTest + +class KlaviyoModelsTest: XCTestCase { + func testProfileModelConvertsToAPIModel() { + let profile = Profile( + email: "walter.white@breakingbad.com", + phoneNumber: "1800-better-call-saul", + externalId: "999", + firstName: "Walter", + lastName: "White", + organization: "Walter White Inc.", + title: "Lead chemist", + image: "https://www.breakingbad.com/walter.png", + location: Profile.Location( + address1: "1 main st", + city: "Albuquerque", + country: "USA", + zip: "42000", + timezone: "MDT"), + properties: ["order amount": "a lot of money"]) + let anonymousId = "C10H15N" + let apiProfile = profile.toAPIModel(anonymousId: anonymousId) + + XCTAssertEqual(apiProfile.attributes.email, profile.email) + XCTAssertEqual(apiProfile.attributes.phoneNumber, profile.phoneNumber) + XCTAssertEqual(apiProfile.attributes.externalId, profile.externalId) + XCTAssertEqual(apiProfile.attributes.firstName, profile.firstName) + XCTAssertEqual(apiProfile.attributes.lastName, profile.lastName) + XCTAssertEqual(apiProfile.attributes.organization, profile.organization) + XCTAssertEqual(apiProfile.attributes.title, profile.title) + XCTAssertEqual(apiProfile.attributes.image, profile.image) + XCTAssertEqual(apiProfile.attributes.location?.address1, profile.location?.address1) + XCTAssertEqual(apiProfile.attributes.location?.city, profile.location?.city) + XCTAssertEqual(apiProfile.attributes.location?.country, profile.location?.country) + XCTAssertEqual(apiProfile.attributes.location?.zip, profile.location?.zip) + XCTAssertEqual(apiProfile.attributes.location?.timezone, profile.location?.timezone) + + let apiProps = apiProfile.attributes.properties.value as! [String: Any] + let orderAmount = apiProps["order amount"] as! String + + XCTAssertEqual(orderAmount, profile.properties["order amount"] as! String) + XCTAssertEqual(apiProfile.attributes.anonymousId, anonymousId) + } + + func testProfileWithNoIdsModelConvertsToAPIModel() { + let profile = Profile( + firstName: "Walter", + lastName: "White") + let anonymousId = "C10H15N" + let apiProfile = profile.toAPIModel( + email: "walter.white@breakingbad.com", + phoneNumber: "1800-better-call-saul", + externalId: "999", + anonymousId: anonymousId) + XCTAssertNil(profile.email) + XCTAssertNil(profile.phoneNumber) + XCTAssertNil(profile.externalId) + XCTAssertEqual(apiProfile.attributes.email, "walter.white@breakingbad.com") + XCTAssertEqual(apiProfile.attributes.phoneNumber, "1800-better-call-saul") + XCTAssertEqual(apiProfile.attributes.externalId, "999") + XCTAssertEqual(apiProfile.attributes.firstName, profile.firstName) + XCTAssertEqual(apiProfile.attributes.lastName, profile.lastName) + XCTAssertEqual(apiProfile.attributes.anonymousId, anonymousId) + } + + func testEmptyStringIdsConvertToNil() { + let profile = Profile( + email: "", + phoneNumber: "", + externalId: "", + firstName: "Walter", + lastName: "White") + let anonymousId = "C10H15N" + let apiProfile = profile.toAPIModel( + anonymousId: anonymousId) + + XCTAssertNil(apiProfile.attributes.email) + XCTAssertNil(apiProfile.attributes.phoneNumber) + XCTAssertNil(apiProfile.attributes.externalId) + XCTAssertEqual(apiProfile.attributes.firstName, profile.firstName) + XCTAssertEqual(apiProfile.attributes.lastName, profile.lastName) + XCTAssertEqual(apiProfile.attributes.anonymousId, anonymousId) + } +} diff --git a/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift index e33f8658..ecfb03bd 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoSDKTests.swift @@ -1,5 +1,5 @@ // -// File.swift +// KlaviyoSDKTests.swift // // // Created by Noah Durell on 2/21/23. @@ -7,6 +7,7 @@ @testable import KlaviyoSwift import Foundation +import KlaviyoCore import XCTest // MARK: - KlaviyoSDKTests @@ -29,7 +30,7 @@ class KlaviyoSDKTests: XCTestCase { func setupActionAssertion(expectedAction: KlaviyoAction, file: StaticString = #filePath, line: UInt = #line) -> XCTestExpectation { let expectation = XCTestExpectation(description: "wait for action \(expectedAction)") - environment.analytics.send = { action in + klaviyoSwiftEnvironment.send = { action in XCTAssertEqual(action, expectedAction, file: file, line: line) expectation.fulfill() return nil @@ -167,7 +168,7 @@ class KlaviyoSDKTests: XCTestCase { // MARK: test property getters func testPropertyGetters() throws { - environment.analytics.state = { KlaviyoState(email: "foo@foo.com", phoneNumber: "555BLOB", externalId: "my_test_id", pushTokenData: .init(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.analytics.appContextInfo())), queue: []) } + klaviyoSwiftEnvironment.state = { KlaviyoState(email: "foo@foo.com", phoneNumber: "555BLOB", externalId: "my_test_id", pushTokenData: .init(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())), queue: []) } let klaviyo = KlaviyoSDK() XCTAssertEqual("foo@foo.com", klaviyo.email) XCTAssertEqual("555BLOB", klaviyo.phoneNumber) diff --git a/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift index a59445c8..8c656358 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoStateTests.swift @@ -8,6 +8,7 @@ @testable import KlaviyoSwift import AnyCodable import Foundation +import KlaviyoCore import SnapshotTesting import XCTest @@ -71,7 +72,6 @@ final class KlaviyoStateTests: XCTestCase { } func testLoadNewKlaviyoState() throws { - environment.getUserDefaultString = { _ in nil } environment.fileClient.fileExists = { _ in false } environment.archiverClient.unarchivedMutableArray = { _ in [] } let state = loadKlaviyoStateFromDisk(apiKey: "foo") @@ -82,7 +82,7 @@ final class KlaviyoStateTests: XCTestCase { environment.fileClient.fileExists = { _ in true } - environment.data = { _ in + environment.dataFromUrl = { _ in throw NSError(domain: "missing file", code: 1) } environment.archiverClient.unarchivedMutableArray = { _ in @@ -99,7 +99,7 @@ final class KlaviyoStateTests: XCTestCase { true } - environment.analytics.decoder = DataDecoder(jsonDecoder: InvalidJSONDecoder()) + environment.decoder = DataDecoder(jsonDecoder: InvalidJSONDecoder()) environment.archiverClient.unarchivedMutableArray = { _ in XCTFail("unarchivedMutableArray should not be called.") return [] @@ -113,10 +113,13 @@ final class KlaviyoStateTests: XCTestCase { environment.fileClient.fileExists = { _ in true } - environment.data = { _ in - try! JSONEncoder().encode(KlaviyoState(apiKey: "foo", anonymousId: environment.analytics.uuid().uuidString, queue: [], requestsInFlight: [])) + environment.dataFromUrl = { _ in + try! JSONEncoder().encode(KlaviyoState( + apiKey: "foo", + anonymousId: environment.uuid().uuidString, + queue: [], + requestsInFlight: [])) } - environment.analytics.decoder = DataDecoder(jsonDecoder: KlaviyoEnvironment.decoder) let state = loadKlaviyoStateFromDisk(apiKey: "foo") assertSnapshot(matching: state, as: .dump) @@ -124,22 +127,25 @@ final class KlaviyoStateTests: XCTestCase { func testFullKlaviyoStateEncodingDecodingIsEqual() throws { let event = Event.test - let createEventPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateEventPayload(data: .init(event: event)) - let eventRequest = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(createEventPayload)) + let createEventPayload = CreateEventPayload(data: CreateEventPayload.Event(name: event.metric.name.value)) + let eventRequest = KlaviyoRequest(apiKey: "foo", endpoint: .createEvent(createEventPayload)) + let profile = Profile.test - let data = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateProfilePayload.Profile(profile: profile, anonymousId: "foo") - let payload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateProfilePayload(data: data) - let profileRequest = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(payload)) - let tokenPayload = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload( + let payload = CreateProfilePayload(data: profile.toAPIModel(anonymousId: "foo")) + + let profileRequest = KlaviyoRequest(apiKey: "foo", endpoint: .createProfile(payload)) + let tokenPayload = PushTokenPayload( pushToken: "foo", enablement: "AUTHORIZED", background: "AVAILABLE", - profile: .init(email: "foo", phoneNumber: "foo"), - anonymousId: "foo") - let tokenRequest = KlaviyoAPI.KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) - let state = KlaviyoState(apiKey: "key", queue: [tokenRequest, profileRequest, eventRequest]) - let encodedState = try KlaviyoEnvironment.production.analytics.encodeJSON(AnyEncodable(state)) - let decodedState: KlaviyoState = try KlaviyoEnvironment.production.analytics.decoder.decode(encodedState) + profile: ProfilePayload(email: "foo", phoneNumber: "foo", anonymousId: "foo")) + let tokenRequest = KlaviyoRequest(apiKey: "foo", endpoint: .registerPushToken(tokenPayload)) + + let state = KlaviyoState(apiKey: "key", queue: [tokenRequest, eventRequest, profileRequest]) + + let encodedState = try KlaviyoEnvironment.production.encodeJSON(AnyEncodable(state)) + let decodedState: KlaviyoState = try KlaviyoEnvironment.production.decoder.decode(encodedState) + XCTAssertEqual(decodedState, state) } @@ -156,23 +162,23 @@ final class KlaviyoStateTests: XCTestCase { func testBackgroundStates() { let backgroundStates = [ - UIBackgroundRefreshStatus.available: KlaviyoState.PushBackground.available, + UIBackgroundRefreshStatus.available: PushBackground.available, .denied: .denied, .restricted: .restricted ] for (status, expecation) in backgroundStates { - XCTAssertEqual(KlaviyoState.PushBackground.create(from: status), expecation) + XCTAssertEqual(PushBackground.create(from: status), expecation) } // Fake value to test availability - XCTAssertEqual(KlaviyoState.PushBackground.create(from: UIBackgroundRefreshStatus(rawValue: 20)!), .available) + XCTAssertEqual(PushBackground.create(from: UIBackgroundRefreshStatus(rawValue: 20)!), .available) } @available(iOS 14.0, *) func testPushEnablementStates() { let enablementStates = [ - UNAuthorizationStatus.authorized: KlaviyoState.PushEnablement.authorized, + UNAuthorizationStatus.authorized: PushEnablement.authorized, .denied: .denied, .ephemeral: .ephemeral, .notDetermined: .notDetermined, @@ -180,10 +186,10 @@ final class KlaviyoStateTests: XCTestCase { ] for (status, expecation) in enablementStates { - XCTAssertEqual(KlaviyoState.PushEnablement.create(from: status), expecation) + XCTAssertEqual(PushEnablement.create(from: status), expecation) } // Fake value to test availability - XCTAssertEqual(KlaviyoState.PushEnablement.create(from: UNAuthorizationStatus(rawValue: 50)!), .notDetermined) + XCTAssertEqual(PushEnablement.create(from: UNAuthorizationStatus(rawValue: 50)!), .notDetermined) } } diff --git a/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift b/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift index 684da321..7fc8f603 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoTestUtils.swift @@ -9,57 +9,9 @@ import Combine import XCTest @_spi(KlaviyoPrivate) @testable import KlaviyoSwift import CombineSchedulers - -enum FakeFileError: Error { - case fake -} +import KlaviyoCore let ARCHIVED_RETURNED_DATA = Data() -let SAMPLE_DATA: NSMutableArray = [ - [ - "properties": [ - "foo": "bar" - ] - ] -] -let TEST_URL = URL(string: "fake_url")! -let TEST_RETURN_DATA = Data() - -let TEST_FAILURE_JSON_INVALID_PHONE_NUMBER = """ -{ - "errors": [ - { - "id": "9997bd4f-7d5f-4f01-bbd1-df0065ef4faa", - "status": 400, - "code": "invalid", - "title": "Invalid input.", - "detail": "Invalid phone number format (Example of a valid format: +12345678901)", - "source": { - "pointer": "/data/attributes/phone_number" - }, - "meta": {} - } - ] -} -""" - -let TEST_FAILURE_JSON_INVALID_EMAIL = """ -{ - "errors": [ - { - "id": "dce2d180-0f36-4312-aa6d-92d025c17147", - "status": 400, - "code": "invalid", - "title": "Invalid input.", - "detail": "Invalid email address", - "source": { - "pointer": "/data/attributes/email" - }, - "meta": {} - } - ] -} -""" extension ArchiverClient { static let test = ArchiverClient( @@ -68,29 +20,39 @@ extension ArchiverClient { } extension AppLifeCycleEvents { - static let test = Self(lifeCycleEvents: { Empty().eraseToAnyPublisher() }) + static let test = Self(lifeCycleEvents: { Empty().eraseToAnyPublisher() }) } extension KlaviyoEnvironment { static var lastLog: String? - static var test = { KlaviyoEnvironment( - archiverClient: ArchiverClient.test, - fileClient: FileClient.test, - data: { _ in TEST_RETURN_DATA }, - logger: LoggerClient.test, - analytics: AnalyticsEnvironment.test, - getUserDefaultString: { _ in "value" }, - appLifeCycle: AppLifeCycleEvents.test, - notificationCenterPublisher: { _ in Empty().eraseToAnyPublisher() }, - getNotificationSettings: { .authorized }, - getBackgroundSetting: { .available }, - legacyIdentifier: { "iOS:\(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!.uuidString)" }, - startReachability: {}, - stopReachability: {}, - reachabilityStatus: { nil }, - randomInt: { 0 }, - stateChangePublisher: { Empty().eraseToAnyPublisher() }, - raiseFatalError: { _ in }, emitDeveloperWarning: { _ in }) + static var test = { + KlaviyoEnvironment( + archiverClient: ArchiverClient.test, + fileClient: FileClient.test, + dataFromUrl: { _ in TEST_RETURN_DATA }, + logger: LoggerClient.test, + appLifeCycle: AppLifeCycleEvents.test, + notificationCenterPublisher: { _ in Empty().eraseToAnyPublisher() }, + getNotificationSettings: { .authorized }, + getBackgroundSetting: { .available }, + startReachability: {}, + stopReachability: {}, + reachabilityStatus: { nil }, + randomInt: { 0 }, + raiseFatalError: { _ in }, + emitDeveloperWarning: { _ in }, + networkSession: { NetworkSession.test() }, + apiURL: { "dead_beef" }, + encodeJSON: { _ in TEST_RETURN_DATA }, + decoder: DataDecoder(jsonDecoder: TestJSONDecoder()), + uuid: { UUID(uuidString: "00000000-0000-0000-0000-000000000001")! }, + date: { Date(timeIntervalSince1970: 1_234_567_890) }, + timeZone: { "EST" }, + appContextInfo: { AppContextInfo.test }, + klaviyoAPI: KlaviyoAPI.test(), + timer: { _ in Just(Date()).eraseToAnyPublisher() }, + SDKName: { __klaviyoSwiftName }, + SDKVersion: { __klaviyoSwiftVersion }) } } @@ -106,31 +68,6 @@ class InvalidJSONDecoder: JSONDecoder { } } -extension AnalyticsEnvironment { - static let testStore = Store(initialState: KlaviyoState(queue: []), reducer: KlaviyoReducer()) - - static let test = AnalyticsEnvironment( - networkSession: { NetworkSession.test() }, - apiURL: "dead_beef", - encodeJSON: { _ in TEST_RETURN_DATA }, - decoder: DataDecoder(jsonDecoder: TestJSONDecoder()), - uuid: { UUID(uuidString: "00000000-0000-0000-0000-000000000001")! }, - date: { Date(timeIntervalSince1970: 1_234_567_890) }, - timeZone: { "EST" }, - appContextInfo: { AppContextInfo.test }, - klaviyoAPI: KlaviyoAPI.test(), - timer: { _ in Just(Date()).eraseToAnyPublisher() }, - send: { action in - testStore.send(action) - }, - state: { - AnalyticsEnvironment.testStore.state.value - }, - statePublisher: { - Just(INITIALIZED_TEST_STATE()).eraseToAnyPublisher() - }) -} - struct KlaviyoTestReducer: ReducerProtocol { var reducer: (inout KlaviyoSwift.KlaviyoState, KlaviyoAction) -> EffectTask = { _, _ in .none } diff --git a/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift b/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift index 4cfe4dec..7ac9e32c 100644 --- a/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift +++ b/Tests/KlaviyoSwiftTests/StateChangePublisherTests.swift @@ -10,6 +10,7 @@ import CombineSchedulers import Foundation import XCTest @_spi(KlaviyoPrivate) @testable import KlaviyoSwift +import KlaviyoCore final class StateChangePublisherTests: XCTestCase { @MainActor @@ -53,21 +54,21 @@ final class StateChangePublisherTests: XCTestCase { let reducer = KlaviyoTestReducer(reducer: initializationReducer) let test = Store(initialState: .test, reducer: reducer) - environment.analytics.send = { + klaviyoSwiftEnvironment.send = { test.send($0) } - environment.analytics.statePublisher = { + klaviyoSwiftEnvironment.statePublisher = { test.state.eraseToAnyPublisher() } testScheduler.run() @MainActor func runDebouncedEffect() { - _ = environment.analytics.send(.initialize("foo")) + _ = klaviyoSwiftEnvironment.send(.initialize("foo")) testScheduler.run() // This should not trigger a save since in our reducer it does not change the state. - _ = environment.analytics.send(.setPushToken("foo", .authorized)) - _ = environment.analytics.send(.setEmail("foo")) + _ = klaviyoSwiftEnvironment.send(.setPushToken("foo", .authorized)) + _ = klaviyoSwiftEnvironment.send(.setEmail("foo")) } runDebouncedEffect() testScheduler.advance(by: .seconds(2.0)) @@ -99,18 +100,18 @@ final class StateChangePublisherTests: XCTestCase { let reducer = KlaviyoTestReducer(reducer: initializationReducer) let test = Store(initialState: .test, reducer: reducer) - environment.analytics.send = { + klaviyoSwiftEnvironment.send = { test.send($0) } - environment.analytics.statePublisher = { + klaviyoSwiftEnvironment.statePublisher = { test.state.eraseToAnyPublisher() } @MainActor func runDebouncedEffect() { - _ = environment.analytics.send(.initialize("foo")) - _ = environment.analytics.send(.flushQueue) - _ = environment.analytics.send(.flushQueue) + _ = klaviyoSwiftEnvironment.send(.initialize("foo")) + _ = klaviyoSwiftEnvironment.send(.flushQueue) + _ = klaviyoSwiftEnvironment.send(.flushQueue) } runDebouncedEffect() @@ -149,17 +150,17 @@ final class StateChangePublisherTests: XCTestCase { let reducer = KlaviyoTestReducer(reducer: initializationReducer) let test = Store(initialState: .test, reducer: reducer) - environment.analytics.send = { + klaviyoSwiftEnvironment.send = { test.send($0) } - environment.analytics.statePublisher = { + klaviyoSwiftEnvironment.statePublisher = { test.state.eraseToAnyPublisher() } - _ = environment.analytics.send(.initialize("foo")) + _ = klaviyoSwiftEnvironment.send(.initialize("foo")) testScheduler.run() for i in 0...10 { - _ = environment.analytics.send(.setEmail("foo\(i)")) + _ = klaviyoSwiftEnvironment.send(.setEmail("foo\(i)")) } testScheduler.advance(by: 1.0) wait(for: [savedCalledExpectation], timeout: 1.0) diff --git a/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift b/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift index 7671f4e4..f724336d 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementEdgeCaseTests.swift @@ -7,12 +7,14 @@ @testable import KlaviyoSwift import Foundation +import KlaviyoCore import XCTest class StateManagementEdgeCaseTests: XCTestCase { @MainActor override func setUp() async throws { environment = KlaviyoEnvironment.test() + klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() } // MARK: - initialization @@ -111,7 +113,7 @@ class StateManagementEdgeCaseTests: XCTestCase { } await store.receive(.start) await store.receive(.flushQueue) - await store.receive(.setPushEnablement(KlaviyoState.PushEnablement.authorized)) + await store.receive(.setPushEnablement(PushEnablement.authorized)) } // MARK: - Set Email @@ -125,7 +127,7 @@ class StateManagementEdgeCaseTests: XCTestCase { } let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -172,7 +174,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testSetExternalIdUninitializedDoesNotAddToPendingRequest() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -217,7 +219,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testSetPhoneNumberUninitializedDoesNotAddToPendingRequest() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -229,7 +231,7 @@ class StateManagementEdgeCaseTests: XCTestCase { @MainActor func testSetPhoneNumberMissingApiKeyStillSetsPhoneNumber() async throws { - let initialState = KlaviyoState(anonymousId: environment.analytics.uuid().uuidString, + let initialState = KlaviyoState(anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .initialized, @@ -261,7 +263,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testSetPushTokenUninitializedDoesNotAddToPendingRequest() async throws { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -293,7 +295,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testStopUninitialized() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -307,7 +309,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testStopInitializing() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .initializing, @@ -323,7 +325,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testStartUninitialized() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -339,7 +341,7 @@ class StateManagementEdgeCaseTests: XCTestCase { func testNetworkStatusChangedUninitialized() async { let apiKey = "fake-key" let initialState = KlaviyoState(apiKey: apiKey, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .uninitialized, @@ -354,7 +356,7 @@ class StateManagementEdgeCaseTests: XCTestCase { @MainActor func testTokenRequestMissingApiKey() async { let initialState = KlaviyoState( - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .initialized, @@ -416,13 +418,13 @@ class StateManagementEdgeCaseTests: XCTestCase { let initialState = KlaviyoState( apiKey: TEST_API_KEY, email: "foo@bar.com", - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, phoneNumber: "99999999", externalId: "12345", pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.analytics.appContextInfo())), + deviceData: .init(context: environment.appContextInfo())), queue: [], requestsInFlight: [], initalizationState: .initialized, diff --git a/Tests/KlaviyoSwiftTests/StateManagementTests.swift b/Tests/KlaviyoSwiftTests/StateManagementTests.swift index 7965ac75..b2ef5156 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementTests.swift @@ -9,12 +9,14 @@ import AnyCodable import Combine import Foundation +import KlaviyoCore import XCTest class StateManagementTests: XCTestCase { @MainActor override func setUp() async throws { environment = KlaviyoEnvironment.test() + klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() } // MARK: - Initialization @@ -26,12 +28,12 @@ class StateManagementTests: XCTestCase { let apiKey = "fake-key" // Avoids a warning in xcode despite the result being discardable. - _ = await store.send(.initialize(apiKey)) { + await store.send(.initialize(apiKey)) { $0.apiKey = apiKey $0.initalizationState = .initializing } - let expectedState = KlaviyoState(apiKey: apiKey, anonymousId: environment.analytics.uuid().uuidString, queue: [], requestsInFlight: []) + let expectedState = KlaviyoState(apiKey: apiKey, anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: []) await store.receive(.completeInitialization(expectedState)) { $0.anonymousId = expectedState.anonymousId $0.initalizationState = .initialized @@ -40,14 +42,14 @@ class StateManagementTests: XCTestCase { await store.receive(.start) await store.receive(.flushQueue) - await store.receive(.setPushEnablement(KlaviyoState.PushEnablement.authorized)) + await store.receive(.setPushEnablement(PushEnablement.authorized)) } @MainActor func testInitializeSubscribesToAppropriatePublishers() async throws { let lifecycleExpectation = XCTestExpectation(description: "lifecycle is subscribed") let stateChangeIsSubscribed = XCTestExpectation(description: "state change is subscribed") - let lifecycleSubject = PassthroughSubject() + let lifecycleSubject = PassthroughSubject() environment.appLifeCycle.lifeCycleEvents = { lifecycleSubject.handleEvents(receiveSubscription: { _ in lifecycleExpectation.fulfill() @@ -55,7 +57,7 @@ class StateManagementTests: XCTestCase { .eraseToAnyPublisher() } let stateChangeSubject = PassthroughSubject() - environment.stateChangePublisher = { + klaviyoSwiftEnvironment.stateChangePublisher = { stateChangeSubject.handleEvents(receiveSubscription: { _ in stateChangeIsSubscribed.fulfill() }) @@ -144,7 +146,7 @@ class StateManagementTests: XCTestCase { _ = await store.receive(.deQueueCompletedResults(pushTokenRequest)) { $0.flushing = false $0.requestsInFlight = [] - $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.analytics.appContextInfo())) + $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())) } } @@ -208,7 +210,7 @@ class StateManagementTests: XCTestCase { _ = await store.receive(.deQueueCompletedResults(pushTokenRequest)) { $0.flushing = false $0.requestsInFlight = [] - $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.analytics.appContextInfo())) + $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blobtoken", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())) } _ = await store.send(.setPushToken("blobtoken", .authorized)) } @@ -285,7 +287,7 @@ class StateManagementTests: XCTestCase { func testFlushQueueWithMultipleRequests() async throws { var count = 0 // request uuids need to be unique :) - environment.analytics.uuid = { + environment.uuid = { count += 1 switch count { case 1: @@ -317,7 +319,7 @@ class StateManagementTests: XCTestCase { } await store.receive(.sendRequest) await store.receive(.deQueueCompletedResults(request2)) { - $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.analytics.appContextInfo())) + $0.pushTokenData = KlaviyoState.PushTokenData(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.appContextInfo())) $0.flushing = false $0.requestsInFlight = [] $0.queue = [] @@ -466,7 +468,7 @@ class StateManagementTests: XCTestCase { } } - var request: KlaviyoAPI.KlaviyoRequest? + var request: KlaviyoRequest? _ = await store.send(.flushQueue) { $0.enqueueProfileOrTokenRequest() @@ -536,14 +538,13 @@ class StateManagementTests: XCTestCase { $0.externalId = Profile.test.externalId $0.pushTokenData = nil - let request = KlaviyoAPI.KlaviyoRequest( + let request = KlaviyoRequest( apiKey: initialState.apiKey!, - endpoint: .registerPushToken(.init( + endpoint: .registerPushToken(PushTokenPayload( pushToken: initialState.pushTokenData!.pushToken, enablement: initialState.pushTokenData!.pushEnablement.rawValue, background: initialState.pushTokenData!.pushBackground.rawValue, - profile: Profile.test, - anonymousId: initialState.anonymousId!) + profile: Profile.test.toAPIModel(anonymousId: initialState.anonymousId!)) )) $0.queue = [request] } @@ -560,8 +561,18 @@ class StateManagementTests: XCTestCase { for eventName in Event.EventName.allCases { let event = Event(name: eventName, properties: ["push_token": initialState.pushTokenData!.pushToken]) await store.send(.enqueueEvent(event)) { - let newEvent = Event(name: eventName, properties: event.properties, identifiers: .init(phoneNumber: $0.phoneNumber)) - try $0.enqueueRequest(request: .init(apiKey: XCTUnwrap($0.apiKey), endpoint: .createEvent(.init(data: .init(event: newEvent, anonymousId: XCTUnwrap($0.anonymousId)))))) + try $0.enqueueRequest( + request: KlaviyoRequest( + apiKey: XCTUnwrap($0.apiKey), + endpoint: .createEvent(CreateEventPayload( + data: CreateEventPayload.Event( + name: eventName.value, + properties: event.properties, + phoneNumber: $0.phoneNumber, + anonymousId: initialState.anonymousId!, + time: event.time, + pushToken: initialState.pushTokenData!.pushToken) + )))) } // if the event is opened push we want to flush immidietly, for all other events we flush during regular intervals set in code @@ -587,16 +598,22 @@ class StateManagementTests: XCTestCase { } await store.receive(.enqueueEvent(event), timeout: TIMEOUT_NANOSECONDS) { - let newEvent = Event(name: .OpenedAppMetric, identifiers: .init(phoneNumber: $0.phoneNumber)) try $0.enqueueRequest( - request: .init(apiKey: XCTUnwrap($0.apiKey), - endpoint: .createEvent(.init( - data: .init(event: newEvent, anonymousId: XCTUnwrap($0.anonymousId))))) + request: KlaviyoRequest( + apiKey: XCTUnwrap($0.apiKey), + endpoint: .createEvent(CreateEventPayload( + data: CreateEventPayload.Event( + name: Event.EventName.OpenedAppMetric.value, + properties: event.properties, + phoneNumber: $0.phoneNumber, + anonymousId: initialState.anonymousId!, + time: event.time) + ))) ) } await store.receive(.start, timeout: TIMEOUT_NANOSECONDS) await store.receive(.flushQueue, timeout: TIMEOUT_NANOSECONDS) - await store.receive(.setPushEnablement(KlaviyoState.PushEnablement.authorized), timeout: TIMEOUT_NANOSECONDS) + await store.receive(.setPushEnablement(PushEnablement.authorized), timeout: TIMEOUT_NANOSECONDS) } } diff --git a/Tests/KlaviyoSwiftTests/TestData.swift b/Tests/KlaviyoSwiftTests/TestData.swift index 0a842529..9e83c167 100644 --- a/Tests/KlaviyoSwiftTests/TestData.swift +++ b/Tests/KlaviyoSwiftTests/TestData.swift @@ -7,17 +7,19 @@ import Foundation @_spi(KlaviyoPrivate) @testable import KlaviyoSwift +import Combine +import KlaviyoCore let TEST_API_KEY = "fake-key" let INITIALIZED_TEST_STATE = { KlaviyoState( apiKey: TEST_API_KEY, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.analytics.appContextInfo())), + deviceData: .init(context: environment.appContextInfo())), queue: [], requestsInFlight: [], initalizationState: .initialized, @@ -27,7 +29,7 @@ let INITIALIZED_TEST_STATE = { let INITILIZING_TEST_STATE = { KlaviyoState( apiKey: TEST_API_KEY, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, queue: [], requestsInFlight: [], initalizationState: .initializing, @@ -37,12 +39,12 @@ let INITILIZING_TEST_STATE = { let INITIALIZED_TEST_STATE_INVALID_PHONE = { KlaviyoState( apiKey: TEST_API_KEY, - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, phoneNumber: "invalid_phone_number", pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.analytics.appContextInfo())), + deviceData: .init(context: environment.appContextInfo())), queue: [], requestsInFlight: [], initalizationState: .initialized, @@ -53,11 +55,11 @@ let INITIALIZED_TEST_STATE_INVALID_EMAIL = { KlaviyoState( apiKey: TEST_API_KEY, email: "invalid_email", - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, - deviceData: .init(context: environment.analytics.appContextInfo())), + deviceData: .init(context: environment.appContextInfo())), queue: [], requestsInFlight: [], initalizationState: .initialized, @@ -82,7 +84,7 @@ extension Profile { title: "Jelly", image: "foo", location: .test, - properties: SAMPLE_PROPERTIES) + properties: [:]) } extension Profile.Location { @@ -113,40 +115,88 @@ extension Event { "Device Manufacturer": "Orange", "Device Model": "jPhone 1,1" ] as [String: Any] - static let test = Self(name: .CustomEvent("blob"), properties: SAMPLE_PROPERTIES) + static let test = Self(name: .CustomEvent("blob"), properties: nil, time: KlaviyoEnvironment.test().date()) } extension Event.Metric { static let test = Self(name: .CustomEvent("blob")) } -extension KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.CreateEventPayload { - static let test = Self(data: .init(event: .test)) -} - -extension URLResponse { - static let non200Response = HTTPURLResponse(url: TEST_URL, statusCode: 500, httpVersion: nil, headerFields: nil)! - static let validResponse = HTTPURLResponse(url: TEST_URL, statusCode: 200, httpVersion: nil, headerFields: nil)! -} - -extension KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload { - static let test = KlaviyoAPI.KlaviyoRequest.KlaviyoEndpoint.PushTokenPayload( - pushToken: "foo", - enablement: "AUTHORIZED", - background: "AVAILABLE", - profile: .init(), - anonymousId: "anon-id") -} - extension KlaviyoState { static let test = KlaviyoState(apiKey: "foo", email: "test@test.com", - anonymousId: environment.analytics.uuid().uuidString, + anonymousId: environment.uuid().uuidString, phoneNumber: "phoneNumber", externalId: "externalId", - pushTokenData: .init(pushToken: "blob_token", pushEnablement: .authorized, pushBackground: .available, deviceData: .init(context: environment.analytics.appContextInfo())), + pushTokenData: PushTokenData( + pushToken: "blob_token", + pushEnablement: .authorized, + pushBackground: .available, + deviceData: DeviceMetadata(context: environment.appContextInfo())), queue: [], requestsInFlight: [], initalizationState: .initialized, flushing: true) } + +let SAMPLE_DATA: NSMutableArray = [ + [ + "properties": [ + "foo": "bar" + ] + ] +] +let TEST_URL = URL(string: "fake_url")! +let TEST_RETURN_DATA = Data() + +let TEST_FAILURE_JSON_INVALID_PHONE_NUMBER = """ +{ + "errors": [ + { + "id": "9997bd4f-7d5f-4f01-bbd1-df0065ef4faa", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid phone number format (Example of a valid format: +12345678901)", + "source": { + "pointer": "/data/attributes/phone_number" + }, + "meta": {} + } + ] +} +""" + +let TEST_FAILURE_JSON_INVALID_EMAIL = """ +{ + "errors": [ + { + "id": "dce2d180-0f36-4312-aa6d-92d025c17147", + "status": 400, + "code": "invalid", + "title": "Invalid input.", + "detail": "Invalid email address", + "source": { + "pointer": "/data/attributes/email" + }, + "meta": {} + } + ] +} +""" + +extension KlaviyoSwiftEnvironment { + static let testStore = Store(initialState: KlaviyoState(queue: []), reducer: KlaviyoReducer()) + + static let test = { + KlaviyoSwiftEnvironment(send: { action in + testStore.send(action) + }, state: { + KlaviyoSwiftEnvironment.testStore.state.value + }, statePublisher: { + Just(INITIALIZED_TEST_STATE()).eraseToAnyPublisher() + }, stateChangePublisher: { + Empty().eraseToAnyPublisher() + }) + } +} diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testEventPayloadWithoutMetadata.1.json b/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testEventPayloadWithoutMetadata.1.json deleted file mode 100644 index 2ad70580..00000000 --- a/Tests/KlaviyoSwiftTests/__Snapshots__/EncodableTests/testEventPayloadWithoutMetadata.1.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "data" : { - "attributes" : { - "metric" : { - "data" : { - "attributes" : { - "name" : "blob" - }, - "type" : "metric" - } - }, - "profile" : { - "data" : { - "attributes" : { - "anonymous_id" : "anon-id", - "properties" : { - - } - }, - "type" : "profile" - } - }, - "properties" : { - "App Build" : "1", - "App Name" : "FooApp", - "App Version" : "1.2.3", - "Application ID" : "com.klaviyo.fooapp", - "blob" : "blob", - "Device Manufacturer" : "Orange", - "Device Model" : "jPhone 1,1", - "hello" : { - "sub" : "dict" - }, - "OS Name" : "iOS", - "OS Version" : "1.1.1", - "stuff" : 2 - }, - "time" : "2009-02-13T23:31:30Z", - "unique_id" : "00000000-0000-0000-0000-000000000001" - }, - "type" : "event" - } -} \ No newline at end of file diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testEncodingError.1.txt b/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testEncodingError.1.txt deleted file mode 100644 index 41e20556..00000000 --- a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testEncodingError.1.txt +++ /dev/null @@ -1,22 +0,0 @@ -▿ KlaviyoAPIError - ▿ internalRequestError: KlaviyoAPIError - ▿ dataEncodingError: KlaviyoRequest - - apiKey: "foo" - ▿ endpoint: KlaviyoEndpoint - ▿ createProfile: CreateProfilePayload - ▿ data: Profile - ▿ attributes: Attributes - - anonymousId: "foo" - - email: Optional.none - - externalId: Optional.none - - firstName: Optional.none - - image: Optional.none - - lastName: Optional.none - - location: Optional.none - - organization: Optional.none - - phoneNumber: Optional.none - ▿ properties: [:] - - value: 0 key/value pairs - - title: Optional.none - - type: "profile" - - uuid: "00000000-0000-0000-0000-000000000001" diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testInvalidURL.1.txt b/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testInvalidURL.1.txt deleted file mode 100644 index edd8b8ef..00000000 --- a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoAPITests/testInvalidURL.1.txt +++ /dev/null @@ -1 +0,0 @@ -internalRequestError(KlaviyoSwift.KlaviyoAPI.KlaviyoAPIError.internalError("Invalid url string. API URL: ")) \ No newline at end of file diff --git a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testValidStateFileExists.1.txt b/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testValidStateFileExists.1.txt index 7be1f88b..751eaaf8 100644 --- a/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testValidStateFileExists.1.txt +++ b/Tests/KlaviyoSwiftTests/__Snapshots__/KlaviyoStateTests/testValidStateFileExists.1.txt @@ -3,15 +3,35 @@ - some: "00000000-0000-0000-0000-000000000001" ▿ apiKey: Optional - some: "foo" - - email: Optional.none - - externalId: Optional.none + ▿ email: Optional + - some: "test@test.com" + ▿ externalId: Optional + - some: "externalId" - flushInterval: 10.0 - - flushing: false - - initalizationState: InitializationState.uninitialized + - flushing: true + - initalizationState: InitializationState.initialized - pendingProfile: Optional>.none - pendingRequests: 0 elements - - phoneNumber: Optional.none - - pushTokenData: Optional.none + ▿ phoneNumber: Optional + - some: "phoneNumber" + ▿ pushTokenData: Optional + ▿ some: PushTokenData + ▿ deviceData: MetaData + - appBuild: "1" + - appId: "com.klaviyo.fooapp" + - appName: "FooApp" + - appVersion: "1.2.3" + - deviceId: "fe-fi-fo-fum" + - deviceModel: "jPhone 1,1" + - environment: "debug" + - klaviyoSdk: "swift" + - manufacturer: "Orange" + - osName: "iOS" + - osVersion: "1.1.1" + - sdkVersion: "3.2.0" + - pushBackground: PushBackground.available + - pushEnablement: PushEnablement.authorized + - pushToken: "blob_token" - queue: 0 elements - requestsInFlight: 0 elements ▿ retryInfo: RetryInfo