Files
Kordant/iOS/KordantTests/AnalyticsServiceTests.swift
Michael Freno e33ddf3002 feat: complete Tasks 21-28 — backend integration, security hardening, UI tests & CI
- Add Apple Sign-In backend (JWKS verification, account linking, session management)
- Implement push notification deep linking with NotificationDeepLinkRouter
- Add jailbreak detection, runtime integrity monitoring, secure enclave service
- Implement OAuth social login, token refresh, and secure logout flows
- Add image caching (memory/disk), optimizer, upload queue, async semaphore
- Implement notification analytics, type preferences, and category setup
- Expand UI test suite with UITestBase, accessibility, auth flow, performance tests
- Add CI pipeline for iOS UI tests (3 device sizes) and performance benchmarks
- Restructure Xcode project to manual groups with KordantWidgets target
- Add SwiftLint, Swift Collections/Algorithms/GoogleSignIn dependencies
- Update project.yml for XcodeGen with new targets and configurations
2026-06-02 15:01:38 -04:00

350 lines
11 KiB
Swift

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")
}
}