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:
2026-06-02 15:01:38 -04:00
parent ab0d4857db
commit e33ddf3002
49 changed files with 10472 additions and 421 deletions

View File

@@ -0,0 +1,229 @@
import Testing
@testable import Kordant
import AppTrackingTransparency
// MARK: - ATTService Tests
struct ATTServiceTests {
/// Creates an ATTService instance with an isolated UserDefaults suite for testing.
@MainActor
private func makeService() -> ATTService {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
return ATTService(defaults: defaults)
}
// MARK: - Initial State
@Test("ATTService starts with notDetermined status and no permission requested")
@MainActor
func initialState() {
let service = makeService()
#expect(service.trackingStatus == .notDetermined)
#expect(service.hasRequestedPermission == false)
#expect(service.hasShownExplanation == false)
#expect(service.analyticsMode == .anonymous)
#expect(service.shouldShowATTPrompt() == true)
#expect(service.shouldShowExplanation() == true)
}
@Test("ATTService restores persisted permission-requested state")
@MainActor
func persistedRequestState() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set(true, forKey: "kordant.att.requested")
let service = ATTService(defaults: defaults)
#expect(service.hasRequestedPermission == true)
#expect(service.shouldShowATTPrompt() == false)
}
@Test("ATTService restores persisted explanation-shown state")
@MainActor
func persistedExplanationState() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
defaults.set(true, forKey: "kordant.att.explanationShown")
let service = ATTService(defaults: defaults)
#expect(service.hasShownExplanation == true)
#expect(service.shouldShowExplanation() == false)
}
// MARK: - Authorization Status Checks
@Test("ATTService isTrackingAuthorized returns true only for .authorized")
@MainActor
func trackingAuthorized() {
let service = makeService()
#expect(service.isTrackingAuthorized() == false)
// Simulate authorized state
service.trackingStatus = .authorized
#expect(service.isTrackingAuthorized() == true)
service.trackingStatus = .denied
#expect(service.isTrackingAuthorized() == false)
}
@Test("ATTService isTrackingDenied returns true only for .denied")
@MainActor
func trackingDenied() {
let service = makeService()
service.trackingStatus = .denied
#expect(service.isTrackingDenied() == true)
service.trackingStatus = .authorized
#expect(service.isTrackingDenied() == false)
}
@Test("ATTService isTrackingRestricted returns true only for .restricted")
@MainActor
func trackingRestricted() {
let service = makeService()
service.trackingStatus = .restricted
#expect(service.isTrackingRestricted() == true)
service.trackingStatus = .authorized
#expect(service.isTrackingRestricted() == false)
}
// MARK: - Analytics Mode
@Test("ATTService analytics mode is anonymous for all non-authorized states")
@MainActor
func analyticsModeForDenied() {
let service = makeService()
service.trackingStatus = .denied
service.refreshStatus()
#expect(service.analyticsMode == .anonymous)
#expect(service.analyticsMode.usesIDFA == false)
}
@Test("ATTService analytics mode is anonymous for restricted state")
@MainActor
func analyticsModeForRestricted() {
let service = makeService()
service.trackingStatus = .restricted
service.refreshStatus()
#expect(service.analyticsMode == .anonymous)
#expect(service.analyticsMode.usesIDFA == false)
}
@Test("ATTService analytics mode is full for authorized state")
@MainActor
func analyticsModeForAuthorized() {
let service = makeService()
service.trackingStatus = .authorized
service.refreshStatus()
#expect(service.analyticsMode == .full)
#expect(service.analyticsMode.usesIDFA == true)
}
@Test("ATTService analytics mode is anonymous for notDetermined")
@MainActor
func analyticsModeForNotDetermined() {
let service = makeService()
#expect(service.analyticsMode == .anonymous)
#expect(service.analyticsMode.usesIDFA == false)
}
// MARK: - Prompt Logic
@Test("ATTService shouldShowATTPrompt returns false after permission requested")
@MainActor
func promptNotShownAfterRequest() {
let service = makeService()
#expect(service.shouldShowATTPrompt() == true)
service.hasRequestedPermission = true
#expect(service.shouldShowATTPrompt() == false)
}
@Test("ATTService shouldShowATTPrompt returns false if already authorized")
@MainActor
func promptNotShownIfAuthorized() {
let service = makeService()
service.trackingStatus = .authorized
#expect(service.shouldShowATTPrompt() == false)
}
@Test("ATTService shouldShowATTPrompt returns false if denied previously")
@MainActor
func promptNotShownIfDenied() {
let service = makeService()
service.hasRequestedPermission = true
service.trackingStatus = .denied
#expect(service.shouldShowATTPrompt() == false)
}
@Test("ATTService shouldShowExplanation returns true only before explanation shown")
@MainActor
func explanationLogic() {
let service = makeService()
#expect(service.shouldShowExplanation() == true)
service.markExplanationShown()
#expect(service.shouldShowExplanation() == false)
#expect(service.hasShownExplanation == true)
}
// MARK: - State Management
@Test("ATTService markExplanationShown persists state")
@MainActor
func markExplanationPersists() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let service = ATTService(defaults: defaults)
service.markExplanationShown()
// Create a new instance and verify persistence
let service2 = ATTService(defaults: defaults)
#expect(service2.hasShownExplanation == true)
}
@Test("ATTService refreshStatus updates tracking status")
@MainActor
func refreshStatus() {
let service = makeService()
#expect(service.trackingStatus == .notDetermined)
// Simulate status change (in a real scenario the system updates this)
service.trackingStatus = .denied
#expect(service.analyticsMode == .denied) // indirect: analyticsMode should become anonymous
}
@Test("ATTService resetPermissionState clears request flag")
@MainActor
func resetPermissionState() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let service = ATTService(defaults: defaults)
service.hasRequestedPermission = true
defaults.set(true, forKey: "kordant.att.requested")
service.resetPermissionState()
#expect(service.hasRequestedPermission == false)
}
}
// MARK: - AnalyticsMode Tests
struct AnalyticsModeTests {
@Test("AnalyticsMode usesIDFA is true only for full mode")
func usesIDFA() {
#expect(AnalyticsMode.anonymous.usesIDFA == false)
#expect(AnalyticsMode.full.usesIDFA == true)
}
@Test("AnalyticsMode raw values are correct")
func rawValues() {
#expect(AnalyticsMode.anonymous.rawValue == "anonymous")
#expect(AnalyticsMode.full.rawValue == "full")
}
}

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

View File

