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
This commit is contained in:
349
iOS/KordantTests/AnalyticsServiceTests.swift
Normal file
349
iOS/KordantTests/AnalyticsServiceTests.swift
Normal file
@@ -0,0 +1,349 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user