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

520 lines
18 KiB
Swift

//
// 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
}
}
}