@@ -0,0 +1,321 @@
import Testing
@testable import Kordant
import BackgroundTasks
import SwiftUI
// MARK: - SyncStatus Tests
struct SyncStatusTests {
@Test("SyncStatus defaults are correct")
func defaults() {
let status = SyncStatus()
#expect(status.lastSuccessfulSync == nil)
#expect(status.lastSyncAttempt == nil)
#expect(status.currentSyncState == .idle)
#expect(status.syncError == nil)
#expect(status.totalBytesTransferred == 0)
#expect(status.deltaSyncSavings == 0)
#expect(status.isLowPowerMode == false)
#expect(status.isOffline == false)
}
@Test("SyncStatus lastSyncDescription shows Never when no sync")
func lastSyncDescriptionNever() {
let status = SyncStatus()
#expect(status.lastSyncDescription == "Never")
}
@Test("SyncStatus lastSyncDescription shows relative time")
func lastSyncDescriptionRelative() {
var status = SyncStatus()
status.lastSuccessfulSync = Date()
// Should show something like "now" or "0s"
#expect(!status.lastSyncDescription.isEmpty)
#expect(status.lastSyncDescription != "Never")
}
@Test("SyncStatus bytesTransferredString formats bytes")
func bytesTransferredString() {
var status = SyncStatus()
status.totalBytesTransferred = 1024
#expect(status.bytesTransferredString == "1 KB")
}
@Test("SyncStatus deltaSyncSavingsPercent is 0 when no data")
func deltaSyncSavingsZero() {
let status = SyncStatus()
#expect(status.deltaSyncSavingsPercent == 0)
}
@Test("SyncStatus deltaSyncSavingsPercent calculates correctly")
func deltaSyncSavingsCalculation() {
var status = SyncStatus()
status.totalBytesTransferred = 500
status.deltaSyncSavings = 500
// 500 / (500 + 500) * 100 = 50%
#expect(status.deltaSyncSavingsPercent == 50.0)
}
@Test("SyncStatus equality compares key fields")
func equality() {
var status1 = SyncStatus()
var status2 = SyncStatus()
status1.lastSuccessfulSync = Date()
status2.lastSuccessfulSync = status1.lastSuccessfulSync
status1.currentSyncState = .completed
status2.currentSyncState = .completed
#expect(status1 == status2)
}
}
// MARK: - SyncStatusManager Tests
@MainActor
struct SyncStatusManagerTests {
@Test("SyncStatusManager starts with idle state")
func initialState() {
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
#expect(manager.status.currentSyncState == .idle)
}
@Test("SyncStatusManager startSync updates state")
func startSync() {
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
manager.startSync(.appRefresh)
#expect(manager.status.currentSyncState == .syncing)
#expect(manager.status.lastSyncAttempt != nil)
#expect(manager.status.lastSyncOperation == .appRefresh)
#expect(manager.status.syncError == nil)
}
@Test("SyncStatusManager completeSync updates state")
func completeSync() {
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
manager.startSync(.appRefresh)
manager.completeSync(bytesTransferred: 1024, deltaSavings: 512)
#expect(manager.status.currentSyncState == .completed)
#expect(manager.status.lastSuccessfulSync != nil)
#expect(manager.status.syncError == nil)
#expect(manager.status.totalBytesTransferred == 1024)
#expect(manager.status.deltaSyncSavings == 512)
}
@Test("SyncStatusManager failSync updates state")
func failSync() {
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
manager.startSync(.appRefresh)
manager.failSync(with: "Network error")
#expect(manager.status.currentSyncState == .failed)
#expect(manager.status.syncError == "Network error")
}
@Test("SyncStatusManager setOffline updates state")
func setOffline() {
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
manager.setOffline(true)
#expect(manager.status.isOffline == true)
#expect(manager.status.currentSyncState == .offline)
manager.setOffline(false)
#expect(manager.status.isOffline == false)
#expect(manager.status.currentSyncState == .idle)
}
@Test("SyncStatusManager accumulates bytes across syncs")
func accumulateBytes() {
let manager = SyncStatusManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
manager.completeSync(bytesTransferred: 100, deltaSavings: 50)
manager.completeSync(bytesTransferred: 200, deltaSavings: 100)
#expect(manager.status.totalBytesTransferred == 300)
#expect(manager.status.deltaSyncSavings == 150)
}
@Test("SyncStatusManager persists and restores state")
func persistence() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let manager1 = SyncStatusManager(defaults: defaults)
manager1.completeSync(bytesTransferred: 500, deltaSavings: 200)
let manager2 = SyncStatusManager(defaults: defaults)
#expect(manager2.status.totalBytesTransferred == 500)
#expect(manager2.status.deltaSyncSavings == 200)
}
@Test("SyncStatusManager resets syncing state on launch")
func resetSyncingStateOnLaunch() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let manager1 = SyncStatusManager(defaults: defaults)
manager1.startSync(.appRefresh)
// Simulate crash state is syncing
let manager2 = SyncStatusManager(defaults: defaults)
// Should reset to idle
#expect(manager2.status.currentSyncState == .idle)
}
}
// MARK: - BackgroundTaskID Tests
struct BackgroundTaskIDTests {
@Test("BackgroundTaskID has correct raw values")
func rawValues() {
#expect(BackgroundTaskID.appRefresh.rawValue == "com.frenocorp.kordant.refresh")
#expect(BackgroundTaskID.darkWebScan.rawValue == "com.frenocorp.kordant.darkWebScan")
#expect(BackgroundTaskID.spamDatabaseUpdate.rawValue == "com.frenocorp.kordant.spamDatabaseUpdate")
}
}
// MARK: - BackgroundSyncService Tests
struct BackgroundSyncServiceTests {
@Test("BackgroundSyncService minimumFetchInterval is 15 minutes")
func minimumFetchInterval() {
#expect(BackgroundSyncService.minimumFetchInterval == 15 * 60)
}
@Test("BackgroundSyncService lowPowerFetchInterval is 30 minutes")
func lowPowerFetchInterval() {
#expect(BackgroundSyncService.lowPowerFetchInterval == 30 * 60)
}
@Test("DeltaSyncResult hasChanges is true when any data changed")
func deltaSyncResultHasChanges() {
let result = DeltaSyncResult(
alertsChanged: true,
exposuresChanged: false,
watchlistChanged: false,
bytesTransferred: 100,
deltaSavings: 50,
newAlerts: [],
newExposures: []
)
#expect(result.hasChanges == true)
}
@Test("DeltaSyncResult hasChanges is false when nothing changed")
func deltaSyncResultNoChanges() {
let result = DeltaSyncResult(
alertsChanged: false,
exposuresChanged: false,
watchlistChanged: false,
bytesTransferred: 0,
deltaSavings: 0,
newAlerts: [],
newExposures: []
)
#expect(result.hasChanges == false)
}
@Test("SyncOperation raw values are correct")
func syncOperationRawValues() {
#expect(SyncOperation.appRefresh.rawValue == "app_refresh")
#expect(SyncOperation.darkWebScan.rawValue == "dark_web_scan")
#expect(SyncOperation.spamDatabaseUpdate.rawValue == "spam_database_update")
#expect(SyncOperation.pushNotificationSync.rawValue == "push_notification_sync")
#expect(SyncOperation.manual.rawValue == "manual")
}
@Test("SyncState raw values are correct")
func syncStateRawValues() {
#expect(SyncState.idle.rawValue == "idle")
#expect(SyncState.syncing.rawValue == "syncing")
#expect(SyncState.completed.rawValue == "completed")
#expect(SyncState.failed.rawValue == "failed")
#expect(SyncState.offline.rawValue == "offline")
}
}
// MARK: - BackgroundTaskScheduler Tests
struct BackgroundTaskSchedulerTests {
@Test("BackgroundTaskScheduler minimumRefreshInterval is 15 minutes")
func minimumRefreshInterval() {
let scheduler = BackgroundTaskScheduler()
// We can't directly access private properties, but we can verify
// the scheduling logic works through the public API
#expect(scheduler.shouldDeferBackgroundTasks() == false)
}
@Test("BackgroundTaskScheduler should not defer when recently synced in normal mode")
func shouldNotDeferNormalMode() {
let scheduler = BackgroundTaskScheduler()
#expect(scheduler.shouldDeferBackgroundTasks() == false)
}
@Test("BackgroundTaskScheduler registerAllTasks does not crash")
func registerAllTasks() {
let scheduler = BackgroundTaskScheduler()
// Should not throw
scheduler.registerAllTasks()
}
@Test("BackgroundTaskScheduler scheduleAllTasks does not crash")
func scheduleAllTasks() {
let scheduler = BackgroundTaskScheduler()
// Should not throw
scheduler.scheduleAllTasks()
}
}
// MARK: - ETagCacheEntry Tests
struct ETagCacheEntryTests {
@Test("ETagCacheEntry is not stale when fresh")
func freshEntry() {
let entry = ETagCacheEntry(etag: "abc123", lastModified: "2024-01-01", timestamp: Date())
#expect(entry.isStale == false)
}
@Test("ETagCacheEntry is stale after 10 minutes")
func staleEntry() {
let entry = ETagCacheEntry(
etag: "abc123",
lastModified: "2024-01-01",
timestamp: Date().addingTimeInterval(-601) // Just over 10 minutes
)
#expect(entry.isStale == true)
}
@Test("ETagCacheEntry is stale at exactly 10 minutes")
func exactlyStale() {
let entry = ETagCacheEntry(
etag: "abc123",
lastModified: "2024-01-01",
timestamp: Date().addingTimeInterval(-600) // Exactly 10 minutes
)
#expect(entry.isStale == true)
}
@Test("ETagCacheEntry is Codable")
func codable() throws {
let entry = ETagCacheEntry(etag: "abc123", lastModified: "2024-01-01", timestamp: Date())
let data = try JSONEncoder().encode(entry)
let decoded = try JSONDecoder().decode(ETagCacheEntry.self, from: data)
#expect(decoded.etag == "abc123")
#expect(decoded.lastModified == "2024-01-01")
}
}
// MARK: - SyncOperation Codable Tests
struct SyncOperationCodableTests {
@Test("SyncOperation encodes and decodes correctly")
func encodeDecode() throws {
let operation = SyncOperation.appRefresh
let data = try JSONEncoder().encode(operation)
let decoded = try JSONDecoder().decode(SyncOperation.self, from: data)
#expect(decoded == .appRefresh)
}
}
// MARK: - SyncState Codable Tests
struct SyncStateCodableTests {
@Test("SyncState encodes and decodes correctly")
func encodeDecode() throws {
let state = SyncState.syncing
let data = try JSONEncoder().encode(state)
let decoded = try JSONDecoder().decode(SyncState.self, from: data)
#expect(decoded == .syncing)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
import Testing
@testable import Kordant
import SwiftUI
import OSLog
// MARK: - LaunchTimer Tests
struct LaunchTimerTests {
@Test("LaunchTimer tracks elapsed time since process start")
func elapsedSinceProcessStart() {
let timer = LaunchTimer.shared
let elapsed = timer.elapsedSinceProcessStart
#expect(elapsed >= 0)
}
@Test("LaunchTimer measures phase start and end")
func measurePhase() {
let timer = LaunchTimer.shared
let id = timer.startPhase("TestPhase")
#expect(id >= 0)
// Simulate some work
try? Task.checkCancellation()
timer.endPhase("TestPhase", signpostID: id)
let report = timer.report()
#expect(report["TestPhase_start"] != nil)
#expect(report["TestPhase_end"] != nil)
#expect(report["TestPhase_duration"] != nil)
#expect(report["total"] != nil)
}
@Test("LaunchTimer logs events")
func logEvent() {
let timer = LaunchTimer.shared
timer.logEvent("TestEvent", "test message")
let report = timer.report()
#expect(report["TestEvent"] != nil)
}
@Test("LaunchTimer report includes total time")
func reportContainsTotal() {
let timer = LaunchTimer.shared
let report = timer.report()
#expect(report["total"] != nil)
#expect(report["total"]! >= 0)
}
}
// MARK: - Launch Performance Tests
struct LaunchPerformanceTests {
/// Measures AuthService initialization time (should be fast, < 10ms)
@Test("AuthService init is fast (no blocking work)")
@MainActor
func authServiceInitTime() {
let keychain = MockKeychainService()
let apiClient = MockAuthAPIClient()
let start = Date()
let service = AuthService(keychain: keychain, apiClient: apiClient)
let elapsed = -start.timeIntervalSinceNow
// AuthService init should be nearly instantaneous since restoreSession is deferred
#expect(elapsed < 0.01, "AuthService init took \(elapsed)s (expected < 10ms)")
}
/// Measures session restoration time
@Test("AuthService restoreSession completes quickly")
@MainActor
func sessionRestoreTime() async {
let keychain = MockKeychainService()
let apiClient = MockAuthAPIClient()
let service = AuthService(keychain: keychain, apiClient: apiClient)
let start = Date()
service.restoreSession()
let elapsed = -start.timeIntervalSinceNow
// Session restore involves keychain lookups, should be fast
#expect(elapsed < 0.05, "Session restore took \(elapsed)s (expected < 50ms)")
}
/// Measures SecurityManager initialization (should be lightweight)
@Test("SecurityManager init is fast")
@MainActor
func securityManagerInitTime() {
let start = Date()
_ = SecurityManager.shared
let elapsed = -start.timeIntervalSinceNow
// SecurityManager should be lazy, init should be instant
#expect(elapsed < 0.01, "SecurityManager init took \(elapsed)s (expected < 10ms)")
}
/// Measures NetworkMonitor initialization (should be lazy)
@Test("NetworkMonitor init is fast")
func networkMonitorInitTime() {
let start = Date()
let monitor = NetworkMonitor()
let elapsed = -start.timeIntervalSinceNow
// NetworkMonitor should not start monitoring on init
#expect(elapsed < 0.01, "NetworkMonitor init took \(elapsed)s (expected < 10ms)")
monitor.stopMonitoring()
}
/// Measures ImageCacheService initialization (should be lazy)
@Test("ImageCacheService shared init is fast")
@MainActor
func imageCacheServiceInitTime() {
let start = Date()
_ = ImageCacheService.shared
let elapsed = -start.timeIntervalSinceNow
// ImageCacheService should not load metadata on init
#expect(elapsed < 0.05, "ImageCacheService init took \(elapsed)s (expected < 50ms)")
}
}
// MARK: - Lazy Loading Verification Tests
struct LazyLoadingTests {
@Test("AuthService does not restore session in init")
@MainActor
func authServiceNoRestoreOnInit() {
let keychain = MockKeychainService()
let apiClient = MockAuthAPIClient()
// Store a token in keychain
try? keychain.store(key: "jwt", value: Data("test-token".utf8))
try? keychain.store(key: "currentUser", value: try! JSONEncoder().encode(
User(id: "1", name: "Test", email: "test@test.com")
))
let service = AuthService(keychain: keychain, apiClient: apiClient)
// Session should NOT be restored in init
#expect(service.state == .unauthenticated)
#expect(service.currentUser == nil)
// After explicit restore, state should update
service.restoreSession()
#expect(service.state == .authenticated)
}
@Test("NetworkMonitor does not start monitoring on init")
func networkMonitorLazyStart() {
let monitor = NetworkMonitor()
// The monitor property should exist but monitoring should not have started
// We can't directly check the private flag, but we verify the behavior
// by checking that the default isConnected value hasn't changed
#expect(monitor.isConnected == true) // Default value, not from actual monitoring
monitor.stopMonitoring()
}
}
// MARK: - Build Configuration Tests
struct BuildConfigTests {
@Test("LaunchTimer is available in all configurations")
func launchTimerAvailable() {
let timer = LaunchTimer.shared
#expect(timer != nil)
}
@Test("Build configuration is accessible")
func buildConfig() {
#expect(ProcessInfo.processInfo.operatingSystemVersionString.count > 0)
}
}

View File

@@ -0,0 +1,530 @@
import Testing
@testable import Kordant
import AppIntents
import Foundation
// MARK: - Mock TRPCalling for Intent Tests
final class IntentMockTRPCalling: TRPCalling {
var shouldSucceed = true
var stubbedAlerts: [Alert] = []
var stubbedExposures: [Exposure] = []
var stubbedWatchlist: [WatchlistItem] = []
var stubbedSpamResult = SpamCheckResult(
phone: "+15551234567",
isSpam: true,
confidence: 0.92,
category: "telemarketer",
reportCount: 156
)
var addedWatchlistTerm: String?
var addedWatchlistType: WatchlistItemType?
var scannedExposures: [Exposure]?
var errorToThrow: Error?
init() {}
func callProcedure<T: Decodable>(path: String, input: (any Encodable)?) async throws -> T {
if let error = errorToThrow { throw error }
throw APIError.notImplemented
}
func userMe() async throws -> User { throw APIError.notImplemented }
func getSubscription() async throws -> Subscription { throw APIError.notImplemented }
func getWatchlist() async throws -> [WatchlistItem] {
if let error = errorToThrow { throw error }
return stubbedWatchlist
}
func getExposures() async throws -> [Exposure] {
if let error = errorToThrow { throw error }
return stubbedExposures
}
func getAlerts() async throws -> [Alert] {
if let error = errorToThrow { throw error }
return stubbedAlerts
}
func getVoiceEnrollments() async throws -> [VoiceEnrollment] { throw APIError.notImplemented }
func getVoiceAnalyses() async throws -> [VoiceAnalysis] { throw APIError.notImplemented }
func getSpamRules() async throws -> [SpamRule] { throw APIError.notImplemented }
func getPropertyWatchlist() async throws -> [PropertyWatchlistItem] { throw APIError.notImplemented }
func getRemovalRequests() async throws -> [RemovalRequest] { throw APIError.notImplemented }
func getBrokerListings() async throws -> [BrokerListing] { throw APIError.notImplemented }
func getNormalizedAlerts() async throws -> [NormalizedAlert] { throw APIError.notImplemented }
func getCorrelationGroups() async throws -> [CorrelationGroup] { throw APIError.notImplemented }
func getSecurityReports() async throws -> [SecurityReport] { throw APIError.notImplemented }
func addWatchlistItem(term: String, type: WatchlistItemType) async throws -> WatchlistItem {
if let error = errorToThrow { throw error }
addedWatchlistTerm = term
addedWatchlistType = type
return WatchlistItem(id: "new-1", userId: "1", term: term, type: type, status: "active", createdAt: nil)
}
func deleteWatchlistItem(id: String) async throws {
if let error = errorToThrow { throw error }
}
func scanForExposures() async throws -> [Exposure] {
if let error = errorToThrow { throw error }
return scannedExposures ?? []
}
func deleteVoiceEnrollment(id: String) async throws {
if let error = errorToThrow { throw error }
}
func createSpamRule(pattern: String, action: SpamRuleAction, priority: Int, enabled: Bool) async throws -> SpamRule {
throw APIError.notImplemented
}
func updateSpamRule(id: String, enabled: Bool) async throws -> SpamRule {
throw APIError.notImplemented
}
func deleteSpamRule(id: String) async throws {
throw APIError.notImplemented
}
func addProperty(address: String, city: String, state: String, zipCode: String) async throws -> PropertyWatchlistItem {
throw APIError.notImplemented
}
func deleteProperty(id: String) async throws {
throw APIError.notImplemented
}
func startRemoval(exposureId: String, notes: String?) async throws -> RemovalRequest {
throw APIError.notImplemented
}
func checkPhoneNumber(_ number: String) async throws -> SpamCheckResult {
if let error = errorToThrow { throw error }
return stubbedSpamResult
}
func resolveAlert(id: String) async throws { if let error = errorToThrow { throw error } }
func reportFalsePositive(id: String) async throws { if let error = errorToThrow { throw error } }
func updateNotificationPreferences(enabled: Bool) async throws { if let error = errorToThrow { throw error } }
func updateProfile(name: String, email: String) async throws -> User { throw APIError.notImplemented }
func registerDevice(token: String) async throws { if let error = errorToThrow { throw error } }
func createVoiceEnrollment(audioData: Data) async throws -> VoiceEnrollment { throw APIError.notImplemented }
// Call Recording
func analyzeCallRecording(input: AnalyzeCallRecordingInput) async throws -> CallAudioUploader.CallAnalysisResult {
throw APIError.notImplemented
}
func getCallAnalyses(page: Int, limit: Int, status: String?) async throws -> CallAnalysisListResponse {
throw APIError.notImplemented
}
func getCallAnalysis(callRecordingId: String) async throws -> CallRecord { throw APIError.notImplemented }
func getCallAnalysisSettings() async throws -> CallAnalysisSettings { throw APIError.notImplemented }
func updateCallAnalysisSettings(_ settings: CallAnalysisSettings) async throws -> CallAnalysisSettings {
throw APIError.notImplemented
}
func emergencyHangup(callRecordingId: String, phoneNumber: String) async throws -> EmergencyHangupResult {
throw APIError.notImplemented
}
// Security Events
func reportSecurityEvent(
eventType: SecurityEventType,
severity: SecuritySeverity,
indicators: [String],
violations: [String],
deviceInfo: DeviceSecurityInfo
) async throws {
if let error = errorToThrow { throw error }
}
}
// MARK: - CheckThreatScoreIntent Tests
@MainActor
struct CheckThreatScoreIntentTests {
private func makeMock(alerts: [Alert] = [], exposures: [Exposure] = []) -> IntentMockTRPCalling {
let mock = IntentMockTRPCalling()
mock.stubbedAlerts = alerts
mock.stubbedExposures = exposures
return mock
}
@Test("CheckThreatScoreIntent returns low score with no alerts")
func lowScoreNoAlerts() async throws {
// Tests the threat score calculation logic used by CheckThreatScoreIntent
let mock = makeMock(alerts: [], exposures: [])
let alerts = mock.stubbedAlerts
let exposures = mock.stubbedExposures
let unreadCritical = alerts.filter { !$0.read && $0.isCritical }.count
let newExposures = exposures.filter { $0.status == .new }.count
let score = min(Double(unreadCritical * 25 + newExposures * 10), 100)
#expect(score == 0)
#expect(alerts.isEmpty)
#expect(exposures.isEmpty)
}
@Test("CheckThreatScoreIntent calculates score with critical alerts")
func scoreWithCriticalAlerts() async throws {
let mock = makeMock(
alerts: [
Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "Critical", message: "!", read: false, createdAt: nil)
],
exposures: [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "email", exposedData: nil, severity: "high", discoveredAt: nil, status: .new)
]
)
let unreadCritical = mock.stubbedAlerts.filter { !$0.read && $0.isCritical }.count
let newExposures = mock.stubbedExposures.filter { $0.status == .new }.count
let score = min(Double(unreadCritical * 25 + newExposures * 10), 100)
#expect(unreadCritical == 1)
#expect(newExposures == 1)
#expect(score == 35)
}
@Test("CheckThreatScoreIntent caps at 100")
func scoreCappedAt100() async throws {
let mock = makeMock(
alerts: [
Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil),
Alert(id: "2", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil),
Alert(id: "3", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil),
Alert(id: "4", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil)
],
exposures: [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "email", exposedData: nil, severity: "high", discoveredAt: nil, status: .new),
Exposure(id: "2", userId: "1", source: .darkWeb, dataType: "email", exposedData: nil, severity: "high", discoveredAt: nil, status: .new)
]
)
let unreadCritical = mock.stubbedAlerts.filter { !$0.read && $0.isCritical }.count
let newExposures = mock.stubbedExposures.filter { $0.status == .new }.count
let score = min(Double(unreadCritical * 25 + newExposures * 10), 100)
#expect(score == 100)
}
@Test("CheckThreatScoreIntent intent parameter default is includeDetails")
func intentParameterDefaults() {
let _ = CheckThreatScoreIntent()
#expect(Bool(true))
}
}
// MARK: - CheckAlertsIntent Tests
@MainActor
struct CheckAlertsIntentTests {
@Test("CheckAlertsIntent returns no alerts when empty")
func noAlerts() async throws {
let mock = IntentMockTRPCalling()
mock.stubbedAlerts = []
let alerts = try await mock.getAlerts()
let unreadAlerts = alerts.filter { !$0.read }
#expect(unreadAlerts.isEmpty)
#expect(alerts.isEmpty)
}
@Test("CheckAlertsIntent returns unread alerts")
func unreadAlerts() async throws {
let mock = IntentMockTRPCalling()
mock.stubbedAlerts = [
Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "Data Breach", message: "Your data was exposed", read: false, createdAt: nil),
Alert(id: "2", userId: "1", type: .exposure, severity: .low, title: "Exposure", message: "Email found", read: false, createdAt: nil),
Alert(id: "3", userId: "1", type: .login, severity: .low, title: "Read Alert", message: "Already seen", read: true, createdAt: nil)
]
let alerts = try await mock.getAlerts()
let unreadAlerts = alerts.filter { !$0.read }
#expect(alerts.count == 3)
#expect(unreadAlerts.count == 2)
#expect(unreadAlerts[0].title == "Data Breach")
#expect(unreadAlerts[1].title == "Exposure")
}
@Test("CheckAlertsIntent only critical filter")
func onlyCriticalFilter() async throws {
let mock = IntentMockTRPCalling()
mock.stubbedAlerts = [
Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "Critical", message: "!", read: false, createdAt: nil),
Alert(id: "2", userId: "1", type: .exposure, severity: .medium, title: "Medium", message: "?", read: false, createdAt: nil),
Alert(id: "3", userId: "1", type: .exposure, severity: .low, title: "Low", message: ".", read: false, createdAt: nil),
Alert(id: "4", userId: "1", type: .breach, severity: .critical, title: "Critical 2", message: "!", read: true, createdAt: nil)
]
let criticalUnread = mock.stubbedAlerts.filter { $0.isCritical && !$0.read }
#expect(criticalUnread.count == 1)
#expect(criticalUnread[0].title == "Critical")
}
}
// MARK: - AddWatchlistItemIntent Tests
@MainActor
struct AddWatchlistItemIntentTests {
@Test("AddWatchlistItemIntent adds email to watchlist")
func addEmail() async throws {
let mock = IntentMockTRPCalling()
let term = "user@example.com"
let type = WatchlistItemType.email
let item = try await mock.addWatchlistItem(term: term, type: type)
#expect(item.term == "user@example.com")
#expect(item.type == .email)
#expect(mock.addedWatchlistTerm == term)
#expect(mock.addedWatchlistType == type)
}
@Test("AddWatchlistItemIntent adds phone to watchlist")
func addPhone() async throws {
let mock = IntentMockTRPCalling()
let term = "+15551234567"
let item = try await mock.addWatchlistItem(term: term, type: .phone)
#expect(item.term == term)
#expect(item.type == .phone)
}
@Test("AddWatchlistItemIntent adds name to watchlist")
func addName() async throws {
let mock = IntentMockTRPCalling()
let term = "John Doe"
let item = try await mock.addWatchlistItem(term: term, type: .name)
#expect(item.term == term)
#expect(item.type == .name)
}
@Test("AddWatchlistItemIntent throws on empty term")
func emptyTerm() async throws {
let trimmed = " ".trimmingCharacters(in: .whitespacesAndNewlines)
#expect(trimmed.isEmpty)
}
}
// MARK: - CheckSpamNumberIntent Tests
@MainActor
struct CheckSpamNumberIntentTests {
@Test("CheckSpamNumberIntent returns spam result")
func spamCheck() async throws {
let mock = IntentMockTRPCalling()
mock.stubbedSpamResult = SpamCheckResult(
phone: "+15551234567",
isSpam: true,
confidence: 0.92,
category: "telemarketer",
reportCount: 156
)
let result = try await mock.checkPhoneNumber("+15551234567")
#expect(result.isSpam == true)
#expect(result.phone == "+15551234567")
#expect(result.confidence == 0.92)
#expect(result.category == "telemarketer")
#expect(result.reportCount == 156)
}
@Test("CheckSpamNumberIntent returns safe for unknown number")
func safeNumber() async throws {
let mock = IntentMockTRPCalling()
mock.stubbedSpamResult = SpamCheckResult(
phone: "+15559876543",
isSpam: false,
confidence: 0.0,
category: nil,
reportCount: 0
)
let result = try await mock.checkPhoneNumber("+15559876543")
#expect(result.isSpam == false)
#expect(result.confidence == 0.0)
#expect(result.category == nil)
#expect(result.reportCount == 0)
}
@Test("CheckSpamNumberIntent phone number cleaning")
func phoneCleaning() {
let raw = " (555) 123-4567 "
let cleaned = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: CharacterSet.decimalDigits.inverted)
.joined()
#expect(cleaned == "5551234567")
#expect(!cleaned.isEmpty)
}
@Test("CheckSpamNumberIntent handles invalid phone number")
func invalidPhone() {
let raw = "abc"
let cleaned = raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: CharacterSet.decimalDigits.inverted)
.joined()
#expect(cleaned.isEmpty)
}
}
// MARK: - RunSecurityScanIntent Tests
@MainActor
struct RunSecurityScanIntentTests {
@Test("RunSecurityScanIntent returns exposures after scan")
func scanReturnsExposures() async throws {
let mock = IntentMockTRPCalling()
mock.scannedExposures = [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "email", exposedData: "test@example.com", severity: "high", discoveredAt: nil, status: .new)
]
let exposures = try await mock.scanForExposures()
#expect(exposures.count == 1)
#expect(exposures[0].dataType == "email")
}
@Test("RunSecurityScanIntent returns empty when no exposures")
func scanReturnsEmpty() async throws {
let mock = IntentMockTRPCalling()
mock.scannedExposures = []
let exposures = try await mock.scanForExposures()
#expect(exposures.isEmpty)
}
}
// MARK: - WatchlistItemTypeEnum Tests
struct WatchlistItemTypeEnumTests {
@Test("WatchlistItemTypeEnum maps to model type correctly")
func enumMapping() {
#expect(WatchlistItemTypeEnum.email.toModelType() == .email)
#expect(WatchlistItemTypeEnum.phone.toModelType() == .phone)
#expect(WatchlistItemTypeEnum.name.toModelType() == .name)
#expect(WatchlistItemTypeEnum.ssn.toModelType() == .ssn)
#expect(WatchlistItemTypeEnum.address.toModelType() == .address)
#expect(WatchlistItemTypeEnum.domain.toModelType() == .domain)
#expect(WatchlistItemTypeEnum.username.toModelType() == .username)
}
@Test("WatchlistItemTypeEnum has all expected cases")
func allCases() {
let allCases: Set<WatchlistItemTypeEnum> = [
.email, .phone, .name, .ssn, .address, .domain, .username
]
#expect(allCases.count == 7)
}
}
// MARK: - IntentDonationManager Tests
@MainActor
struct IntentDonationManagerTests {
@Test("KordantIntentDonationManager singleton")
func singleton() {
let manager1 = KordantIntentDonationManager.shared
let manager2 = KordantIntentDonationManager.shared
#expect(manager1 === manager2)
}
@Test("KordantIntentDonationManager has all 5 intents")
func allIntentsCount() {
let manager = KordantIntentDonationManager.shared
#expect(manager.allIntents.count == 5)
}
@Test("KordantIntentDonationManager all intents have titles")
func allIntentsHaveTitles() {
let manager = KordantIntentDonationManager.shared
for intent in manager.allIntents {
#expect(!intent.title.isEmpty)
#expect(!intent.description.isEmpty)
#expect(!intent.icon.isEmpty)
}
}
@Test("DonatedIntent raw values match donation keys")
func donatedIntentRawValues() {
#expect(KordantIntentDonationManager.DonatedIntent.checkThreatScore.rawValue == "checkThreatScore")
#expect(KordantIntentDonationManager.DonatedIntent.runSecurityScan.rawValue == "runSecurityScan")
#expect(KordantIntentDonationManager.DonatedIntent.checkAlerts.rawValue == "checkAlerts")
#expect(KordantIntentDonationManager.DonatedIntent.addWatchlistItem.rawValue == "addWatchlistItem")
#expect(KordantIntentDonationManager.DonatedIntent.checkSpamNumber.rawValue == "checkSpamNumber")
}
@Test("DonateOnFirstLaunch donates expected intents")
func donateOnFirstLaunch() {
let manager = KordantIntentDonationManager.shared
manager.donateOnFirstLaunch()
#expect(true)
}
@Test("DonateAfterOnboarding donates expected intents")
func donateAfterOnboarding() {
let manager = KordantIntentDonationManager.shared
manager.donateAfterOnboarding()
#expect(true)
}
}
// MARK: - App Shortcuts Provider Tests
struct AppShortcutsProviderTests {
@Test("KordantShortcutsProvider provides all 5 app shortcuts")
func providerHasAllShortcuts() {
#expect(KordantShortcutsProvider.self is AppShortcutsProvider.Type)
}
}
// MARK: - KordantIntentError Tests
struct KordantIntentErrorTests {
@Test("KordantIntentError has correct localized strings")
func localizedStrings() {
#expect(KordantIntentError.notAuthenticated.localizedStringResource == "You need to sign in to Kordant first.")
#expect(KordantIntentError.invalidInput("test").localizedStringResource == "Invalid input: test")
#expect(KordantIntentError.networkError("timeout").localizedStringResource == "Network error: timeout")
#expect(KordantIntentError.operationFailed("error").localizedStringResource == "Operation failed: error")
}
@Test("KordantIntentError conforms to Error")
func conformsToError() {
let error: any Error = KordantIntentError.notAuthenticated
#expect(error is KordantIntentError)
}
}
// MARK: - Alert Model Tests for Intents
struct AlertModelForIntentsTests {
@Test("Alert isCritical computed property works")
func isCriticalProperty() {
let critical = Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil)
let nonCritical = Alert(id: "2", userId: "1", type: .exposure, severity: .low, title: "?", message: "?", read: false, createdAt: nil)
#expect(critical.isCritical == true)
#expect(nonCritical.isCritical == false)
}
@Test("Alert severities are comparable")
func severityComparable() {
#expect(AlertSeverity.low.rawValue < AlertSeverity.critical.rawValue)
}
}

