- 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
350 lines
11 KiB
Swift
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")
|
|
}
|
|
}
|