diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 37e88c4f..1eee8316 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -48,7 +48,7 @@ struct GeofenceService: GeofenceServiceProvider { let response = try JSONDecoder().decode(GeofenceJSONResponse.self, from: data) return try Set(response.data.map { rawGeofence in try Geofence( - id: "_k:\(companyId):\(rawGeofence.id)", + id: "_k:\(companyId):\(rawGeofence.id):\(rawGeofence.attributes.duration.map { String($0) } ?? "")", longitude: rawGeofence.attributes.longitude, latitude: rawGeofence.attributes.latitude, radius: rawGeofence.attributes.radius @@ -72,5 +72,6 @@ private struct GeofenceJSON: Codable { let latitude: Double let longitude: Double let radius: Double + let duration: Int? } } diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index aec5e05a..ef4ee204 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -48,24 +48,34 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { Task { await stopGeofenceMonitoring() } + @unknown default: + return } } // MARK: Geofencing public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { - handleGeofenceEvent(region: region, eventType: .geofenceEnter) + Task { @MainActor in + await handleGeofenceEvent(region: region, eventType: .geofenceEnter) + } } public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { - handleGeofenceEvent(region: region, eventType: .geofenceExit) + Task { @MainActor in + await handleGeofenceEvent(region: region, eventType: .geofenceExit) + } } - private func handleGeofenceEvent(region: CLRegion, eventType: Event.EventName.LocationEvent) { + @MainActor + private func handleGeofenceEvent(region: CLRegion, eventType: Event.EventName.LocationEvent) async { + checkForExpiredDwellTimers() guard let region = region as? CLCircularRegion, - let klaviyoLocationId = region.klaviyoLocationId else { + let klaviyoGeofence = try? region.toKlaviyoGeofence(), + !klaviyoGeofence.companyId.isEmpty else { return } + let klaviyoLocationId = klaviyoGeofence.locationId // Check cooldown period before processing event guard cooldownTracker.isAllowed(geofenceId: klaviyoLocationId, transition: eventType) else { @@ -87,9 +97,106 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { properties: ["$geofence_id": klaviyoLocationId] ) + await KlaviyoInternal.createGeofenceEvent(event: event, for: klaviyoGeofence.companyId) + if eventType == .geofenceEnter { + startDwellTimer(for: klaviyoLocationId, companyId: klaviyoGeofence.companyId) + } else { + cancelDwellTimer(for: klaviyoLocationId) + } + } +} + +// MARK: Dwell Timer Management + +extension KlaviyoLocationManager { + private func startDwellTimer(for klaviyoLocationId: String, companyId: String) { + cancelDwellTimer(for: klaviyoLocationId) + guard let dwellSeconds = activeGeofenceDurations[klaviyoLocationId] else { + return + } + + let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in + self?.handleDwellTimerFired(for: klaviyoLocationId, apiKey: companyId) + } + currentDwellTimers[klaviyoLocationId] = timer + + // Persist timer start time, duration, and company ID for recovery if app terminates + dwellTimerTracker.saveTimer(geofenceId: klaviyoLocationId, startTime: environment.date().timeIntervalSince1970, duration: dwellSeconds, companyId: companyId) + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Started dwell timer for region \(klaviyoLocationId) with \(dwellSeconds) seconds") + } + } + + private func cancelDwellTimer(for klaviyoLocationId: String) { + // remove tracking it from the persisted tracker + dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) + + if let timer = currentDwellTimers[klaviyoLocationId] { + timer.invalidate() + currentDwellTimers.removeValue(forKey: klaviyoLocationId) + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Cancelled dwell timer for region \(klaviyoLocationId)") + } + } + } + + private func handleDwellTimerFired(for klaviyoLocationId: String, apiKey: String) { + // remove tracking in both memory and persisted tracker since it fired + currentDwellTimers.removeValue(forKey: klaviyoLocationId) + dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) + + guard let dwellDuration = activeGeofenceDurations[klaviyoLocationId] else { + return + } + + let dwellEvent = Event( + name: .locationEvent(.geofenceDwell), + properties: [ + "$geofence_id": klaviyoLocationId, + "$geofence_dwell_duration": dwellDuration + ] + ) + Task { - await MainActor.run { - KlaviyoInternal.create(event: event) + await KlaviyoInternal.createGeofenceEvent(event: dwellEvent, for: apiKey) + } + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Dwell event fired for region \(klaviyoLocationId)") + } + } + + /// Check for expired timers and fire dwell events for them + /// Called on app launch/foreground as a best-effort recovery mechanism + @MainActor + @objc + func checkForExpiredDwellTimers() { + let expiredTimers = dwellTimerTracker.getExpiredTimers() + + // Fire dwell events for expired timers + for (geofenceId, duration, companyId) in expiredTimers { + // Invalidate any corresponding in-memory timer to prevent duplicate events + if let timer = currentDwellTimers[geofenceId] { + timer.invalidate() + currentDwellTimers.removeValue(forKey: geofenceId) + } + + let dwellEvent = Event( + name: .locationEvent(.geofenceDwell), + properties: [ + "$geofence_id": geofenceId, + "$geofence_dwell_duration": duration + ] + ) + + Task { + await KlaviyoInternal.createGeofenceEvent(event: dwellEvent, for: companyId) + } + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Fired expired dwell event for region \(geofenceId) (expired while app was terminated)") } } } diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index a2b37470..404ee4a6 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -19,6 +19,10 @@ class KlaviyoLocationManager: NSObject { private var apiKeyCancellable: AnyCancellable? private var lifecycleCancellable: AnyCancellable? internal let cooldownTracker = GeofenceCooldownTracker() + internal let dwellTimerTracker = DwellTimerTracker() + + var activeGeofenceDurations: [String: Int] = [:] + var currentDwellTimers: [String: Timer] = [:] init(locationManager: LocationManagerProtocol? = nil) { self.locationManager = locationManager ?? CLLocationManager() @@ -69,6 +73,7 @@ class KlaviyoLocationManager: NSObject { let geofencesToAdd = remoteGeofences.subtracting(activeGeofences) await MainActor.run { + updateDwellSettings(remoteGeofences) for geofence in geofencesToAdd { locationManager.startMonitoring(for: geofence.toCLCircularRegion()) } @@ -114,6 +119,12 @@ class KlaviyoLocationManager: NSObject { } klaviyoRegions.forEach(locationManager.stopMonitoring) + activeGeofenceDurations.removeAll() + for timer in currentDwellTimers.values { + timer.invalidate() + } + currentDwellTimers.removeAll() + dwellTimerTracker.clearAllTimers() } // MARK: - API Key Observation @@ -160,6 +171,9 @@ class KlaviyoLocationManager: NSObject { self.locationManager.startMonitoringSignificantLocationChanges() case .foregrounded, .backgrounded: self.locationManager.stopMonitoringSignificantLocationChanges() + Task { @MainActor in + self.checkForExpiredDwellTimers() + } default: break } @@ -170,4 +184,13 @@ class KlaviyoLocationManager: NSObject { lifecycleCancellable?.cancel() lifecycleCancellable = nil } + + // MARK: - Dwell Settings Management + + private func updateDwellSettings(_ geofences: Set) { + activeGeofenceDurations.removeAll() + for geofence in geofences { + activeGeofenceDurations[geofence.locationId] = geofence.duration + } + } } diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index e363b39e..f6812df4 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -12,7 +12,7 @@ import KlaviyoSwift /// Represents a Klaviyo geofence struct Geofence: Equatable, Hashable, Codable { - /// The geofence ID in the format "_k:{companyId}:{UUID}" with a "_k" prefix, company ID, and location ID from Klaviyo, separated by colons. + /// The geofence ID in the format "_k:{companyId}:{UUID}:{duration}" with a "_k" prefix, company ID, location ID, and optional duration from Klaviyo, separated by colons. let id: String /// Longitude of the geofence center @@ -26,21 +26,31 @@ struct Geofence: Equatable, Hashable, Codable { /// Company ID to which this geofence belongs, extracted from the geofence ID. var companyId: String { - let components = id.split(separator: ":") - guard components.count == 3, components[0] == "_k" else { return "" } + let components = id.split(separator: ":", omittingEmptySubsequences: false) + guard components.count == 4, components[0] == "_k" else { return "" } return String(components[1]) } /// Location UUID to which this geofence belongs, extracted from the geofence ID. var locationId: String { - let components = id.split(separator: ":", maxSplits: 2) - guard components.count == 3, components[0] == "_k" else { return "" } + let components = id.split(separator: ":", omittingEmptySubsequences: false) + guard components.count == 4, components[0] == "_k" else { return "" } return String(components[2]) } + /// Optional duration for this geofence to record a dwell event + var duration: Int? { + let components = id.split(separator: ":", omittingEmptySubsequences: false) + guard components.count == 4, components[0] == "_k" else { return nil } + let durationString = String(components[3]) + // Return nil if duration component is empty + guard !durationString.isEmpty else { return nil } + return Int(durationString) + } + /// Creates a new geofence /// - Parameters: - /// - id: Unique identifier for the geofence in format "_k:{companyId}:{UUID}" where companyId is 6 alphanumeric characters + /// - id: Unique identifier for the geofence in format "_k:{companyId}:{UUID}:{duration}" where duration is optional /// - longitude: Longitude coordinate of the geofence center /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters @@ -82,12 +92,4 @@ extension CLCircularRegion { func toKlaviyoGeofence() throws -> Geofence { try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) } - - var klaviyoLocationId: String? { - do { - return try toKlaviyoGeofence().locationId - } catch { - return nil - } - } } diff --git a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift new file mode 100644 index 00000000..06da73f6 --- /dev/null +++ b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift @@ -0,0 +1,99 @@ +// +// DwellTimerTracker.swift +// klaviyo-swift-sdk +// +// Created by Isobelle Lim on 1/27/25. +// + +import Foundation +import KlaviyoCore +import KlaviyoSwift +import OSLog + +/// Manages geofence dwell timer persistence and recovery. +/// +/// This tracker handles persistence of dwell timer data to UserDefaults, +/// allowing recovery of expired timers when the app terminates and relaunches. +/// The actual Timer objects are managed by KlaviyoLocationManager. +class DwellTimerTracker { + private static let dwellTimersKey = "klaviyo_dwell_timers" + + private struct DwellTimerData: Codable { + let startTime: TimeInterval + let duration: Int + let companyId: String + } + + /// Save dwell timer data to UserDefaults + /// + /// - Parameters: + /// - geofenceId: The geofence location ID + /// - startTime: The timestamp when the timer started + /// - duration: The duration of the timer in seconds + /// - companyId: The company ID associated with this geofence + func saveTimer(geofenceId: String, startTime: TimeInterval, duration: Int, companyId: String) { + var timerMap = loadTimers() + timerMap[geofenceId] = DwellTimerData(startTime: startTime, duration: duration, companyId: companyId) + + guard let data = try? JSONEncoder().encode(timerMap) else { + return + } + UserDefaults.standard.set(data, forKey: Self.dwellTimersKey) + } + + /// Remove dwell timer data from UserDefaults + /// + /// - Parameter geofenceId: The geofence location ID + func removeTimer(geofenceId: String) { + var timerMap = loadTimers() + timerMap.removeValue(forKey: geofenceId) + + guard let data = try? JSONEncoder().encode(timerMap) else { + return + } + UserDefaults.standard.set(data, forKey: Self.dwellTimersKey) + } + + /// Clear all persisted dwell timer data from UserDefaults + /// Called when geofence monitoring is stopped to prevent stale events + func clearAllTimers() { + UserDefaults.standard.removeObject(forKey: Self.dwellTimersKey) + } + + /// Load all persisted dwell timers from UserDefaults + /// + /// - Returns: Dictionary mapping geofence IDs to their timer data + private func loadTimers() -> [String: DwellTimerData] { + guard let data = UserDefaults.standard.data(forKey: Self.dwellTimersKey), + let timerMap = try? JSONDecoder().decode([String: DwellTimerData].self, from: data) else { + return [:] + } + return timerMap + } + + /// Check for expired timers, remove them from persistence, and return them + /// + /// - Returns: Array of expired timer information (geofence ID, duration, and company ID) + func getExpiredTimers() -> [(geofenceId: String, duration: Int, companyId: String)] { + let timerMap = loadTimers() + guard !timerMap.isEmpty else { return [] } + + let currentTime = environment.date().timeIntervalSince1970 + var expiredTimers: [(geofenceId: String, duration: Int, companyId: String)] = [] + + for (geofenceId, timerData) in timerMap { + // Check if timer expired (elapsed >= duration) + if currentTime - timerData.startTime >= TimeInterval(timerData.duration) { + expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration, companyId: timerData.companyId)) + // Remove expired timer from persistence + removeTimer(geofenceId: geofenceId) + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Found expired dwell timer for region \(geofenceId) (expired while app was terminated)") + } + } + } + + return expiredTimers + } +} diff --git a/Sources/KlaviyoSwift/KlaviyoInternal.swift b/Sources/KlaviyoSwift/KlaviyoInternal.swift index 59fc8dcb..b2712f8e 100644 --- a/Sources/KlaviyoSwift/KlaviyoInternal.swift +++ b/Sources/KlaviyoSwift/KlaviyoInternal.swift @@ -189,6 +189,12 @@ package enum KlaviyoInternal { profileEventCancellable = nil } + /// Clears the event buffer to ensure clean state between tests. + /// This prevents events from previous tests from being replayed in new tests. + package static func clearEventBuffer() { + eventBuffer.clear() + } + /// Enriches an event with metadata (device info, SDK info, etc.) /// - Parameter event: The event to enrich /// - Returns: A new Event with metadata appended to properties @@ -222,6 +228,28 @@ package enum KlaviyoInternal { dispatchOnMainThread(action: .enqueueEvent(event)) } + // MARK: - Geofence Event + + /// Send a geofence event to Klaviyo. + /// If the SDK is not yet initialized, it will automatically initialize using the API key extracted from the geofence. + /// If the SDK is already initialized with a different API key, the event will be ignored. + /// + /// - Parameters: + /// - apiKey: The API key (company ID) extracted from the geofence event + /// - event: The geofence event to be sent + @MainActor + package static func createGeofenceEvent(event: Event, for apiKey: String) async { + if let storedApiKey = try? await fetchAPIKey() { + guard storedApiKey == apiKey else { + return + } + dispatchOnMainThread(action: .enqueueEvent(event)) + } else { + dispatchOnMainThread(action: .initialize(apiKey)) + dispatchOnMainThread(action: .enqueueEvent(event)) + } + } + // MARK: - Deep link handling /// Handles a deep link according to the handler configured in `klaviyoSwiftEnvironment` diff --git a/Sources/KlaviyoSwift/Utilities/EventBuffer.swift b/Sources/KlaviyoSwift/Utilities/EventBuffer.swift index 5de37248..ef48dc4f 100644 --- a/Sources/KlaviyoSwift/Utilities/EventBuffer.swift +++ b/Sources/KlaviyoSwift/Utilities/EventBuffer.swift @@ -93,4 +93,13 @@ final class EventBuffer { return recentEvents } } + + /// Clears all events from the buffer. + /// This is useful for testing to ensure clean state between tests. + func clear() { + queue.async(flags: .barrier) { [weak self] in + guard let self else { return } + self.buffer.removeAll() + } + } } diff --git a/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift new file mode 100644 index 00000000..1e66ca6f --- /dev/null +++ b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift @@ -0,0 +1,266 @@ +// +// DwellTimerTrackerTests.swift +// klaviyo-swift-sdk +// +// Created by Isobelle Lim on 1/27/25. +// + +@testable import KlaviyoLocation +import Foundation +import KlaviyoCore +import KlaviyoSwift +import XCTest + +final class DwellTimerTrackerTests: XCTestCase { + var tracker: DwellTimerTracker! + var mockDate: Date! + let baseTime: TimeInterval = 1_700_000_000 // Fixed base time for testing + let testCompanyId = "test-company-id" + + override func setUp() { + super.setUp() + + // Set up mock date that we can control + mockDate = Date(timeIntervalSince1970: baseTime) + + // Set up test environment with controlled date + environment = KlaviyoEnvironment.test() + environment.date = { [weak self] in + self?.mockDate ?? Date() + } + + tracker = DwellTimerTracker() + + // Clean up any existing timer data + clearTimerData() + } + + override func tearDown() { + clearTimerData() + tracker = nil + mockDate = nil + super.tearDown() + } + + // MARK: - Helper Methods + + private func clearTimerData() { + UserDefaults.standard.removeObject(forKey: "klaviyo_dwell_timers") + } + + // MARK: - Save and Remove Tests + + func test_saveTimer_persistsTimerData() { + // GIVEN + let geofenceId = "test-geofence-1" + let startTime = baseTime + let duration = 60 + + // WHEN + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration, companyId: testCompanyId) + + // THEN - Timer should be persisted + let expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired yet") + } + + func test_removeTimer_removesPersistedData() { + // GIVEN - Save a timer + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60, companyId: testCompanyId) + + // WHEN - Remove the timer + tracker.removeTimer(geofenceId: geofenceId) + + // THEN - Timer should be gone + let expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 0, "No timers should exist after removal") + } + + func test_saveTimer_overwritesExistingTimer() { + // GIVEN - Save a timer with initial duration + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60, companyId: testCompanyId) + + // WHEN - Save again with different duration + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 120, companyId: testCompanyId) + + // THEN - Should have the new duration + // Advance time by 70 seconds (more than 60, less than 120) + mockDate = Date(timeIntervalSince1970: baseTime + 70.0) + let expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 0, "Timer with 120s duration should not be expired at 70s") + } + + // MARK: - Expired Timer Tests + + func test_getExpiredTimers_returnsExpiredTimers() { + // GIVEN - Save a timer that started 70 seconds ago with 60 second duration + let geofenceId = "test-geofence-1" + let startTime = baseTime - 70.0 + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) + + // WHEN - Check for expired timers (current time is baseTime, 70s later) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should find expired timer + XCTAssertEqual(expiredTimers.count, 1, "Should find one expired timer") + XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match geofence ID") + XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") + XCTAssertEqual(expiredTimers[0].companyId, testCompanyId, "Expired timer should have correct company ID") + } + + func test_getExpiredTimers_returnsEmptyWhenNoExpiredTimers() { + // GIVEN - Save a timer that started 30 seconds ago with 60 second duration + let geofenceId = "test-geofence-1" + let startTime = baseTime - 30.0 + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) + + // WHEN - Check for expired timers (current time is baseTime, only 30s later) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should not find expired timer + XCTAssertEqual(expiredTimers.count, 0, "Should not find expired timer when duration not met") + } + + func test_getExpiredTimers_returnsExpiredTimerAtBoundary() { + // GIVEN - Save a timer that started exactly 60 seconds ago with 60 second duration + let geofenceId = "test-geofence-1" + let startTime = baseTime - 60.0 + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) + + // WHEN - Check for expired timers (current time is baseTime, exactly 60s later) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should find expired timer (>= duration) + XCTAssertEqual(expiredTimers.count, 1, "Should find expired timer at boundary") + } + + func test_getExpiredTimers_handlesMultipleTimers() { + // GIVEN - Save multiple timers with different states + let geofence1 = "geofence-1" // Expired (started 70s ago, 60s duration) + let geofence2 = "geofence-2" // Not expired (started 30s ago, 60s duration) + let geofence3 = "geofence-3" // Expired (started 120s ago, 90s duration) + + tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) + tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 30.0, duration: 60, companyId: testCompanyId) + tracker.saveTimer(geofenceId: geofence3, startTime: baseTime - 120.0, duration: 90, companyId: testCompanyId) + + // WHEN - Check for expired timers + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should find only expired timers + XCTAssertEqual(expiredTimers.count, 2, "Should find two expired timers") + let expiredIds = Set(expiredTimers.map(\.geofenceId)) + XCTAssertTrue(expiredIds.contains(geofence1), "geofence1 should be expired") + XCTAssertTrue(expiredIds.contains(geofence3), "geofence3 should be expired") + XCTAssertFalse(expiredIds.contains(geofence2), "geofence2 should not be expired") + } + + // MARK: - Active Timer Deduplication Tests + + func test_getExpiredTimers_doesNotRemoveActiveTimersFromPersistence() { + // GIVEN - Save a timer that's not expired yet + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 30.0, duration: 60, companyId: testCompanyId) + + // WHEN - Check for expired timers (timer is not expired, so won't be returned) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should not return timer (it's not expired) + XCTAssertEqual(expiredTimers.count, 0, "Should not return timer that hasn't expired") + + // Verify timer is still in persistence (not removed because it's not expired) + let expiredTimersAfter = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimersAfter.count, 0, "Timer should still not be expired") + + // Verify timer data still exists in persistence by checking if it would be expired later + mockDate = Date(timeIntervalSince1970: baseTime + 40.0) // 70 seconds total (expired) + let expiredTimersLater = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimersLater.count, 1, "Timer should be expired after enough time passes") + } + + func test_getExpiredTimers_handlesMixOfActiveAndExpiredTimers() { + // GIVEN - Save multiple timers (both expired) + // Note: In practice, if a timer is active in memory, persistence should have been + // updated when it started. This test verifies that expired timers are returned + // regardless of activeTimerIds parameter (which is now unused but kept for API compatibility) + let geofence1 = "geofence-1" + let geofence2 = "geofence-2" + + tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) + tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) + + // WHEN - Check for expired timers + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should return all expired timers (activeTimerIds parameter is no longer used) + XCTAssertEqual(expiredTimers.count, 2, "Should find both expired timers") + let expiredIds = Set(expiredTimers.map(\.geofenceId)) + XCTAssertTrue(expiredIds.contains(geofence1), "Should return geofence1") + XCTAssertTrue(expiredIds.contains(geofence2), "Should return geofence2") + } + + // MARK: - Cleanup Tests + + func test_getExpiredTimers_removesExpiredTimersFromPersistence() { + // GIVEN - Save an expired timer + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) + + // WHEN - Check for expired timers + mockDate = Date(timeIntervalSince1970: baseTime) + _ = tracker.getExpiredTimers() + + // THEN - Timer should be removed from persistence + let expiredTimersAfter = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimersAfter.count, 0, "Expired timer should be removed from persistence") + } + + func test_getExpiredTimers_returnsEmptyWhenNoTimersExist() { + // GIVEN - No timers saved + + // WHEN - Check for expired timers + let expiredTimers = tracker.getExpiredTimers() + + // THEN - Should return empty + XCTAssertEqual(expiredTimers.count, 0, "Should return empty when no timers exist") + } + + // MARK: - Integration Tests + + func test_fullTimerLifecycle() { + // GIVEN - Save a timer + let geofenceId = "test-geofence-1" + let startTime = baseTime + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) + + // WHEN - Check immediately (should not be expired) + mockDate = Date(timeIntervalSince1970: baseTime) + var expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired immediately") + + // WHEN - Check after 30 seconds (should still not be expired) + mockDate = Date(timeIntervalSince1970: baseTime + 30.0) + expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired after 30 seconds") + + // WHEN - Check after 60 seconds (should be expired) + mockDate = Date(timeIntervalSince1970: baseTime + 60.0) + expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 1, "Timer should be expired after 60 seconds") + XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match") + XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") + XCTAssertEqual(expiredTimers[0].companyId, testCompanyId, "Expired timer should have correct company ID") + + // WHEN - Check again (should be removed) + expiredTimers = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimers.count, 0, "Expired timer should be removed after first check") + } +} diff --git a/Tests/KlaviyoLocationTests/GeofenceTests.swift b/Tests/KlaviyoLocationTests/GeofenceTests.swift index 97499107..dcac23b7 100644 --- a/Tests/KlaviyoLocationTests/GeofenceTests.swift +++ b/Tests/KlaviyoLocationTests/GeofenceTests.swift @@ -17,25 +17,67 @@ final class GeofenceTests: XCTestCase { func testGeofenceInitialization() throws { let geofence = try Geofence( - id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + + XCTAssertEqual(geofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:") + XCTAssertEqual(geofence.longitude, -122.03026995144546) + XCTAssertEqual(geofence.latitude, 37.33204742438631) + XCTAssertEqual(geofence.radius, 100.0) + XCTAssertEqual(geofence.companyId, "ABC123") + XCTAssertEqual(geofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertNil(geofence.duration) + } + + func testGeofenceInitializationWithDuration() throws { + let geofence = try Geofence( + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:300", longitude: -122.03026995144546, latitude: 37.33204742438631, radius: 100.0 ) - XCTAssertEqual(geofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(geofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:300") XCTAssertEqual(geofence.longitude, -122.03026995144546) XCTAssertEqual(geofence.latitude, 37.33204742438631) XCTAssertEqual(geofence.radius, 100.0) XCTAssertEqual(geofence.companyId, "ABC123") XCTAssertEqual(geofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(geofence.duration, 300) + } + + func testGeofenceRequiresFourComponents() throws { + // Test that geofence IDs must have exactly 4 components (3 colons) + // IDs with only 3 components should result in empty companyId/locationId + let invalidGeofence = try Geofence( + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + XCTAssertEqual(invalidGeofence.companyId, "", "Company ID should be empty for invalid format") + XCTAssertEqual(invalidGeofence.locationId, "", "Location ID should be empty for invalid format") + XCTAssertNil(invalidGeofence.duration, "Duration should be nil for invalid format") + + // Test that valid 4-component IDs work correctly + let validGeofence = try Geofence( + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + XCTAssertEqual(validGeofence.companyId, "ABC123", "Company ID should be extracted correctly") + XCTAssertEqual(validGeofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f", "Location ID should be extracted correctly") } // MARK: - Core Location Conversion Tests func testToCLCircularRegion() throws { let geofence = try Geofence( - id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:", longitude: -122.03026995144546, latitude: 37.33204742438631, radius: 100.0 @@ -43,7 +85,7 @@ final class GeofenceTests: XCTestCase { let clRegion = geofence.toCLCircularRegion() - XCTAssertEqual(clRegion.identifier, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(clRegion.identifier, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:") XCTAssertEqual(clRegion.center.longitude, -122.03026995144546) XCTAssertEqual(clRegion.center.latitude, 37.33204742438631) XCTAssertEqual(clRegion.radius, 100.0) @@ -112,17 +154,110 @@ final class GeofenceTests: XCTestCase { // Then XCTAssertEqual(geofences.count, 2) let firstGeofence = geofences.first { $0.locationId == "8db4effa-44f1-45e6-a88d-8e7d50516a0f" }! - XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:") XCTAssertEqual(firstGeofence.companyId, "ABC123") XCTAssertEqual(firstGeofence.latitude, 40.7128) XCTAssertEqual(firstGeofence.longitude, -74.006) XCTAssertEqual(firstGeofence.radius, 100) + XCTAssertNil(firstGeofence.duration, "Duration should be nil when not provided in JSON") let secondGeofence = geofences.first { $0.locationId == "a84011cf-93ef-4e78-b047-c0ce4ea258e4" }! - XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4") + XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4:") XCTAssertEqual(secondGeofence.companyId, "ABC123") XCTAssertEqual(secondGeofence.latitude, 40.6892) XCTAssertEqual(secondGeofence.longitude, -74.0445) XCTAssertEqual(secondGeofence.radius, 200) + XCTAssertNil(secondGeofence.duration, "Duration should be nil when not provided in JSON") + } + + func testGeofenceServiceParsesDurationFromJSON() async throws { + // Given + KlaviyoLocationTestUtils.setupTestEnvironment(apiKey: "ABC123") + let jsonString = """ + { + "data": [ + { + "type": "geofence", + "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", + "attributes": { + "latitude": 40.7128, + "longitude": -74.006, + "radius": 100, + "duration": 300 + } + }, + { + "type": "geofence", + "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", + "attributes": { + "latitude": 40.6892, + "longitude": -74.0445, + "radius": 200, + "duration": 60 + } + } + ] + } + """ + let testData = jsonString.data(using: .utf8)! + let geofenceService = GeofenceService() + + // When + let geofences = try await geofenceService.parseGeofences(from: testData, companyId: "ABC123") + + // Then + XCTAssertEqual(geofences.count, 2) + let firstGeofence = geofences.first { $0.locationId == "8db4effa-44f1-45e6-a88d-8e7d50516a0f" }! + XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:300") + XCTAssertEqual(firstGeofence.duration, 300) + + let secondGeofence = geofences.first { $0.locationId == "a84011cf-93ef-4e78-b047-c0ce4ea258e4" }! + XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4:60") + XCTAssertEqual(secondGeofence.duration, 60) + } + + func testGeofenceServiceHandlesMixedDurationValues() async throws { + // Given - one geofence with duration, one without + KlaviyoLocationTestUtils.setupTestEnvironment(apiKey: "ABC123") + let jsonString = """ + { + "data": [ + { + "type": "geofence", + "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", + "attributes": { + "latitude": 40.7128, + "longitude": -74.006, + "radius": 100, + "duration": 120 + } + }, + { + "type": "geofence", + "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", + "attributes": { + "latitude": 40.6892, + "longitude": -74.0445, + "radius": 200 + } + } + ] + } + """ + let testData = jsonString.data(using: .utf8)! + let geofenceService = GeofenceService() + + // When + let geofences = try await geofenceService.parseGeofences(from: testData, companyId: "ABC123") + + // Then + XCTAssertEqual(geofences.count, 2) + let firstGeofence = geofences.first { $0.locationId == "8db4effa-44f1-45e6-a88d-8e7d50516a0f" }! + XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:120") + XCTAssertEqual(firstGeofence.duration, 120) + + let secondGeofence = geofences.first { $0.locationId == "a84011cf-93ef-4e78-b047-c0ce4ea258e4" }! + XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4:") + XCTAssertNil(secondGeofence.duration) } } diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift index 5df81a66..8c56918e 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift @@ -21,6 +21,7 @@ final class KlaviyoLocationManagerTests: XCTestCase { var mockAuthorizationStatus: CLAuthorizationStatus = .authorizedAlways var mockApiKeyPublisher: PassthroughSubject! + var mockLifecycleEvents: PassthroughSubject! var cancellables: Set = [] override func setUp() { @@ -28,9 +29,13 @@ final class KlaviyoLocationManagerTests: XCTestCase { mockLocationManager = MockLocationManager() mockApiKeyPublisher = PassthroughSubject() + mockLifecycleEvents = PassthroughSubject() // Set up environment with mock authorization status BEFORE creating location manager environment = createMockEnvironment() + environment.appLifeCycle = AppLifeCycleEvents(lifeCycleEvents: { + self.mockLifecycleEvents.eraseToAnyPublisher() + }) // Set up state publisher BEFORE creating location manager let initialState = KlaviyoState(queue: []) @@ -56,8 +61,10 @@ final class KlaviyoLocationManagerTests: XCTestCase { locationManager = nil mockLocationManager = nil mockApiKeyPublisher = nil + mockLifecycleEvents = nil cancellables.removeAll() KlaviyoInternal.resetAPIKeySubject() + UserDefaults.standard.removeObject(forKey: "klaviyo_dwell_timers") super.tearDown() } @@ -137,6 +144,73 @@ final class KlaviyoLocationManagerTests: XCTestCase { XCTAssertEqual(locationManager.syncGeofencesCallCount, 2, "syncGeofences should be called once after foreground") } + + // MARK: - Dwell Timer Lifecycle Tests + + func test_foregroundEvent_callsCheckForExpiredDwellTimers() async { + // GIVEN + mockAuthorizationStatus = .authorizedAlways + await locationManager.startGeofenceMonitoring() + locationManager.reset() + + // WHEN - Send foreground event + mockLifecycleEvents.send(.foregrounded) + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // THEN - checkForExpiredDwellTimers should be called + XCTAssertGreaterThan(locationManager.checkForExpiredDwellTimersCallCount, 0, + "checkForExpiredDwellTimers should be called on foreground event") + } + + func test_backgroundEvent_callsCheckForExpiredDwellTimers() async { + // GIVEN + mockAuthorizationStatus = .authorizedAlways + await locationManager.startGeofenceMonitoring() + locationManager.reset() + + // WHEN - Send background event + mockLifecycleEvents.send(.backgrounded) + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // THEN - checkForExpiredDwellTimers should be called + XCTAssertGreaterThan(locationManager.checkForExpiredDwellTimersCallCount, 0, + "checkForExpiredDwellTimers should be called on background event") + } + + func test_foregroundEvent_processesExpiredTimers() async { + // GIVEN - Set up expired timer in persistence + let geofenceId = "test-geofence-1" + let baseTime: TimeInterval = 1_700_000_000 + let startTime = baseTime - 70.0 // Started 70 seconds ago + let duration = 60 // 60 second duration (expired) + + // Set up mock date + let mockDate = Date(timeIntervalSince1970: baseTime) + environment.date = { mockDate } + + // Save expired timer + locationManager.dwellTimerTracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration, companyId: "test-company-id") + + // Verify timer exists before + let expiredBefore = locationManager.dwellTimerTracker.getExpiredTimers() + XCTAssertEqual(expiredBefore.count, 1, "Should have one expired timer before foreground") + + mockAuthorizationStatus = .authorizedAlways + await locationManager.startGeofenceMonitoring() + locationManager.reset() + + // WHEN - Send foreground event + mockLifecycleEvents.send(.foregrounded) + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // THEN - checkForExpiredDwellTimers should be called and timer should be processed + XCTAssertGreaterThan(locationManager.checkForExpiredDwellTimersCallCount, 0, + "checkForExpiredDwellTimers should be called on foreground event") + + // Verify timer was removed after processing + let expiredAfter = locationManager.dwellTimerTracker.getExpiredTimers() + XCTAssertEqual(expiredAfter.count, 0, "Expired timer should be removed after processing") + } } // MARK: - Mock Classes @@ -172,6 +246,8 @@ private final class MockLocationManager: LocationManagerProtocol { private final class MockKlaviyoLocationManager: KlaviyoLocationManager { var syncGeofencesCallCount: Int = 0 var stopGeofenceMonitoringCallCount: Int = 0 + var checkForExpiredDwellTimersCallCount: Int = 0 + var wasSyncGeofencesCalled: Bool { syncGeofencesCallCount > 0 } @@ -195,8 +271,15 @@ private final class MockKlaviyoLocationManager: KlaviyoLocationManager { await super.stopGeofenceMonitoring() } + @MainActor + override func checkForExpiredDwellTimers() { + checkForExpiredDwellTimersCallCount += 1 + super.checkForExpiredDwellTimers() + } + func reset() { syncGeofencesCallCount = 0 stopGeofenceMonitoringCallCount = 0 + checkForExpiredDwellTimersCallCount = 0 } } diff --git a/Tests/KlaviyoSwiftTests/EventBufferTests.swift b/Tests/KlaviyoSwiftTests/EventBufferTests.swift index eb35eb5a..11674cad 100644 --- a/Tests/KlaviyoSwiftTests/EventBufferTests.swift +++ b/Tests/KlaviyoSwiftTests/EventBufferTests.swift @@ -5,7 +5,6 @@ // Created by Ajay Subramanya on 10/7/25. // -@testable import KlaviyoLocation @testable import KlaviyoSwift import Foundation import XCTest @@ -270,16 +269,4 @@ class EventBufferTests: XCTestCase { XCTAssertEqual(events.first?.metric.name.value, "$opened_push") XCTAssertEqual(events.first?.properties["message_id"] as? String, "abc123") } - - func testBufferWithLocationEvent() { - let openedPushEvent = Event(name: .locationEvent(.geofenceEnter)) - - // When - eventBuffer.buffer(openedPushEvent) - let events = eventBuffer.getRecentEvents() - - // Then - XCTAssertEqual(events.count, 1) - XCTAssertEqual(events.first?.metric.name.value, "$geofence_enter") - } } diff --git a/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift index fd55cf76..3233f55f 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift @@ -24,6 +24,8 @@ final class KlaviyoInternalTests: XCTestCase { cancellables.removeAll() KlaviyoInternal.resetAPIKeySubject() KlaviyoInternal.resetProfileDataSubject() + KlaviyoInternal.resetEventSubject() + KlaviyoInternal.clearEventBuffer() } // MARK: - Profile Data Tests @@ -709,4 +711,134 @@ final class KlaviyoInternalTests: XCTestCase { XCTAssertNotNil(properties["Device ID"], "Buffered event should include metadata") XCTAssertNotNil(properties["SDK Name"], "Buffered event should include metadata") } + + // MARK: - Geofence Event Tests + + @MainActor + func testCreateGeofenceEvent_initializesSDKAndSendsEventWhenUninitialized() async throws { + // Given: SDK is uninitialized + let initialState = KlaviyoState(queue: [], initalizationState: .uninitialized) + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create geofence event + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let apiKey = "TEST123" + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: apiKey) + try await Task.sleep(nanoseconds: 100_000_000) + + // Then: Verify SDK is initialized and event is in pending requests + let currentState = testStore.state.value + XCTAssertEqual(currentState.initalizationState, .initialized, "SDK should be initialized") + XCTAssertEqual(currentState.apiKey, apiKey, "API key should be set") + } + + @MainActor + func testCreateGeofenceEvent_flushesQueueWhenQueueHasItems() async throws { + // Given: SDK is initialized with items in the queue + var initialState = INITIALIZED_TEST_STATE() + initialState.flushing = false + + // Add some existing requests to the queue + let existingRequest1 = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) + let existingRequest2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "token1", enablement: .authorized) + initialState.queue = [existingRequest1, existingRequest2] + + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create a geofence event with matching API key + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let apiKey = initialState.apiKey! + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: apiKey) + try await Task.sleep(nanoseconds: 500_000_000) + + // Then: Verify queue is flushed (items moved to requestsInFlight and queue is empty) + XCTAssertTrue(testStore.state.value.queue.isEmpty, "Queue should be empty after a geofence event forces a flush") + } + + @MainActor + func testCreateGeofenceEvent_ignoresEventWhenAPIKeyDoesNotMatch() async throws { + // Given: SDK is initialized with a different API key + var initialState = INITIALIZED_TEST_STATE() + initialState.apiKey = "EXISTING_KEY" + initialState.flushing = false + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create a geofence event with a different API key + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let differentApiKey = "DIFFERENT_KEY" + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: differentApiKey) + try await Task.sleep(nanoseconds: 100_000_000) + + // Then: Verify SDK was not re-initialized and event was not enqueued + let currentState = testStore.state.value + XCTAssertEqual(currentState.apiKey, "EXISTING_KEY", "API key should remain unchanged") + XCTAssertEqual(currentState.pendingRequests.count, 0, "Event should not be enqueued when API key doesn't match") + XCTAssertEqual(currentState.queue.count, 0, "Queue should remain empty") + } + + @MainActor + func testCreateGeofenceEvent_enqueuesEventWhenAPIKeyMatches() async throws { + // Given: SDK is initialized with matching API key + var initialState = INITIALIZED_TEST_STATE() + initialState.apiKey = "MATCHING_KEY" + initialState.flushing = false + + // Add some existing requests to the queue + let existingRequest1 = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) + let existingRequest2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "token1", enablement: .authorized) + initialState.queue = [existingRequest1, existingRequest2] + + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create a geofence event with matching API key + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let matchingApiKey = "MATCHING_KEY" + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: matchingApiKey) + try await Task.sleep(nanoseconds: 200_000_000) + + // Then: Verify event was enqueued (should trigger flushQueue for prioritized events) + let currentState = testStore.state.value + XCTAssertEqual(currentState.apiKey, "MATCHING_KEY", "API key should remain unchanged") + XCTAssertTrue(currentState.queue.isEmpty || !currentState.requestsInFlight.isEmpty, "Event should be processed (either in queue or in flight)") + } } diff --git a/Tests/KlaviyoSwiftTests/StateManagementTests.swift b/Tests/KlaviyoSwiftTests/StateManagementTests.swift index e13873c3..8003d8ad 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementTests.swift @@ -701,4 +701,66 @@ class StateManagementTests: XCTestCase { await store.receive(.setPushEnablement(PushEnablement.authorized), timeout: TIMEOUT_NANOSECONDS) await store.receive(.setBadgeCount(0)) } + + @MainActor + func testPrioritizedEventsAreInsertedAtFrontOfQueue() async throws { + var initialState = INITIALIZED_TEST_STATE() + initialState.flushing = false + + // Add some existing requests to the queue + let existingRequest1 = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) + let existingRequest2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "token1", enablement: .authorized) + initialState.queue = [existingRequest1, existingRequest2] + + let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + + // Test geofence event is inserted at front + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + + var geofenceRequest: KlaviyoRequest? + await store.send(.enqueueEvent(geofenceEvent)) { + // Geofence event is prioritized, so it should be inserted at index 0 + geofenceRequest = try KlaviyoRequest( + endpoint: .createEvent( + XCTUnwrap($0.apiKey), + CreateEventPayload( + data: CreateEventPayload.Event( + name: geofenceEvent.metric.name.value, + properties: geofenceEvent.properties, + phoneNumber: $0.phoneNumber, + anonymousId: initialState.anonymousId!, + time: geofenceEvent.time, + pushToken: $0.pushTokenData?.pushToken + ) + ) + ) + ) + $0.queue.insert(geofenceRequest!, at: 0) + } + + var actualGeofenceRequest: KlaviyoRequest? + await store.receive(.flushQueue) { + $0.flushing = true + $0.requestsInFlight = $0.queue + $0.queue = [] + XCTAssertEqual($0.requestsInFlight.count, 3, "Should have 3 requests in flight") + actualGeofenceRequest = $0.requestsInFlight[0] + if case let .createEvent(_, payload) = actualGeofenceRequest!.endpoint { + XCTAssertEqual(payload.data.attributes.metric.data.attributes.name, "$geofence_enter", "First request in flight should be geofence event") + } else { + XCTFail("First request in flight should be geofence event") + } + XCTAssertEqual($0.requestsInFlight[1].id, existingRequest1.id, "Second request should be existing request 1") + XCTAssertEqual($0.requestsInFlight[2].id, existingRequest2.id, "Third request should be existing request 2") + } + await store.receive(.sendRequest) + await store.receive(.deQueueCompletedResults(actualGeofenceRequest!)) { + $0.requestsInFlight.removeAll { $0.id == actualGeofenceRequest!.id } + $0.retryState = .retry(1) + $0.flushing = false + } + } }