View File

@@ -0,0 +1,519 @@
//
// UnitPerformanceTests.swift
// KordantTests
//
// Unit-level performance tests using manual timing and XCTMetric
// for measuring view model operations, API serialization, cache
// operations, and cryptographic primitives with mocked data.
//
import Testing
@testable import Kordant
import Foundation
// MARK: - JSON Deserialization Performance
struct DeserializationPerformanceTests {
/// Measures the time to decode a full Alert array from JSON.
/// Baseline: < 50ms for 1000 alerts on iPhone 12.
@Test("Decode 1000 alerts under 50ms")
func decodeAlerts() throws {
let alertsJSON = generateAlertsJSON(count: 1000)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let start = Date()
let data = try #require(alertsJSON.data(using: .utf8))
let alerts = try decoder.decode([Alert].self, from: data)
let elapsed = -start.timeIntervalSinceNow
#expect(alerts.count == 1000, "Should decode 1000 alerts")
#expect(elapsed < 0.05, "Decoding 1000 alerts took \(elapsed)s (expected < 50ms)")
}
/// Measures the time to decode a full Exposure array from JSON.
/// Baseline: < 50ms for 1000 exposures on iPhone 12.
@Test("Decode 1000 exposures under 50ms")
func decodeExposures() throws {
let exposuresJSON = generateExposuresJSON(count: 1000)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let start = Date()
let data = try #require(exposuresJSON.data(using: .utf8))
let exposures = try decoder.decode([Exposure].self, from: data)
let elapsed = -start.timeIntervalSinceNow
#expect(exposures.count == 1000, "Should decode 1000 exposures")
#expect(elapsed < 0.05, "Decoding 1000 exposures took \(elapsed)s (expected < 50ms)")
}
/// Measures User JSON decoding performance.
@Test("Decode user object under 5ms")
func decodeUser() throws {
let userJSON = """
{
"id": "user-1",
"name": "Test User",
"email": "test@kordant.com",
"subscriptionTier": "premium",
"createdAt": "2026-01-15T00:00:00Z",
"updatedAt": "2026-06-01T00:00:00Z"
}
"""
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let start = Date()
let data = try #require(userJSON.data(using: .utf8))
let user = try decoder.decode(User.self, from: data)
let elapsed = -start.timeIntervalSinceNow
#expect(user.id == "user-1")
#expect(elapsed < 0.005, "User deserialization took \(elapsed)s (expected < 5ms)")
}
// MARK: - Helpers
private func generateAlertsJSON(count: Int) -> String {
let alerts = (0..<count).map { i in
"""
{
"id": "alert-\(i)",
"userId": "user-1",
"type": \(i % 3 == 0 ? "\"exposure\"" : i % 3 == 1 ? "\"breach\"" : "\"login\""),
"severity": \(i % 4 == 0 ? "\"critical\"" : i % 4 == 1 ? "\"high\"" : i % 4 == 2 ? "\"medium\"" : "\"low\""),
"title": "Alert \(i)",
"message": "This is alert number \(i)",
"read": \(i % 2 == 0),
"createdAt": "2026-06-0\(i % 9 + 1)T10:00:0\(i % 60)Z"
}
"""
}
return "[\(alerts.joined(separator: ","))]"
}
private func generateExposuresJSON(count: Int) -> String {
let exposures = (0..<count).map { i in
"""
{
"id": "exp-\(i)",
"userId": "user-1",
"source": \(i % 5 == 0 ? "\"darkWeb\"" : i % 5 == 1 ? "\"dataBreach\"" : i % 5 == 2 ? "\"socialMedia\"" : "\"publicRecord\""),
"dataType": "Email Address",
"exposedData": "user\(i)@example.com",
"severity": \(i % 3 == 0 ? "\"high\"" : "\"medium\""),
"status": \(i % 3 == 0 ? "\"new\"" : "\"reviewed\""),
"discoveredAt": "2026-06-0\(i % 9 + 1)T00:00:00Z"
}
"""
}
return "[\(exposures.joined(separator: ","))]"
}
}
// MARK: - ViewModel Performance Tests
@MainActor
struct ViewModelPerformanceTests {
/// Measures DashboardViewModel data loading time with mocked API.
/// Baseline: < 100ms for full data load with 50 alerts, 50 exposures, 50 watchlist items.
@Test("DashboardViewModel loads data under 100ms")
func dashboardViewModelLoadTime() async throws {
let mock = MockTRPCalling()
mock.stubbedAlerts = generateMockAlerts(count: 50)
mock.stubbedExposures = generateMockExposures(count: 50)
mock.stubbedWatchlist = generateMockWatchlistItems(count: 50)
let vm = DashboardViewModel(api: mock)
let start = Date()
await vm.loadDashboard()
let elapsed = -start.timeIntervalSinceNow
#expect(vm.alerts.count == 50)
#expect(vm.exposures.count == 50)
#expect(vm.watchlistItems.count == 50)
#expect(elapsed < 0.1, "DashboardViewModel load took \(elapsed)s (expected < 100ms)")
}
/// Measures DarkWatchViewModel data loading time.
/// Baseline: < 100ms for full data load.
@Test("DarkWatchViewModel loads data under 100ms")
func darkWatchViewModelLoadTime() async throws {
let mock = MockTRPCalling()
mock.stubbedWatchlist = generateMockWatchlistItems(count: 30)
mock.stubbedExposures = generateMockExposures(count: 30)
let vm = DarkWatchViewModel(api: mock)
let start = Date()
await vm.loadData()
let elapsed = -start.timeIntervalSinceNow
#expect(vm.watchlistItems.count == 30)
#expect(vm.exposures.count == 30)
#expect(elapsed < 0.1, "DarkWatchViewModel load took \(elapsed)s (expected < 100ms)")
}
/// Measures threat score calculation performance with large datasets.
/// Baseline: < 10ms for 500 alerts + 500 exposures.
@Test("Threat score calculation under 10ms with 1000 items")
func threatScoreCalculationPerformance() async throws {
let mock = MockTRPCalling()
mock.stubbedAlerts = generateMockAlerts(count: 500)
mock.stubbedExposures = generateMockExposures(count: 500)
let vm = DashboardViewModel(api: mock)
await vm.loadDashboard()
// Measure threat score computation
let start = Date()
let _ = vm.threatScore
let elapsed = -start.timeIntervalSinceNow
#expect(elapsed < 0.01, "Threat score calculation took \(elapsed)s (expected < 10ms)")
}
// MARK: - Helpers
private func generateMockAlerts(count: Int) -> [Alert] {
(0..<count).map { i in
Alert(
id: "alert-\(i)",
userId: "user-1",
type: i % 3 == 0 ? .exposure : i % 3 == 1 ? .breach : .login,
severity: i % 4 == 0 ? .critical : i % 4 == 1 ? .high : i % 4 == 2 ? .medium : .low,
title: "Alert \(i)",
message: "Alert message \(i)",
read: i % 2 == 0,
createdAt: Date().addingTimeInterval(-Double(i) * 3600)
)
}
}
private func generateMockExposures(count: Int) -> [Exposure] {
(0..<count).map { i in
Exposure(
id: "exp-\(i)",
userId: "user-1",
source: i % 5 == 0 ? .darkWeb : i % 5 == 1 ? .dataBreach : i % 5 == 2 ? .socialMedia : .publicRecord,
dataType: "Email",
exposedData: "user\(i)@example.com",
severity: i % 3 == 0 ? "high" : "medium",
status: i % 3 == 0 ? .new : .reviewed,
discoveredAt: Date().addingTimeInterval(-Double(i) * 7200)
)
}
}
private func generateMockWatchlistItems(count: Int) -> [WatchlistItem] {
(0..<count).map { i in
WatchlistItem(
id: "watch-\(i)",
userId: "user-1",
term: "item\(i)@example.com",
type: .email,
status: "active",
createdAt: Date().addingTimeInterval(-Double(i) * 86400)
)
}
}
}
// MARK: - Keychain Performance Tests
struct KeychainPerformanceTests {
/// Measures mock keychain store/retrieve performance.
/// Baseline: < 0.1ms per operation.
@Test("Keychain store/retrieve under 0.1ms per operation")
func keychainStoreRetrievePerformance() throws {
let keychain = MockKeychainService()
let value = Data(repeating: 0x41, count: 1024) // 1KB value
let start = Date()
let count = 100
for i in 0..<count {
try keychain.store(key: "key-\(i)", value: value)
let _ = try keychain.retrieve(key: "key-\(i)")
}
let elapsed = -start.timeIntervalSinceNow
let perOp = elapsed / Double(count * 2) // store + retrieve per iteration
#expect(perOp < 0.0001, "Keychain op took \(perOp * 1000)ms (expected < 0.1ms)")
}
}
// MARK: - Security Manager Performance Tests
struct SecurityPerformanceTests {
/// Measures jailbreak detector checks runtime.
/// Baseline: < 10ms for full detection suite.
@Test("Jailbreak detection completes under 10ms")
func jailbreakDetectionPerformance() {
let detector = JailbreakDetector.shared
let start = Date()
let _ = detector.isJailbroken
let elapsed = -start.timeIntervalSinceNow
// The check should be fast since it's checking file system paths
#expect(elapsed < 0.01, "Jailbreak detection took \(elapsed)s (expected < 10ms)")
}
/// Measures RuntimeIntegrityMonitor checks performance.
@Test("Runtime integrity check under 10ms")
func runtimeIntegrityPerformance() {
let monitor = RuntimeIntegrityMonitor.shared
let start = Date()
let _ = monitor.isRuntimeIntegrityCompromised
let elapsed = -start.timeIntervalSinceNow
#expect(elapsed < 0.01, "Runtime integrity check took \(elapsed)s (expected < 10ms)")
}
/// Measures SecureEnclave availability check performance.
@Test("SecureEnclave availability check under 10ms")
func secureEnclaveCheckPerformance() {
let service = SecureEnclaveService.shared
let start = Date()
let _ = service.isAvailable
let elapsed = -start.timeIntervalSinceNow
#expect(elapsed < 0.01, "Secure Enclave check took \(elapsed)s (expected < 10ms)")
}
}
// MARK: - Image Cache Metadata Performance Tests
struct ImageCacheMetadataPerformanceTests {
/// Measures ImageCacheMetadata creation performance for bulk operations.
@Test("ImageCacheMetadata creation under 0.1ms per item")
func metadataCreationPerformance() {
let start = Date()
let count = 1000
for i in 0..<count {
_ = ImageCacheMetadata(
url: "https://images.kordant.com/photo-\(i).jpg",
contentType: "image/jpeg",
fileSize: 1024 * 50,
cachedAt: Date(),
expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60)
)
}
let elapsed = -start.timeIntervalSinceNow
let perItem = elapsed / Double(count)
#expect(perItem < 0.0001, "Metadata creation took \(perItem * 1000)ms (expected < 0.1ms)")
}
/// Measures CacheStats value type creation performance.
@Test("CacheStats values created efficiently")
func cacheStatsCreationPerformance() {
let start = Date()
let count = 10000
for _ in 0..<count {
_ = CacheStats(
memoryUsage: 1024 * 1024,
memoryCapacity: 50 * 1024 * 1024,
diskUsage: 5 * 1024 * 1024,
diskCapacity: 100 * 1024 * 1024,
cachedEntries: 100,
diskFiles: 50
)
}
let elapsed = -start.timeIntervalSinceNow
let perItem = elapsed / Double(count)
#expect(perItem < 0.00001, "CacheStats creation took \(perItem * 1000)ms (expected < 0.01ms)")
}
}
// MARK: - Sort Performance Tests
struct SortPerformanceTests {
/// Measures alert sorting performance (alerts are sorted by createdAt).
/// This is called every time the dashboard loads.
@Test("Alert sorting under 5ms for 500 alerts")
func alertSortingPerformance() {
let alerts = (0..<500).map { i in
Alert(
id: "alert-\(i)",
userId: "user-1",
type: .exposure,
severity: .medium,
title: "Alert",
message: "Message",
read: i % 2 == 0,
createdAt: Date().addingTimeInterval(Double(i) * 3600 - 500 * 3600)
)
}
let start = Date()
let sorted = alerts.sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) }
let elapsed = -start.timeIntervalSinceNow
#expect(sorted.count == 500)
#expect(elapsed < 0.005, "Sorting 500 alerts took \(elapsed)s (expected < 5ms)")
}
}
// MARK: - XCTMetric-Based Performance Tests (XCTestCase)
/// XCTestCase-based performance tests using XCTMetric for baseline recording.
/// These tests use `measure(metrics:)` which supports automatic baseline comparison
/// and 10% regression detection in Xcode.
///
/// Note: XCTMetric requires XCTestCase, not the Testing framework.
/// These tests are run via the KordantTests target alongside Testing-based tests.
final class XCTMetricPerformanceTests: XCTestCase {
private static let encoder: JSONEncoder = {
let enc = JSONEncoder()
enc.dateEncodingStrategy = .iso8601
return enc
}()
private static let decoder: JSONDecoder = {
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
return dec
}()
// MARK: - JSON Encoding Performance
/// Measures JSON encoder performance for encoding Alert objects.
/// XCTMetric captures clock, CPU, and memory.
func testJSONEncodingPerformance() throws {
let alerts = (0..<500).map { i in
Alert(
id: "alert-\(i)",
userId: "user-1",
type: .exposure,
severity: .medium,
title: "Test Alert \(i)",
message: "This is a test alert message with some detail text that might appear in the actual app.",
read: i % 2 == 0,
createdAt: Date()
)
}
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
let encoder = Self.encoder
for alert in alerts {
_ = try? encoder.encode(alert)
}
}
}
// MARK: - JSON Decoding Performance
/// Measures JSON decoder performance for decoding Alert array.
func testJSONDecodingPerformance() throws {
let alerts = (0..<500).map { i in
Alert(
id: "alert-\(i)",
userId: "user-1",
type: .exposure,
severity: .medium,
title: "Test Alert \(i)",
message: "Test message",
read: i % 2 == 0,
createdAt: Date()
)
}
let data = try Self.encoder.encode(alerts)
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
_ = try? Self.decoder.decode([Alert].self, from: data)
}
}
// MARK: - ViewModel Data Processing Performance
/// Measures threat score calculation performance with XCTMetric.
func testThreatScoreCalculationPerformance() throws {
let alerts = (0..<200).map { i in
Alert(
id: "alert-\(i)",
userId: "user-1",
type: i % 3 == 0 ? .breach : .exposure,
severity: i % 5 == 0 ? .critical : .low,
title: "Alert",
message: "Message",
read: i % 2 == 0,
createdAt: Date().addingTimeInterval(-Double(i) * 3600)
)
}
let exposures = (0..<100).map { i in
Exposure(
id: "exp-\(i)",
userId: "user-1",
source: .darkWeb,
dataType: "Email",
exposedData: "test@example.com",
severity: i % 3 == 0 ? "high" : "medium",
status: i % 2 == 0 ? .new : .reviewed,
discoveredAt: Date()
)
}
measure(metrics: [XCTClockMetric(), XCTCPUMetric(), XCTMemoryMetric()]) {
let unreadCritical = alerts.filter { !$0.read && $0.isCritical }.count
let newExposures = exposures.filter { $0.status == .new }.count
let score = min(Double(unreadCritical * 25 + newExposures * 10), 100)
_ = score / 100.0
}
}
// MARK: - Image Cache Metadata Persistence Performance
/// Measures metadata JSON persistence encoding/decoding performance.
func testImageCacheMetadataPersistencePerformance() throws {
let metadata = (0..<500).map { i in
(
key: "https://images.kordant.com/photo-\(i).jpg",
value: ImageCacheMetadata(
url: "https://images.kordant.com/photo-\(i).jpg",
contentType: "image/jpeg",
fileSize: 1024 * (50 + i % 100),
cachedAt: Date(),
expirationDate: Date().addingTimeInterval(7 * 24 * 60 * 60)
)
)
}
let dict = Dictionary(uniqueKeysWithValues: metadata)
measure(metrics: [XCTClockMetric()]) {
if let encoded = try? Self.encoder.encode(dict) {
_ = try? Self.decoder.decode([String: ImageCacheMetadata].self, from: encoded)
}
}
}
// MARK: - Alert Sorting Performance
/// Measures the sorting performance of alerts (used by DashboardViewModel).
func testAlertSortingPerformance() throws {
let alerts = (0..<500).map { i in
Alert(
id: "alert-\(i)",
userId: "user-1",
type: .exposure,
severity: .medium,
title: "Alert",
message: "Message",
read: i % 2 == 0,
createdAt: Date().addingTimeInterval(Double(i) * 3600 - 500 * 3600)
)
}
measure(metrics: [XCTClockMetric()]) {
let sorted = alerts.sorted { ($0.createdAt ?? .distantPast) > ($1.createdAt ?? .distantPast) }
_ = sorted
}
}
}

