import Testing @testable import Kordant import Combine // MARK: - Mock Analytics Provider /// A mock analytics provider that records all method calls for test verification. @MainActor final class MockAnalyticsProvider: AnalyticsProvider { var configureCallCount = 0 var lastConfiguredUsesIDFA: Bool? var loggedEvents: [AnalyticsEvent] = [] var loggedScreenViews: [(name: String, className: String)] = [] var flushCallCount = 0 var resetCallCount = 0 func configure(usesIDFA: Bool) { configureCallCount += 1 lastConfiguredUsesIDFA = usesIDFA } func logEvent(_ event: AnalyticsEvent) { loggedEvents.append(event) } func logScreenView(screenName: String, screenClass: String) { loggedScreenViews.append((screenName, screenClass)) } func flush() { flushCallCount += 1 } func reset() { resetCallCount += 1 configureCallCount = 0 lastConfiguredUsesIDFA = nil loggedEvents.removeAll() loggedScreenViews.removeAll() flushCallCount = 0 } } // MARK: - AnalyticsService Tests struct AnalyticsServiceTests { /// Creates an AnalyticsService with an isolated UserDefaults and mock provider. @MainActor private func makeService() -> (service: AnalyticsService, mock: MockAnalyticsProvider, att: ATTService) { let defaults = UserDefaults(suiteName: UUID().uuidString)! let att = ATTService(defaults: defaults) let service = AnalyticsService() let mock = MockAnalyticsProvider() return (service, mock, att) } // MARK: - Initial State @Test("AnalyticsService starts inactive") @MainActor func initialState() { let (service, _, _) = makeService() #expect(service.isAnalyticsActive == false) #expect(service.isFullAnalyticsActive == false) } // MARK: - Configuration @Test("AnalyticsService configure with authorized ATT enables full analytics") @MainActor func configureWithAuthorizedATT() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) #expect(service.isAnalyticsActive == true) #expect(service.isFullAnalyticsActive == true) #expect(mock.configureCallCount >= 1) #expect(mock.lastConfiguredUsesIDFA == true) } @Test("AnalyticsService configure with denied ATT enables anonymous analytics") @MainActor func configureWithDeniedATT() { let (service, mock, att) = makeService() att.trackingStatus = .denied service.configure(provider: mock) #expect(service.isAnalyticsActive == true) #expect(service.isFullAnalyticsActive == false) #expect(mock.configureCallCount >= 1) #expect(mock.lastConfiguredUsesIDFA == false) } @Test("AnalyticsService configure with restricted ATT enables anonymous analytics") @MainActor func configureWithRestrictedATT() { let (service, mock, att) = makeService() att.trackingStatus = .restricted service.configure(provider: mock) #expect(service.isAnalyticsActive == true) #expect(service.isFullAnalyticsActive == false) #expect(mock.lastConfiguredUsesIDFA == false) } @Test("AnalyticsService configure with notDetermined ATT enables anonymous analytics") @MainActor func configureWithNotDeterminedATT() { let (service, mock, _) = makeService() service.configure(provider: mock) #expect(service.isAnalyticsActive == true) #expect(service.isFullAnalyticsActive == false) #expect(mock.lastConfiguredUsesIDFA == false) } @Test("AnalyticsService configure without provider uses null provider") @MainActor func configureWithoutProvider() { let (service, _, att) = makeService() att.trackingStatus = .authorized service.configure(provider: nil) // Should not crash; events silently discarded service.logEvent(AnalyticsEvent(name: "test")) #expect(service.isAnalyticsActive == true) } @Test("AnalyticsService configure is idempotent") @MainActor func configureIsIdempotent() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.configure(provider: mock) // second call should be no-op #expect(mock.configureCallCount == 1) // only configured once } // MARK: - Logging @Test("AnalyticsService logEvent sends event to provider") @MainActor func logEvent() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) let event = AnalyticsEvent(name: "test_event", parameters: ["key": "value"], screenName: "TestScreen") service.logEvent(event) #expect(mock.loggedEvents.count == 2) // 1 for configure event + 1 for our event let loggedEvent = mock.loggedEvents.last #expect(loggedEvent?.name == "test_event") #expect(loggedEvent?.parameters["key"] == "value") } @Test("AnalyticsService logEvent is no-op before configure") @MainActor func logEventBeforeConfigure() { let (service, mock, _) = makeService() service.logEvent(AnalyticsEvent(name: "test")) #expect(mock.loggedEvents.isEmpty) } @Test("AnalyticsService logScreenView sends screen view event") @MainActor func logScreenView() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.logScreenView(screenName: "Dashboard", screenClass: "DashboardView") #expect(mock.loggedScreenViews.count == 1) #expect(mock.loggedScreenViews[0].name == "Dashboard") #expect(mock.loggedScreenViews[0].className == "DashboardView") } @Test("AnalyticsService logAction convenience method") @MainActor func logAction() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.logAction("button_tap", screen: "Settings", parameters: ["button": "save"]) let actionEvent = mock.loggedEvents.last #expect(actionEvent?.name == "user_action") #expect(actionEvent?.parameters["action"] == "button_tap") #expect(actionEvent?.parameters["screen"] == "Settings") #expect(actionEvent?.parameters["button"] == "save") } @Test("AnalyticsService logError convenience method") @MainActor func logError() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) let error = NSError(domain: "test", code: 42, userInfo: [NSLocalizedDescriptionKey: "Something failed"]) service.logError(error, context: "login") let errorEvent = mock.loggedEvents.last #expect(errorEvent?.name == "error") #expect(errorEvent?.parameters["error"] == "Something failed") #expect(errorEvent?.parameters["context"] == "login") } @Test("AnalyticsService logFeatureUsed convenience method") @MainActor func logFeatureUsed() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.logFeatureUsed("voice_print", parameters: ["duration": "30s"]) let featureEvent = mock.loggedEvents.last #expect(featureEvent?.name == "feature_used") #expect(featureEvent?.parameters["feature"] == "voice_print") #expect(featureEvent?.parameters["duration"] == "30s") } // MARK: - Reset @Test("AnalyticsService reset clears state and calls provider reset") @MainActor func reset() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.logEvent(AnalyticsEvent(name: "test")) service.reset() #expect(service.isAnalyticsActive == false) #expect(service.isFullAnalyticsActive == false) #expect(mock.resetCallCount == 1) } @Test("AnalyticsService can be re-configured after reset") @MainActor func reconfigureAfterReset() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.reset() // Re-configure service.configure(provider: mock) #expect(service.isAnalyticsActive == true) #expect(service.isFullAnalyticsActive == true) #expect(mock.configureCallCount == 1) // reset clears call counts, configure sets it again } // MARK: - Flush @Test("AnalyticsService flush delegates to provider") @MainActor func flush() { let (service, mock, att) = makeService() att.trackingStatus = .authorized service.configure(provider: mock) service.flush() #expect(mock.flushCallCount == 1) } // MARK: - ATT Status Change @Test("AnalyticsService reconfigures when ATT status changes") @MainActor func reconfiguresOnATTChange() { let (service, mock, att) = makeService() att.trackingStatus = .notDetermined service.configure(provider: mock) #expect(mock.lastConfiguredUsesIDFA == false) // Simulate ATT status changing to authorized att.trackingStatus = .authorized att.refreshStatus() // Allow publisher to fire let expectation = #expectation() Task { @MainActor in try? await Task.sleep(nanoseconds: 100_000_000) // 100ms for Combine to deliver #expect(mock.lastConfiguredUsesIDFA == true) expectation.fulfill() } await #expectation(expectation, timeout: 1.0) } } // MARK: - NullAnalyticsProvider Tests struct NullAnalyticsProviderTests { @MainActor @Test("NullAnalyticsProvider discards all events without crashing") func discardsEventsWithoutCrash() { let provider = NullAnalyticsProvider() // Should not crash provider.configure(usesIDFA: true) provider.logEvent(AnalyticsEvent(name: "test")) provider.logScreenView(screenName: "Test", screenClass: "TestView") provider.flush() provider.reset() // No state to assert — just verifying no crash #expect(true) } } // MARK: - AnalyticsEvent Tests struct AnalyticsEventTests { @Test("AnalyticsEvent initializes with defaults") func defaults() { let event = AnalyticsEvent(name: "test") #expect(event.name == "test") #expect(event.parameters.isEmpty) #expect(event.screenName == nil) } @Test("AnalyticsEvent initializes with all parameters") func fullInitialization() { let event = AnalyticsEvent( name: "screen_view", parameters: ["screen": "Dashboard"], screenName: "Dashboard" ) #expect(event.name == "screen_view") #expect(event.parameters["screen"] == "Dashboard") #expect(event.screenName == "Dashboard") } }