View File

@@ -0,0 +1,229 @@
import XCTest
@testable import Kordant
final class WidgetDataTests: XCTestCase {
// MARK: - Encoding / Decoding
func testWidgetDataEncodingDecoding() throws {
let original = WidgetData(
threatScore: 0.42,
recentAlerts: [
WidgetAlert(
id: "test-1",
title: "Test Alert",
message: "Test message",
severity: "high",
type: "breach",
createdAt: Date()
)
],
alertCount: 1,
unreadCount: 1,
criticalCount: 0,
exposureCount: 2,
lastUpdated: Date()
)
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(WidgetData.self, from: encoded)
XCTAssertEqual(original, decoded)
XCTAssertEqual(decoded.threatScore, 0.42)
XCTAssertEqual(decoded.alertCount, 1)
XCTAssertEqual(decoded.unreadCount, 1)
XCTAssertEqual(decoded.criticalCount, 0)
XCTAssertEqual(decoded.exposureCount, 2)
XCTAssertEqual(decoded.recentAlerts.count, 1)
XCTAssertEqual(decoded.recentAlerts[0].id, "test-1")
XCTAssertEqual(decoded.recentAlerts[0].title, "Test Alert")
XCTAssertEqual(decoded.recentAlerts[0].severity, "high")
}
// MARK: - Threat Level
func testThreatLevelLow() {
let data = WidgetData(
threatScore: 0.15,
recentAlerts: [],
alertCount: 0,
unreadCount: 0,
criticalCount: 0,
exposureCount: 0,
lastUpdated: Date()
)
XCTAssertEqual(data.threatLevel, .low)
}
func testThreatLevelMedium() {
let data = WidgetData(
threatScore: 0.25,
recentAlerts: [],
alertCount: 0,
unreadCount: 0,
criticalCount: 0,
exposureCount: 0,
lastUpdated: Date()
)
XCTAssertEqual(data.threatLevel, .medium)
}
func testThreatLevelHigh() {
let data = WidgetData(
threatScore: 0.45,
recentAlerts: [],
alertCount: 0,
unreadCount: 0,
criticalCount: 0,
exposureCount: 0,
lastUpdated: Date()
)
XCTAssertEqual(data.threatLevel, .high)
}
func testThreatLevelCritical() {
let data = WidgetData(
threatScore: 0.72,
recentAlerts: [],
alertCount: 0,
unreadCount: 0,
criticalCount: 0,
exposureCount: 0,
lastUpdated: Date()
)
XCTAssertEqual(data.threatLevel, .critical)
}
func testThreatLevelBoundaries() {
// Exactly 0.2 is medium
let mediumLow = WidgetData(threatScore: 0.2, recentAlerts: [], alertCount: 0, unreadCount: 0, criticalCount: 0, exposureCount: 0, lastUpdated: Date())
XCTAssertEqual(mediumLow.threatLevel, .medium)
// Exactly 0.4 is high
let highLow = WidgetData(threatScore: 0.4, recentAlerts: [], alertCount: 0, unreadCount: 0, criticalCount: 0, exposureCount: 0, lastUpdated: Date())
XCTAssertEqual(highLow.threatLevel, .high)
// Exactly 0.7 is critical
let criticalLow = WidgetData(threatScore: 0.7, recentAlerts: [], alertCount: 0, unreadCount: 0, criticalCount: 0, exposureCount: 0, lastUpdated: Date())
XCTAssertEqual(criticalLow.threatLevel, .critical)
}
// MARK: - Threat Percentage
func testThreatPercentage() {
let data = WidgetData(threatScore: 0.35, recentAlerts: [], alertCount: 0, unreadCount: 0, criticalCount: 0, exposureCount: 0, lastUpdated: Date())
XCTAssertEqual(data.threatPercentage, 35)
let data2 = WidgetData(threatScore: 1.0, recentAlerts: [], alertCount: 0, unreadCount: 0, criticalCount: 0, exposureCount: 0, lastUpdated: Date())
XCTAssertEqual(data2.threatPercentage, 100)
let data3 = WidgetData(threatScore: 0.0, recentAlerts: [], alertCount: 0, unreadCount: 0, criticalCount: 0, exposureCount: 0, lastUpdated: Date())
XCTAssertEqual(data3.threatPercentage, 0)
}
// MARK: - WidgetAlert
func testWidgetAlertSeverityEnum() {
let critical = WidgetAlert(id: "1", title: "Test", message: "Msg", severity: "critical", type: "breach", createdAt: nil)
XCTAssertEqual(critical.severityEnum, .critical)
let high = WidgetAlert(id: "2", title: "Test", message: "Msg", severity: "high", type: "exposure", createdAt: nil)
XCTAssertEqual(high.severityEnum, .high)
let medium = WidgetAlert(id: "3", title: "Test", message: "Msg", severity: "medium", type: "voiceMatch", createdAt: nil)
XCTAssertEqual(medium.severityEnum, .medium)
let low = WidgetAlert(id: "4", title: "Test", message: "Msg", severity: "low", type: "removal", createdAt: nil)
XCTAssertEqual(low.severityEnum, .low)
// Unknown severity defaults to low
let unknown = WidgetAlert(id: "5", title: "Test", message: "Msg", severity: "unknown", type: "login", createdAt: nil)
XCTAssertEqual(unknown.severityEnum, .low)
}
func testWidgetAlertTypeEnum() {
let breach = WidgetAlert(id: "1", title: "Test", message: "Msg", severity: "low", type: "breach", createdAt: nil)
XCTAssertEqual(breach.typeEnum, .breach)
let exposure = WidgetAlert(id: "2", title: "Test", message: "Msg", severity: "low", type: "exposure", createdAt: nil)
XCTAssertEqual(exposure.typeEnum, .exposure)
}
func testWidgetAlertDeepLink() {
let alert = WidgetAlert(id: "abc-123", title: "Test", message: "Msg", severity: "high", type: "breach", createdAt: nil)
let url = alert.deepLink
XCTAssertEqual(url.absoluteString, "kordant://alerts/abc-123")
}
// MARK: - Placeholder & Unavailable
func testPlaceholderData() {
let placeholder = WidgetData.placeholder
XCTAssertEqual(placeholder.threatScore, 0.25)
XCTAssertEqual(placeholder.alertCount, 5)
XCTAssertEqual(placeholder.recentAlerts.count, 3)
}
func testUnavailableData() {
let unavailable = WidgetData.unavailable
XCTAssertEqual(unavailable.threatScore, 0)
XCTAssertEqual(unavailable.alertCount, 0)
XCTAssertEqual(unavailable.recentAlerts.count, 0)
}
func testPlaceholderAlerts() {
let alerts = WidgetAlert.placeholders
XCTAssertEqual(alerts.count, 3)
XCTAssertEqual(alerts[0].title, "Data Breach Detected")
XCTAssertEqual(alerts[0].severity, "critical")
XCTAssertEqual(alerts[1].severity, "high")
XCTAssertEqual(alerts[2].severity, "medium")
}
// MARK: - Severity Filter
func testSeverityFilterAll() {
let filter = AlertSeverityFilter.all
XCTAssertTrue(filter.matches(severity: "low"))
XCTAssertTrue(filter.matches(severity: "medium"))
XCTAssertTrue(filter.matches(severity: "high"))
XCTAssertTrue(filter.matches(severity: "critical"))
}
func testSeverityFilterCritical() {
let filter = AlertSeverityFilter.critical
XCTAssertFalse(filter.matches(severity: "low"))
XCTAssertFalse(filter.matches(severity: "medium"))
XCTAssertFalse(filter.matches(severity: "high"))
XCTAssertTrue(filter.matches(severity: "critical"))
}
func testSeverityFilterHigh() {
let filter = AlertSeverityFilter.high
XCTAssertFalse(filter.matches(severity: "low"))
XCTAssertFalse(filter.matches(severity: "medium"))
XCTAssertTrue(filter.matches(severity: "high"))
XCTAssertTrue(filter.matches(severity: "critical"))
}
// MARK: - Data Manager (integration)
func testWidgetDataManagerSaveAndLoad() {
let manager = WidgetDataManager.shared
let original = WidgetData.placeholder
// Clear any existing data
manager.clear()
XCTAssertNil(manager.load())
// Save and reload
manager.save(original)
let loaded = manager.load()
XCTAssertNotNil(loaded)
XCTAssertEqual(loaded, original)
XCTAssertEqual(loaded?.threatScore, 0.25)
// Clean up
manager.clear()
XCTAssertNil(manager.load())
}
}