Files
Kordant/iOS/KordantTests/KordantTests.swift
Michael Freno 1511a844a7 feat(ios): implement offline mode & sync conflict resolution (#23)
- Add OfflineSyncCoordinator for managing offline/online transitions
- Add OfflineSyncIndicatorView for UI feedback during sync
- Add SyncProgress tracking with stage descriptions and progress bars
- Add delta sync support with savings tracking
- Add BackgroundTaskScheduler interval configs for low-power mode
- Add isProcessingTask discriminator to BackgroundTaskID
- Add DeltaFetchResult generic type for efficient data fetching
- Add SyncProgressStage enum with localized descriptions
- Add progress reset on app launch to prevent stale state
- Add delta sync savings percentage calculation
- Update BackgroundSyncTests with comprehensive coverage
- Add OfflineSyncTests for offline queue and conflict resolution
- Mark task 22 (Token Refresh) and task 28 (Review Compliance) as done
- Update Xcode project with new source files and build phases
2026-06-02 17:00:17 -04:00

4245 lines
145 KiB
Swift

import Testing
@testable import Kordant
import SwiftUI
// MARK: - Mocks
final class MockKeychainService: KeychainServiceProtocol {
private var storage: [String: Data] = [:]
func store(key: String, value: Data) throws {
storage[key] = value
}
func retrieve(key: String) throws -> Data? {
storage[key]
}
func delete(key: String) throws {
storage.removeValue(forKey: key)
}
func clearAll() throws {
storage.removeAll()
}
}
final class MockAuthAPIClient: AuthAPIClientProtocol {
var shouldSucceed = true
var lastAppleIdentityToken: String?
var lastAppleAuthorizationCode: String?
var lastAppleUserIdentifier: String?
var lastGoogleIdToken: String?
var lastRefreshToken: String?
var didCallLogout = false
func login(email: String, password: String) async throws -> AuthTokenResponse {
if shouldSucceed {
return AuthTokenResponse(
accessToken: "mock-token",
refreshToken: "mock-refresh",
user: User(id: "1", name: "Test", email: email)
)
}
throw APIError.unauthorized
}
func signup(name: String, email: String, password: String) async throws -> AuthTokenResponse {
if shouldSucceed {
return AuthTokenResponse(
accessToken: "mock-token",
refreshToken: "mock-refresh",
user: User(id: "1", name: name, email: email)
)
}
throw APIError.serverError(statusCode: 409)
}
func resetPassword(email: String) async throws {
if !shouldSucceed {
throw APIError.notFound
}
}
// MARK: - OAuth
func loginWithApple(
identityToken: String,
authorizationCode: String,
userIdentifier: String
) async throws -> AuthTokenResponse {
lastAppleIdentityToken = identityToken
lastAppleAuthorizationCode = authorizationCode
lastAppleUserIdentifier = userIdentifier
if shouldSucceed {
return AuthTokenResponse(
accessToken: "mock-token",
refreshToken: "apple-refresh-token",
user: User(id: "apple-user-1", name: "Apple User", email: "apple@privaterelay.appleid.com")
)
}
throw APIError.tRPCError(code: 401, message: "Invalid Apple identity token")
}
func loginWithGoogle(idToken: String) async throws -> AuthTokenResponse {
lastGoogleIdToken = idToken
if shouldSucceed {
return AuthTokenResponse(
accessToken: "mock-token",
refreshToken: "google-refresh-token",
user: User(id: "google-user-1", name: "Google User", email: "google@gmail.com")
)
}
throw APIError.tRPCError(code: 401, message: "Invalid Google ID token")
}
func refreshToken(refreshToken: String) async throws -> AuthTokenResponse {
lastRefreshToken = refreshToken
if shouldSucceed {
return AuthTokenResponse(
accessToken: "mock-token",
refreshToken: "new-refresh-token",
user: User(id: "", name: "", email: "")
)
}
throw APIError.unauthorized
}
func logout() async throws {
didCallLogout = true
if !shouldSucceed {
throw APIError.serverError(statusCode: 500)
}
}
}
// MARK: - Password Strength Tests
struct PasswordStrengthTests {
@Test("Short password with no criteria is weak")
func shortPassword() {
#expect(PasswordStrengthCalculator.strength(of: "abc") == .weak)
}
@Test("Password meeting one criterion is weak")
func oneCriterion() {
#expect(PasswordStrengthCalculator.strength(of: "abcdefgh") == .weak)
}
@Test("Password with length and uppercase is fair")
func lengthAndUppercase() {
#expect(PasswordStrengthCalculator.strength(of: "Abcdefgh") == .fair)
}
@Test("Password with length, uppercase, and digit is good")
func threeCriteria() {
#expect(PasswordStrengthCalculator.strength(of: "Abcdefg1") == .good)
}
@Test("Password meeting all criteria is strong")
func allCriteria() {
#expect(PasswordStrengthCalculator.strength(of: "Abcdefg1!") == .strong)
}
@Test("Empty password is weak")
func emptyPassword() {
#expect(PasswordStrengthCalculator.strength(of: "") == .weak)
}
}
// MARK: - Keychain Service Tests
struct KeychainServiceTests {
@Test("MockKeychainService stores and retrieves data")
func storeAndRetrieve() throws {
let keychain = MockKeychainService()
try keychain.store(key: "test", value: Data("hello".utf8))
let result = try keychain.retrieve(key: "test")
#expect(result == Data("hello".utf8))
}
@Test("MockKeychainService returns nil for missing key")
func missingKey() throws {
let keychain = MockKeychainService()
let result = try keychain.retrieve(key: "nonexistent")
#expect(result == nil)
}
@Test("MockKeychainService overwrites existing value")
func overwriteValue() throws {
let keychain = MockKeychainService()
try keychain.store(key: "key", value: Data("first".utf8))
try keychain.store(key: "key", value: Data("second".utf8))
let result = try keychain.retrieve(key: "key")
#expect(result == Data("second".utf8))
}
@Test("MockKeychainService deletes value")
func deleteValue() throws {
let keychain = MockKeychainService()
try keychain.store(key: "key", value: Data("value".utf8))
try keychain.delete(key: "key")
let result = try keychain.retrieve(key: "key")
#expect(result == nil)
}
@Test("MockKeychainService clears all values")
func clearAll() throws {
let keychain = MockKeychainService()
try keychain.store(key: "a", value: Data("1".utf8))
try keychain.store(key: "b", value: Data("2".utf8))
try keychain.clearAll()
#expect(try keychain.retrieve(key: "a") == nil)
#expect(try keychain.retrieve(key: "b") == nil)
}
}
// MARK: - Auth Service Tests
struct AuthServiceTests {
@MainActor
private func makeService(apiClient: MockAuthAPIClient = MockAuthAPIClient()) -> AuthService {
AuthService(
keychain: MockKeychainService(),
apiClient: apiClient
)
}
@Test("AuthService starts unauthenticated")
@MainActor
func initialState() {
let service = makeService()
#expect(service.state == .unauthenticated)
#expect(service.currentUser == nil)
#expect(!service.isBiometricEnabled)
#expect(!service.hasCompletedOnboarding)
}
@Test("AuthService login succeeds and updates state")
@MainActor
func loginSuccess() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = makeService(apiClient: client)
await service.login(email: "test@example.com", password: "password123")
#expect(service.state == .authenticated)
#expect(service.currentUser?.email == "test@example.com")
#expect(service.currentUser?.name == "Test")
}
@Test("AuthService login failure sets error")
@MainActor
func loginFailure() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let service = makeService(apiClient: client)
await service.login(email: "test@example.com", password: "wrong")
#expect(service.state == .unauthenticated)
#expect(service.error != nil)
}
@Test("AuthService signup succeeds and updates state")
@MainActor
func signupSuccess() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = makeService(apiClient: client)
await service.signup(name: "Test User", email: "test@example.com", password: "password123")
#expect(service.state == .authenticated)
#expect(service.currentUser?.name == "Test User")
#expect(service.currentUser?.email == "test@example.com")
}
@Test("AuthService logout clears all state")
@MainActor
func logout() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = makeService(apiClient: client)
await service.login(email: "test@example.com", password: "password123")
service.completeOnboarding()
service.enableBiometric()
service.logout()
#expect(service.state == .unauthenticated)
#expect(service.currentUser == nil)
#expect(!service.isBiometricEnabled)
#expect(!service.hasCompletedOnboarding)
}
@Test("AuthService shows biometric prompt after first login")
@MainActor
func biometricPromptAfterLogin() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = makeService(apiClient: client)
await service.login(email: "test@example.com", password: "password123")
#expect(service.showBiometricPrompt)
}
@Test("AuthService does not show biometric prompt if already enabled")
@MainActor
func noBiometricPromptIfEnabled() async {
let keychain = MockKeychainService()
try? keychain.store(key: "useBiometric", value: Data("token".utf8))
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = AuthService(keychain: keychain, apiClient: client)
await service.login(email: "test@example.com", password: "password123")
#expect(!service.showBiometricPrompt)
}
@Test("AuthService enabling biometric sets flag and hides prompt")
@MainActor
func enableBiometric() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = makeService(apiClient: client)
await service.login(email: "test@example.com", password: "password123")
service.enableBiometric()
#expect(service.isBiometricEnabled)
#expect(!service.showBiometricPrompt)
}
@Test("AuthService onboarding state persists")
@MainActor
func onboardingCompletion() {
let service = makeService()
#expect(!service.hasCompletedOnboarding)
service.completeOnboarding()
#expect(service.hasCompletedOnboarding)
}
@Test("AuthService resetPassword succeeds without error")
@MainActor
func resetPasswordSuccess() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = makeService(apiClient: client)
await service.resetPassword(email: "test@example.com")
#expect(service.error == nil)
}
@Test("AuthService resetPassword failure sets error")
@MainActor
func resetPasswordFailure() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let service = makeService(apiClient: client)
await service.resetPassword(email: "test@example.com")
#expect(service.error != nil)
}
}
// MARK: - ThemeManager Tests
struct ThemeManagerTests {
@MainActor
private func makeManager() -> ThemeManager {
ThemeManager(defaults: UserDefaults(suiteName: UUID().uuidString)!)
}
@Test("ThemeManager starts with system mode by default")
@MainActor
func defaultThemeMode() {
let manager = makeManager()
#expect(manager.colorScheme == nil)
}
@Test("ThemeManager switches to light mode")
@MainActor
func setLightMode() {
let manager = makeManager()
manager.setLight()
#expect(manager.colorScheme == .light)
}
@Test("ThemeManager switches to dark mode")
@MainActor
func setDarkMode() {
let manager = makeManager()
manager.setDark()
#expect(manager.colorScheme == .dark)
}
@Test("ThemeManager switches back to system mode")
@MainActor
func setSystemMode() {
let manager = makeManager()
manager.setLight()
#expect(manager.colorScheme == .light)
manager.setSystem()
#expect(manager.colorScheme == nil)
}
}
// MARK: - Color Tests
struct ColorTests {
@Test("Brand primary color is accessible")
func brandPrimaryColor() {
#expect(Color.brandPrimary != .clear)
}
@Test("Brand accent color is accessible")
func brandAccentColor() {
#expect(Color.brandAccent != .clear)
}
@Test("Semantic colors are accessible")
func semanticColors() {
#expect(Color.success != .clear)
#expect(Color.warning != .clear)
#expect(Color.error != .clear)
}
@Test("Adaptive background colors are accessible")
func adaptiveColors() {
#expect(Color.bgPrimary != .clear)
#expect(Color.bgSecondary != .clear)
#expect(Color.bgTertiary != .clear)
}
@Test("Text colors are accessible")
func textColors() {
#expect(Color.textPrimary != .clear)
#expect(Color.textSecondary != .clear)
#expect(Color.textTertiary != .clear)
}
@Test("Border color is accessible")
func borderColor() {
#expect(Color.border != .clear)
}
}
// MARK: - Route Tests
struct RouteTests {
@Test("Route initializes from valid dashboard deep link")
func dashboardDeepLink() {
guard let url = URL(string: "kordant://dashboard") else {
Issue.record("Could not create URL")
return
}
let route = Route(deepLink: url)
#expect(route == .dashboard)
}
@Test("Route initializes from valid alerts deep link")
func alertsDeepLink() {
guard let url = URL(string: "kordant://alerts") else {
Issue.record("Could not create URL")
return
}
let route = Route(deepLink: url)
#expect(route == .alerts)
}
@Test("Route initializes from alert detail deep link")
func alertDetailDeepLink() {
guard let url = URL(string: "kordant://alerts/abc123") else {
Issue.record("Could not create URL")
return
}
let route = Route(deepLink: url)
#expect(route == .alertDetail(id: "abc123"))
}
@Test("Route returns nil for invalid scheme")
func invalidScheme() {
guard let url = URL(string: "https://example.com") else {
Issue.record("Could not create URL")
return
}
let route = Route(deepLink: url)
#expect(route == nil)
}
@Test("Route returns nil for unknown host")
func unknownHost() {
guard let url = URL(string: "kordant://unknown") else {
Issue.record("Could not create URL")
return
}
let route = Route(deepLink: url)
#expect(route == nil)
}
}
// MARK: - AppRouter Tests
struct AppRouterTests {
@Test("AppRouter starts with empty path")
@MainActor
func emptyPath() {
let router = AppRouter()
#expect(router.path.isEmpty)
}
@Test("AppRouter navigates to a route")
@MainActor
func navigateToRoute() {
let router = AppRouter()
router.navigate(to: .dashboard)
#expect(!router.path.isEmpty)
}
@Test("AppRouter pops to root clears path")
@MainActor
func popToRoot() {
let router = AppRouter()
router.navigate(to: .dashboard)
router.navigate(to: .settings)
router.popToRoot()
#expect(router.path.isEmpty)
}
@Test("AppRouter pop removes last route")
@MainActor
func popRemovesLast() {
let router = AppRouter()
router.navigate(to: .dashboard)
router.navigate(to: .settings)
router.pop()
#expect(router.path.count == 1)
}
}
// MARK: - Spacing Tests
struct SpacingTests {
@Test("Spacing values match spec")
func spacingValues() {
#expect(Spacing.xs == 4)
#expect(Spacing.sm == 8)
#expect(Spacing.md == 16)
#expect(Spacing.lg == 24)
#expect(Spacing.xl == 32)
#expect(Spacing.xxl == 48)
}
}
// MARK: - Component Tests
struct ShieldButtonTests {
@Test("ShieldButtonStyle has all cases")
func buttonStyles() {
let styles: [ShieldButtonStyle] = [.primary, .secondary, .ghost, .danger]
#expect(styles.count == 4)
}
@Test("ShieldButtonSize has all cases")
func buttonSizes() {
let sizes: [ShieldButtonSize] = [.small, .medium, .large]
#expect(sizes.count == 3)
}
@Test("ShieldButton action fires on tap")
@MainActor
func buttonAction() {
var fired = false
let button = ShieldButton(title: "Test", action: { fired = true })
button.action()
#expect(fired)
}
}
struct ShieldBadgeTests {
@Test("ShieldBadgeVariant has all cases")
func badgeVariants() {
let variants: [ShieldBadgeVariant] = [.default, .success, .warning, .error, .info]
#expect(variants.count == 5)
}
}
struct ShieldTextFieldTests {
@Test("ShieldTextField renders with label")
@MainActor
func textFieldLabel() {
let text = "test"
_ = ShieldTextField(label: "Username", text: .constant(text))
#expect(text == "test")
}
@Test("ShieldTextField accepts error message")
@MainActor
func textFieldError() {
let field = ShieldTextField(
label: "Email",
text: .constant("bad"),
errorMessage: "Invalid email"
)
#expect(field.errorMessage == "Invalid email")
}
}
struct ToastTests {
@Test("ToastData creates with defaults")
func toastDataDefaults() {
let toast = ToastData(message: "Hello")
#expect(toast.message == "Hello")
#expect(toast.variant == .info)
#expect(toast.duration == 3.5)
}
@Test("ToastData creates with custom values")
func toastDataCustom() {
let toast = ToastData(message: "Error!", variant: .error, duration: 5.0)
#expect(toast.message == "Error!")
#expect(toast.variant == .error)
#expect(toast.duration == 5.0)
}
@Test("ToastData instances are equatable by id")
func toastDataEquality() {
let toast1 = ToastData(message: "Test")
let toast2 = ToastData(message: "Test")
#expect(toast1 != toast2)
}
@MainActor
@Test("ToastManager shows and dismisses toast")
func toastManagerShowDismiss() {
let manager = ToastManager.shared
let initialCount = manager.toasts.count
manager.showToast(message: "Test toast")
#expect(manager.toasts.count == initialCount + 1)
if let toast = manager.toasts.last {
manager.dismiss(toast)
#expect(manager.toasts.count == initialCount)
}
}
}
struct ShieldAvatarTests {
@Test("ShieldAvatarSize has all cases")
func avatarSizes() {
let sizes: [ShieldAvatarSize] = [.small, .medium, .large]
#expect(sizes.count == 3)
#expect(ShieldAvatarSize.small.dimension == 32)
#expect(ShieldAvatarSize.medium.dimension == 44)
#expect(ShieldAvatarSize.large.dimension == 64)
}
@Test("AvatarStatus has all cases")
func avatarStatus() {
let statuses: [AvatarStatus] = [.online, .away, .offline]
#expect(statuses.count == 3)
}
}
struct ShieldProgressBarTests {
@Test("ShieldProgressBar clamps progress between 0 and 1")
func progressClamping() {
let over = ShieldProgressBar(progress: 1.5)
let under = ShieldProgressBar(progress: -0.5)
#expect(over.progress <= 1.0)
#expect(under.progress >= 0.0)
}
}
struct ToastVariantTests {
@Test("ToastVariant has all cases")
func toastVariants() {
let variants: [ToastVariant] = [.success, .error, .warning, .info]
#expect(variants.count == 4)
}
}
// MARK: - Plan Tests
struct PlanTests {
@Test("Plan has all expected cases")
func planCases() {
let plans = Plan.allCases
#expect(plans.count == 3)
#expect(plans.contains(.basic))
#expect(plans.contains(.plus))
#expect(plans.contains(.premium))
}
@Test("Plus plan is recommended")
func recommendedPlan() {
#expect(Plan.basic.isRecommended == false)
#expect(Plan.plus.isRecommended == true)
#expect(Plan.premium.isRecommended == false)
}
@Test("Each plan has pricing")
func planPricing() {
#expect(Plan.basic.monthlyPrice == "Free")
#expect(Plan.plus.monthlyPrice == "$12/mo")
#expect(Plan.premium.monthlyPrice == "$29/mo")
}
@Test("Premium plan has most features")
func planFeatures() {
#expect(Plan.basic.features.count < Plan.plus.features.count)
#expect(Plan.plus.features.count < Plan.premium.features.count)
}
}
// MARK: - PasswordStrength Tests
struct PasswordStrengthColorTests {
@Test("PasswordStrength has correct label for each level")
func strengthLabels() {
#expect(PasswordStrength.weak.label == "Weak")
#expect(PasswordStrength.fair.label == "Fair")
#expect(PasswordStrength.good.label == "Good")
#expect(PasswordStrength.strong.label == "Strong")
}
@Test("PasswordStrength levels are comparable")
func strengthComparison() {
#expect(PasswordStrength.weak < PasswordStrength.fair)
#expect(PasswordStrength.fair < PasswordStrength.good)
#expect(PasswordStrength.good < PasswordStrength.strong)
}
}
// MARK: - Mock URL Protocol
final class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.requestHandler else {
fatalError("MockURLProtocol.requestHandler not set")
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
// MARK: - APIClient Tests
struct APIClientTests {
private func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
@Test("APIClient injects auth header correctly")
func authHeaderInjection() async throws {
let session = makeSession()
let client = APIClient(session: session)
client.authToken = "test-jwt-token"
MockURLProtocol.requestHandler = { request in
let authHeader = request.value(forHTTPHeaderField: "Authorization")
#expect(authHeader == "Bearer test-jwt-token")
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = try JSONEncoder().encode(User(id: "1", name: "Test", email: "test@example.com"))
return (response, data)
}
let _: User = try await client.request("/api/trpc/user.me", method: "GET")
}
@Test("APIClient retries on server error")
func retryOnServerError() async throws {
let session = makeSession()
let config = APIConfig(maxRetries: 2)
let client = APIClient(config: config, session: session)
client.authToken = "test-token"
var attemptCount = 0
MockURLProtocol.requestHandler = { request in
attemptCount += 1
if attemptCount < 2 {
let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = try JSONEncoder().encode(["message": "ok"])
return (response, data)
}
let result: [String: String] = try await client.request("/test")
#expect(result["message"] == "ok")
#expect(attemptCount == 2)
}
@Test("APIClient throws unauthorized on 401")
func unauthorizedError() async throws {
let session = makeSession()
let client = APIClient(session: session)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
await #expect(throws: APIError.unauthorized) {
let _: String = try await client.request("/test")
}
}
@Test("APIClient sets content type headers")
func contentTypeHeaders() async throws {
let session = makeSession()
let client = APIClient(session: session)
MockURLProtocol.requestHandler = { request in
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, Data("\"ok\"".utf8))
}
let _: String = try await client.request("/test")
}
}
// MARK: - TRPCBridge Tests
struct TRPCBridgeTests {
@Test("TRPCBridge rawRequest returns correct data")
func rawRequestReturnsData() async throws {
let session = makeSession()
let client = APIClient(session: session)
let expectedJSON = "{\"0\":{\"result\":{\"data\":{\"id\":\"1\",\"name\":\"Test\",\"email\":\"test@example.com\"}}}}"
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = Data(expectedJSON.utf8)
return (response, data)
}
let data = try await client.rawRequest("/api/trpc/user.me", method: "POST")
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(json != nil)
let firstVal = json?["0"] as? [String: Any]
#expect(firstVal != nil)
let result = firstVal?["result"] as? [String: Any]
#expect(result != nil)
let dataVal = result?["data"] as? [String: Any]
#expect(dataVal != nil)
#expect(dataVal?["id"] as? String == "1")
}
@Test("TRPCBridge callProcedure sends correct path")
func correctPath() async throws {
let session = makeSession()
let client = APIClient(session: session)
let bridge = TRPCBridge(client: client)
MockURLProtocol.requestHandler = { request in
#expect(request.url?.absoluteString.contains("/api/trpc/user.me") == true)
#expect(request.httpMethod == "POST")
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let body: [String: Any] = ["0": ["result": ["data": ["id": "1", "name": "Test", "email": "test@example.com"]]]]
let data = try JSONSerialization.data(withJSONObject: body)
return (response, data)
}
let user: User = try await bridge.callProcedure(path: "user.me")
#expect(user.id == "1")
#expect(user.name == "Test")
#expect(user.email == "test@example.com")
}
@Test("TRPCBridge handles tRPC error format")
func trpcError() async throws {
let session = makeSession()
let client = APIClient(session: session)
let bridge = TRPCBridge(client: client)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let body: [String: Any] = ["error": ["message": "User not found", "code": 404]]
let data = try JSONSerialization.data(withJSONObject: body)
return (response, data)
}
await #expect(throws: APIError.tRPCError(code: 404, message: "User not found")) {
let _: User = try await bridge.callProcedure(path: "user.me")
}
}
@Test("TRPCBridge userMe convenience method")
func userMeConvenience() async throws {
let session = makeSession()
let client = APIClient(session: session)
client.authToken = "test-token"
let bridge = TRPCBridge(client: client)
MockURLProtocol.requestHandler = { request in
#expect(request.url?.absoluteString.hasSuffix("/api/trpc/user.me") == true)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let body: [String: Any] = ["0": ["result": ["data": ["id": "1", "name": "Test", "email": "test@example.com"]]]]
let data = try JSONSerialization.data(withJSONObject: body)
return (response, data)
}
let user = try await bridge.userMe()
#expect(user.id == "1")
}
private func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
}
// MARK: - CacheManager Tests
struct CacheManagerTests {
@Test("CacheManager stores and retrieves values")
func storeAndRetrieve() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let cache = CacheManager(defaults: defaults)
cache.setCached(key: "user", value: User(id: "1", name: "Test", email: "test@example.com"), ttl: 60)
let cached: User? = cache.getCached(key: "user")
#expect(cached?.id == "1")
#expect(cached?.name == "Test")
}
@Test("CacheManager returns nil for expired TTL")
func expiredTTL() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let cache = CacheManager(defaults: defaults)
cache.setCached(key: "item", value: "test-value", ttl: -1)
let cached: String? = cache.getCached(key: "item")
#expect(cached == nil)
}
@Test("CacheManager returns nil for missing key")
func missingKey() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let cache = CacheManager(defaults: defaults)
let cached: String? = cache.getCached(key: "nonexistent")
#expect(cached == nil)
}
@Test("CacheManager clearAll removes all entries")
func clearAll() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let cache = CacheManager(defaults: defaults)
cache.setCached(key: "a", value: "1", ttl: 60)
cache.setCached(key: "b", value: "2", ttl: 60)
cache.clearAll()
let a: String? = cache.getCached(key: "a")
let b: String? = cache.getCached(key: "b")
#expect(a == nil)
#expect(b == nil)
}
@Test("CacheManager remove clears single key")
func removeKey() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let cache = CacheManager(defaults: defaults)
cache.setCached(key: "keep", value: "value", ttl: 60)
cache.setCached(key: "remove", value: "value", ttl: 60)
cache.remove(key: "remove")
let kept: String? = cache.getCached(key: "keep")
let removed: String? = cache.getCached(key: "remove")
#expect(kept == "value")
#expect(removed == nil)
}
}
// MARK: - OfflineQueue Tests
struct OfflineQueueTests {
@Test("OfflineQueue persists and loads queued requests")
func persistAndLoad() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let client = MockAPIClient()
let queue = OfflineQueue(client: client, defaults: defaults)
let request = QueuedRequest(endpoint: "/test", method: "POST", body: Data("\"hello\"".utf8))
queue.addToQueue(request)
#expect(queue.pendingCount() == 1)
let queue2 = OfflineQueue(client: client, defaults: defaults)
#expect(queue2.pendingCount() == 1)
}
@Test("OfflineQueue processes queued requests successfully")
func processQueueSuccess() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let client = MockAPIClient()
client.shouldSucceed = true
let queue = OfflineQueue(client: client, defaults: defaults)
let request = QueuedRequest(endpoint: "/test", method: "POST")
queue.addToQueue(request)
await queue.processQueue()
#expect(queue.pendingCount() == 0)
}
@Test("OfflineQueue retries failed requests up to maxRetries")
func processQueueRetries() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let client = MockAPIClient()
client.shouldSucceed = false
let queue = OfflineQueue(client: client, defaults: defaults)
let request = QueuedRequest(endpoint: "/test", method: "POST")
queue.addToQueue(request)
await queue.processQueue()
#expect(queue.pendingCount() == 1)
var loaded = loadFromDefaults(defaults)
#expect(loaded?.first?.retryCount == 1)
}
@Test("OfflineQueue marks failed after max retries")
func maxRetriesExhausted() async {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let client = MockAPIClient()
client.shouldSucceed = false
let queue = OfflineQueue(client: client, defaults: defaults)
var request = QueuedRequest(endpoint: "/test", method: "POST")
request.retryCount = 3
queue.addToQueue(request)
await queue.processQueue()
#expect(queue.pendingCount() == 0)
}
@Test("OfflineQueue clearQueue removes all")
func clearQueue() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let client = MockAPIClient()
let queue = OfflineQueue(client: client, defaults: defaults)
queue.addToQueue(QueuedRequest(endpoint: "/test", method: "POST"))
queue.addToQueue(QueuedRequest(endpoint: "/test2", method: "GET"))
queue.clearQueue()
#expect(queue.pendingCount() == 0)
}
private func loadFromDefaults(_ defaults: UserDefaults) -> [QueuedRequest]? {
guard let data = defaults.data(forKey: "kordant.offlineQueue") else { return nil }
return try? JSONDecoder().decode([QueuedRequest].self, from: data)
}
}
final class MockAPIClient: APIClientProtocol {
var shouldSucceed = true
var authToken: String?
let config: APIConfig = .shared
func request<T: Decodable>(_ endpoint: String, method: String, body: Data?) async throws -> T {
let data = try await rawRequest(endpoint, method: method, body: body)
return try JSONDecoder().decode(T.self, from: data)
}
func rawRequest(_ endpoint: String, method: String, body: Data?) async throws -> Data {
if shouldSucceed {
return try JSONSerialization.data(withJSONObject: ["message": "ok"])
}
throw APIError.serverError(statusCode: 500)
}
}
// MARK: - NetworkMonitor Tests
struct NetworkMonitorTests {
@Test("NetworkMonitor starts connected by default")
func initialState() {
let monitor = NetworkMonitor()
#expect(monitor.isConnected == true)
monitor.stopMonitoring()
}
}
// MARK: - Model Tests
struct ModelTests {
@Test("User conforms to Codable")
func userCodable() throws {
let user = User(id: "1", name: "Test", email: "t@t.com")
let data = try JSONEncoder().encode(user)
let decoded = try JSONDecoder().decode(User.self, from: data)
#expect(decoded == user)
}
@Test("User conforms to Identifiable")
func userIdentifiable() {
let user = User(id: "42", name: "A", email: "a@b.com")
#expect(user.id == "42")
}
@Test("SubscriptionTier enum has all cases")
func subscriptionTierCases() {
let tiers = SubscriptionTier.allCases
#expect(tiers.count == 4)
#expect(tiers.contains(.free))
#expect(tiers.contains(.basic))
#expect(tiers.contains(.premium))
#expect(tiers.contains(.enterprise))
}
@Test("Alert isCritical computed property")
func alertIsCritical() {
let critical = Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil)
let low = Alert(id: "2", userId: "1", type: .exposure, severity: .low, title: "?", message: "?", read: false, createdAt: nil)
#expect(critical.isCritical == true)
#expect(low.isCritical == false)
}
@Test("AlertSeverity raw values")
func alertSeverityValues() {
#expect(AlertSeverity.low.rawValue == "low")
#expect(AlertSeverity.critical.rawValue == "critical")
}
@Test("Subscription tier raw values")
func subscriptionTierRawValues() {
#expect(SubscriptionTier.free.rawValue == "free")
#expect(SubscriptionTier.enterprise.rawValue == "enterprise")
}
@Test("Exposure source enum cases")
func exposureSourceCases() {
let sources: [ExposureSource] = [.darkWeb, .dataBreach, .socialMedia, .publicRecord, .brokerSite]
#expect(sources.count == 5)
}
@Test("WatchlistItem has CodingKeys")
func watchlistItemCodingKeys() {
let item = WatchlistItem(id: "1", userId: "1", term: "test", type: .email, status: "active", createdAt: nil)
#expect(item.term == "test")
#expect(item.type == .email)
}
}
// MARK: - Mock TRPCalling
final class MockTRPCalling: TRPCalling {
var shouldSucceed = true
var stubbedAlerts: [Kordant.Alert] = []
var stubbedExposures: [Exposure] = []
var stubbedWatchlist: [WatchlistItem] = []
var stubbedEnrollments: [VoiceEnrollment] = []
var stubbedAnalyses: [VoiceAnalysis] = []
var stubbedRules: [SpamRule] = []
var stubbedProperties: [PropertyWatchlistItem] = []
var stubbedRemovalRequests: [RemovalRequest] = []
var stubbedBrokerListings: [BrokerListing] = []
var stubbedCorrelationGroups: [CorrelationGroup] = []
var stubbedUser = User(id: "1", name: "Test User", email: "test@kordant.ai")
var stubbedSubscription = Subscription(id: "1", userId: "1", tier: .premium, status: "active", currentPeriodStart: nil, currentPeriodEnd: nil, stripeCustomerId: nil, stripeSubscriptionId: nil)
var addedWatchlistItem: WatchlistItem?
var addedProperty: PropertyWatchlistItem?
var createdRule: SpamRule?
var startedRemoval: RemovalRequest?
var onCallProcedure: ((String, (any Encodable)?) -> Any)?
func callProcedure<T: Decodable>(path: String, input: (any Encodable)?) async throws -> T {
if let onCallProcedure, let result = onCallProcedure(path, input) as? T {
return result
}
throw APIError.notImplemented
}
func userMe() async throws -> User {
if shouldSucceed { return stubbedUser }
throw APIError.notImplemented
}
func getSubscription() async throws -> Subscription {
if shouldSucceed { return stubbedSubscription }
throw APIError.notImplemented
}
func getWatchlist() async throws -> [WatchlistItem] {
if shouldSucceed { return stubbedWatchlist }
throw APIError.notImplemented
}
func getExposures() async throws -> [Exposure] {
if shouldSucceed { return stubbedExposures }
throw APIError.notImplemented
}
func getAlerts() async throws -> [Kordant.Alert] {
if shouldSucceed { return stubbedAlerts }
throw APIError.notImplemented
}
func getVoiceEnrollments() async throws -> [VoiceEnrollment] {
if shouldSucceed { return stubbedEnrollments }
throw APIError.notImplemented
}
func getVoiceAnalyses() async throws -> [VoiceAnalysis] {
if shouldSucceed { return stubbedAnalyses }
throw APIError.notImplemented
}
func getSpamRules() async throws -> [SpamRule] {
if shouldSucceed { return stubbedRules }
throw APIError.notImplemented
}
func getPropertyWatchlist() async throws -> [PropertyWatchlistItem] {
if shouldSucceed { return stubbedProperties }
throw APIError.notImplemented
}
func getRemovalRequests() async throws -> [RemovalRequest] {
if shouldSucceed { return stubbedRemovalRequests }
throw APIError.notImplemented
}
func getBrokerListings() async throws -> [BrokerListing] {
if shouldSucceed { return stubbedBrokerListings }
throw APIError.notImplemented
}
func getNormalizedAlerts() async throws -> [NormalizedAlert] {
if shouldSucceed { return [] }
throw APIError.notImplemented
}
func getCorrelationGroups() async throws -> [CorrelationGroup] {
if shouldSucceed { return stubbedCorrelationGroups }
throw APIError.notImplemented
}
func getSecurityReports() async throws -> [SecurityReport] {
if shouldSucceed { return [] }
throw APIError.notImplemented
}
func addWatchlistItem(term: String, type: WatchlistItemType) async throws -> WatchlistItem {
if shouldSucceed {
let item = WatchlistItem(id: "new-1", userId: "1", term: term, type: type, status: "active", createdAt: nil)
addedWatchlistItem = item
return item
}
throw APIError.notImplemented
}
func deleteWatchlistItem(id: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func scanForExposures() async throws -> [Exposure] {
if shouldSucceed { return stubbedExposures }
throw APIError.notImplemented
}
func deleteVoiceEnrollment(id: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func createSpamRule(pattern: String, action: SpamRuleAction, priority: Int, enabled: Bool) async throws -> SpamRule {
if shouldSucceed {
let rule = SpamRule(id: "rule-1", userId: "1", pattern: pattern, action: action, priority: priority, enabled: enabled, createdAt: nil)
createdRule = rule
return rule
}
throw APIError.notImplemented
}
func updateSpamRule(id: String, enabled: Bool) async throws -> SpamRule {
if shouldSucceed {
return SpamRule(id: id, userId: "1", pattern: "test", action: .block, priority: 1, enabled: enabled, createdAt: nil)
}
throw APIError.notImplemented
}
func deleteSpamRule(id: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func addProperty(address: String, city: String, state: String, zipCode: String) async throws -> PropertyWatchlistItem {
if shouldSucceed {
let property = PropertyWatchlistItem(id: "prop-1", userId: "1", propertyType: "residential", address: address, city: city, state: state, zipCode: zipCode, status: "active", createdAt: nil)
addedProperty = property
return property
}
throw APIError.notImplemented
}
func deleteProperty(id: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func startRemoval(exposureId: String, notes: String?) async throws -> RemovalRequest {
if shouldSucceed {
let request = RemovalRequest(id: "rem-1", userId: "1", exposureId: exposureId, status: .pending, requestedAt: nil, completedAt: nil, notes: notes)
startedRemoval = request
return request
}
throw APIError.notImplemented
}
func checkPhoneNumber(_ number: String) async throws -> SpamCheckResult {
if shouldSucceed {
return SpamCheckResult(phone: number, isSpam: true, confidence: 0.85, category: "telemarketer", reportCount: 42)
}
throw APIError.notImplemented
}
func resolveAlert(id: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func reportFalsePositive(id: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func updateNotificationPreferences(enabled: Bool) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func updateProfile(name: String, email: String) async throws -> User {
if shouldSucceed {
stubbedUser = User(id: "1", name: name, email: email)
return stubbedUser
}
throw APIError.notImplemented
}
func registerDevice(token: String) async throws {
if !shouldSucceed { throw APIError.notImplemented }
}
func createVoiceEnrollment(audioData: Data) async throws -> VoiceEnrollment {
if shouldSucceed {
return VoiceEnrollment(id: "new-enrollment", userId: "1", voiceSampleCount: 1, status: .pending, createdAt: Date())
}
throw APIError.notImplemented
}
// MARK: - Security Events
var reportedSecurityEvents: [SecurityEventInput] = []
func reportSecurityEvent(
eventType: SecurityEventType,
severity: SecuritySeverity,
indicators: [String],
violations: [String],
deviceInfo: DeviceSecurityInfo
) async throws {
if !shouldSucceed { throw APIError.notImplemented }
reportedSecurityEvents.append(SecurityEventInput(
eventType: eventType.rawValue,
severity: severity.rawValue,
indicators: indicators,
violations: violations,
deviceInfo: deviceInfo
))
}
}
struct SecurityEventInput {
let eventType: String
let severity: String
let indicators: [String]
let violations: [String]
let deviceInfo: DeviceSecurityInfo
}
// MARK: - ViewModel Tests
@MainActor
struct DashboardViewModelTests {
@Test("DashboardViewModel loads alerts, exposures, and watchlist")
func loadDashboard() async {
let mock = MockTRPCalling()
mock.stubbedAlerts = [
Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "Critical", message: "!", read: false, createdAt: Date()),
Alert(id: "2", userId: "1", type: .exposure, severity: .low, title: "Low", message: "?", read: true, createdAt: Date().addingTimeInterval(-3600))
]
mock.stubbedExposures = [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "email", exposedData: "a@b.com", severity: "high", discoveredAt: nil, status: .new),
Exposure(id: "2", userId: "1", source: .dataBreach, dataType: "password", exposedData: nil, severity: "medium", discoveredAt: nil, status: .remediated)
]
mock.stubbedWatchlist = [
WatchlistItem(id: "1", userId: "1", term: "test@email.com", type: .email, status: "active", createdAt: nil)
]
let vm = DashboardViewModel(api: mock)
await vm.loadDashboard()
#expect(vm.alerts.count == 2)
#expect(vm.exposures.count == 2)
#expect(vm.watchlistItems.count == 1)
#expect(vm.isLoading == false)
#expect(vm.error == nil)
#expect(vm.unresolvedExposures == 1)
#expect(vm.activeWatchlistCount == 1)
}
@Test("DashboardViewModel handles errors gracefully")
func loadDashboardError() async {
let mock = MockTRPCalling()
mock.shouldSucceed = false
let vm = DashboardViewModel(api: mock)
await vm.loadDashboard()
#expect(vm.alerts.isEmpty)
#expect(vm.error != nil)
#expect(vm.isLoading == false)
}
@Test("DashboardViewModel threatScore with no issues")
func threatScoreNoIssues() async {
let mock = MockTRPCalling()
mock.stubbedAlerts = []
mock.stubbedExposures = []
let vm = DashboardViewModel(api: mock)
await vm.loadDashboard()
#expect(vm.threatScore == 0)
}
@Test("DashboardViewModel threatScore with critical alerts")
func threatScoreCritical() async {
let mock = MockTRPCalling()
mock.stubbedAlerts = [
Alert(id: "1", userId: "1", type: .breach, severity: .critical, title: "!", message: "!", read: false, createdAt: nil)
]
mock.stubbedExposures = [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "email", exposedData: nil, severity: "high", discoveredAt: nil, status: .new)
]
let vm = DashboardViewModel(api: mock)
await vm.loadDashboard()
#expect(vm.threatScore > 0)
}
@Test("DashboardViewModel recentAlerts returns top 5")
func recentAlertsLimit() async {
let mock = MockTRPCalling()
mock.stubbedAlerts = (1...10).map { i in
Alert(id: "\(i)", userId: "1", type: .exposure, severity: .low, title: "Alert \(i)", message: "", read: false, createdAt: Date().addingTimeInterval(-Double(i) * 60))
}
let vm = DashboardViewModel(api: mock)
await vm.loadDashboard()
#expect(vm.recentAlerts.count == 5)
}
}
@MainActor
struct DarkWatchViewModelTests {
@Test("DarkWatchViewModel loads watchlist and exposures")
func loadData() async {
let mock = MockTRPCalling()
mock.stubbedWatchlist = [
WatchlistItem(id: "1", userId: "1", term: "test@email.com", type: .email, status: "active", createdAt: nil)
]
mock.stubbedExposures = [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "ssn", exposedData: nil, severity: "critical", discoveredAt: nil, status: .new)
]
let vm = DarkWatchViewModel(api: mock)
await vm.loadData()
#expect(vm.watchlistItems.count == 1)
#expect(vm.exposures.count == 1)
#expect(vm.isLoading == false)
}
@Test("DarkWatchViewModel adds watchlist item")
func addWatchlistItem() async {
let mock = MockTRPCalling()
let vm = DarkWatchViewModel(api: mock)
await vm.addWatchlistItem(term: "john@test.com", type: .email)
#expect(vm.watchlistItems.count == 1)
#expect(vm.watchlistItems[0].term == "john@test.com")
#expect(vm.watchlistItems[0].type == .email)
}
@Test("DarkWatchViewModel deletes watchlist item")
func deleteWatchlistItem() async {
let mock = MockTRPCalling()
mock.stubbedWatchlist = [
WatchlistItem(id: "1", userId: "1", term: "test", type: .email, status: "active", createdAt: nil)
]
let vm = DarkWatchViewModel(api: mock)
await vm.loadData()
#expect(vm.watchlistItems.count == 1)
await vm.deleteWatchlistItem(id: "1")
#expect(vm.watchlistItems.isEmpty)
}
@Test("DarkWatchViewModel scan for exposures")
func scanExposures() async {
let mock = MockTRPCalling()
mock.stubbedExposures = [
Exposure(id: "1", userId: "1", source: .darkWeb, dataType: "email", exposedData: nil, severity: "high", discoveredAt: nil, status: .new)
]
let vm = DarkWatchViewModel(api: mock)
await vm.scanForExposures()
#expect(vm.exposures.count == 1)
#expect(vm.isScanning == false)
}
}
@MainActor
struct VoicePrintViewModelTests {
@Test("VoicePrintViewModel loads enrollments and analyses")
func loadData() async {
let mock = MockTRPCalling()
mock.stubbedEnrollments = [
VoiceEnrollment(id: "1", userId: "1", voiceSampleCount: 3, status: .active, createdAt: nil)
]
mock.stubbedAnalyses = [
VoiceAnalysis(id: "1", userId: "1", enrollmentId: "1", result: "Match found", confidence: 0.92, processedAt: nil)
]
let vm = VoicePrintViewModel(api: mock)
await vm.loadData()
#expect(vm.enrollments.count == 1)
#expect(vm.analyses.count == 1)
#expect(vm.isLoading == false)
}
@Test("VoicePrintViewModel deletes enrollment")
func deleteEnrollment() async {
let mock = MockTRPCalling()
mock.stubbedEnrollments = [
VoiceEnrollment(id: "1", userId: "1", voiceSampleCount: 3, status: .active, createdAt: nil)
]
mock.stubbedAnalyses = [
VoiceAnalysis(id: "1", userId: "1", enrollmentId: "1", result: "Match", confidence: 0.9, processedAt: nil)
]
let vm = VoicePrintViewModel(api: mock)
await vm.loadData()
#expect(vm.enrollments.count == 1)
await vm.deleteEnrollment(id: "1")
#expect(vm.enrollments.isEmpty)
#expect(vm.analyses.isEmpty)
}
}
@MainActor
struct SpamShieldViewModelTests {
@Test("SpamShieldViewModel loads rules")
func loadRules() async {
let mock = MockTRPCalling()
mock.stubbedRules = [
SpamRule(id: "1", userId: "1", pattern: "+1234", action: .block, priority: 1, enabled: true, createdAt: nil),
SpamRule(id: "2", userId: "1", pattern: "spam", action: .flag, priority: 2, enabled: false, createdAt: nil)
]
let vm = SpamShieldViewModel(api: mock)
await vm.loadRules()
#expect(vm.rules.count == 2)
#expect(vm.blockedCount == 1)
#expect(vm.flaggedCount == 0)
#expect(vm.allowedCount == 0)
}
@Test("SpamShieldViewModel creates rule")
func createRule() async {
let mock = MockTRPCalling()
let vm = SpamShieldViewModel(api: mock)
await vm.createRule(pattern: "555", action: .block, priority: 1)
#expect(vm.rules.count == 1)
#expect(vm.rules[0].pattern == "555")
#expect(vm.rules[0].action == .block)
}
@Test("SpamShieldViewModel toggles rule")
func toggleRule() async {
let mock = MockTRPCalling()
mock.stubbedRules = [
SpamRule(id: "1", userId: "1", pattern: "test", action: .block, priority: 1, enabled: true, createdAt: nil)
]
let vm = SpamShieldViewModel(api: mock)
await vm.loadRules()
#expect(vm.rules[0].enabled == true)
await vm.toggleRule(vm.rules[0])
#expect(vm.rules[0].enabled == false)
}
@Test("SpamShieldViewModel deletes rule")
func deleteRule() async {
let mock = MockTRPCalling()
mock.stubbedRules = [
SpamRule(id: "1", userId: "1", pattern: "test", action: .block, priority: 1, enabled: true, createdAt: nil)
]
let vm = SpamShieldViewModel(api: mock)
await vm.loadRules()
#expect(vm.rules.count == 1)
await vm.deleteRule(id: "1")
#expect(vm.rules.isEmpty)
}
@Test("SpamShieldViewModel checks phone number")
func checkNumber() async {
let mock = MockTRPCalling()
let vm = SpamShieldViewModel(api: mock)
vm.checkPhoneNumber = "+15551234567"
await vm.checkNumber()
#expect(vm.checkResult != nil)
#expect(vm.checkResult?.isSpam == true)
#expect(vm.checkResult?.confidence == 0.85)
#expect(vm.isCheckingNumber == false)
}
}
@MainActor
struct HomeTitleViewModelTests {
@Test("HomeTitleViewModel loads properties")
func loadProperties() async {
let mock = MockTRPCalling()
mock.stubbedProperties = [
PropertyWatchlistItem(id: "1", userId: "1", propertyType: "residential", address: "123 Main St", city: "Springfield", state: "IL", zipCode: "62701", status: "active", createdAt: nil)
]
let vm = HomeTitleViewModel(api: mock)
await vm.loadProperties()
#expect(vm.properties.count == 1)
#expect(vm.properties[0].address == "123 Main St")
#expect(vm.isLoading == false)
}
@Test("HomeTitleViewModel adds property")
func addProperty() async {
let mock = MockTRPCalling()
let vm = HomeTitleViewModel(api: mock)
await vm.addProperty(address: "456 Oak Ave", city: "Portland", state: "OR", zipCode: "97201")
#expect(vm.properties.count == 1)
#expect(vm.properties[0].address == "456 Oak Ave")
}
@Test("HomeTitleViewModel deletes property")
func deleteProperty() async {
let mock = MockTRPCalling()
mock.stubbedProperties = [
PropertyWatchlistItem(id: "1", userId: "1", propertyType: "residential", address: "123 Main St", city: "Springfield", state: "IL", zipCode: "62701", status: "active", createdAt: nil)
]
let vm = HomeTitleViewModel(api: mock)
await vm.loadProperties()
#expect(vm.properties.count == 1)
await vm.deleteProperty(id: "1")
#expect(vm.properties.isEmpty)
}
}
@MainActor
struct RemoveBrokersViewModelTests {
@Test("RemoveBrokersViewModel loads listings and requests")
func loadData() async {
let mock = MockTRPCalling()
mock.stubbedBrokerListings = [
BrokerListing(id: "1", userId: "1", brokerName: "DataBrokerPro", url: "https://example.com", dataFound: true, status: .active, createdAt: nil)
]
mock.stubbedRemovalRequests = [
RemovalRequest(id: "1", userId: "1", exposureId: "exp-1", status: .inProgress, requestedAt: nil, completedAt: nil, notes: nil)
]
let vm = RemoveBrokersViewModel(api: mock)
await vm.loadData()
#expect(vm.listings.count == 1)
#expect(vm.removalRequests.count == 1)
#expect(vm.isLoading == false)
}
@Test("RemoveBrokersViewModel filters listings by search")
func filterListings() async {
let mock = MockTRPCalling()
mock.stubbedBrokerListings = [
BrokerListing(id: "1", userId: "1", brokerName: "Acme Data", url: nil, dataFound: true, status: .active, createdAt: nil),
BrokerListing(id: "2", userId: "1", brokerName: "Global Info", url: nil, dataFound: false, status: .pending, createdAt: nil)
]
let vm = RemoveBrokersViewModel(api: mock)
await vm.loadData()
vm.searchQuery = "acme"
#expect(vm.filteredListings.count == 1)
#expect(vm.filteredListings[0].brokerName == "Acme Data")
}
@Test("RemoveBrokersViewModel starts removal")
func startRemoval() async {
let mock = MockTRPCalling()
let vm = RemoveBrokersViewModel(api: mock)
await vm.startRemoval(exposureId: "exp-1", notes: "Urgent")
#expect(vm.removalRequests.count == 1)
#expect(vm.removalRequests[0].exposureId == "exp-1")
#expect(vm.removalRequests[0].notes == "Urgent")
}
}
@MainActor
struct AlertDetailViewModelTests {
@Test("AlertDetailViewModel loads correlated alerts")
func loadCorrelated() async {
let mock = MockTRPCalling()
let alert = Alert(id: "alert-1", userId: "1", type: .breach, severity: .critical, title: "Test", message: "!", read: false, createdAt: nil)
mock.stubbedCorrelationGroups = [
CorrelationGroup(id: "cg-1", userId: "1", name: "Group 1", alertIds: ["alert-1", "alert-2"], correlationScore: 0.9, createdAt: nil)
]
mock.stubbedAlerts = [
alert,
Alert(id: "alert-2", userId: "1", type: .exposure, severity: .medium, title: "Related", message: "?", read: false, createdAt: nil)
]
let vm = AlertDetailViewModel(alert: alert, api: mock)
await vm.loadCorrelatedAlerts()
#expect(vm.correlationGroups.count == 1)
#expect(vm.correlatedAlerts.count == 1)
#expect(vm.correlatedAlerts[0].id == "alert-2")
}
@Test("AlertDetailViewModel resolves alert")
func resolveAlert() async {
let mock = MockTRPCalling()
let alert = Alert(id: "alert-1", userId: "1", type: .breach, severity: .critical, title: "Test", message: "!", read: false, createdAt: nil)
let vm = AlertDetailViewModel(alert: alert, api: mock)
await vm.resolveAlert()
#expect(vm.isResolved == true)
}
@Test("AlertDetailViewModel reports false positive")
func falsePositive() async {
let mock = MockTRPCalling()
let alert = Alert(id: "alert-1", userId: "1", type: .breach, severity: .critical, title: "Test", message: "!", read: false, createdAt: nil)
let vm = AlertDetailViewModel(alert: alert, api: mock)
await vm.reportFalsePositive()
#expect(vm.isResolved == true)
}
}
@MainActor
struct SettingsViewModelTests {
@Test("SettingsViewModel loads user and subscription")
func loadSettings() async {
let mock = MockTRPCalling()
mock.stubbedUser = User(id: "1", name: "John Doe", email: "john@kordant.ai")
mock.stubbedSubscription = Subscription(id: "1", userId: "1", tier: .premium, status: "active", currentPeriodStart: nil, currentPeriodEnd: nil, stripeCustomerId: nil, stripeSubscriptionId: nil)
let vm = SettingsViewModel(api: mock)
await vm.loadSettings()
#expect(vm.user?.name == "John Doe")
#expect(vm.user?.email == "john@kordant.ai")
#expect(vm.subscription?.tier == .premium)
#expect(vm.name == "John Doe")
#expect(vm.email == "john@kordant.ai")
}
@Test("SettingsViewModel updates profile")
func updateProfile() async {
let mock = MockTRPCalling()
mock.stubbedUser = User(id: "1", name: "Old Name", email: "old@kordant.ai", subscriptionTier: nil, createdAt: nil, updatedAt: nil)
let vm = SettingsViewModel(api: mock)
await vm.loadSettings()
vm.name = "New Name"
vm.email = "new@kordant.ai"
await vm.updateProfile()
#expect(vm.user?.name == "New Name")
#expect(vm.user?.email == "new@kordant.ai")
}
@Test("SettingsViewModel error shows on API failure")
func loadSettingsError() async {
let mock = MockTRPCalling()
mock.shouldSucceed = false
let vm = SettingsViewModel(api: mock)
await vm.loadSettings()
#expect(vm.user == nil)
#expect(vm.error != nil)
}
}
// MARK: - APIConfig Tests
struct APIConfigTests {
@Test("APIConfig uses development in debug")
func debugEnvironment() {
let config = APIConfig()
#expect(config.environment == .development)
#expect(config.baseURL.absoluteString == "http://localhost:3000")
}
@Test("APIConfig custom config")
func customConfig() {
let config = APIConfig(environment: .production, timeout: 60, maxRetries: 5)
#expect(config.environment == .production)
#expect(config.baseURL.absoluteString == "https://api.kordant.ai")
#expect(config.timeout == 60)
#expect(config.maxRetries == 5)
}
@Test("APIConfig staging URL")
func stagingURL() {
let config = APIConfig(environment: .staging)
#expect(config.baseURL.absoluteString == "https://staging.kordant.ai")
}
}
// MARK: - Push Notification Tests
struct PushNotificationServiceTests {
@Test("PushNotificationService has authorizationStatus helper")
func hasAuthorizationStatus() async {
// Verifying the method signature exists; actual status depends on simulator
let service = PushNotificationService.shared
let status = await service.authorizationStatus
#expect(status == .notDetermined || status == .denied || status == .authorized)
}
@Test("PushNotificationService has isAuthorized helper")
func hasIsAuthorized() async {
let service = PushNotificationService.shared
let authorized = await service.isAuthorized
#expect(authorized == false) // Simulator starts as notDetermined
}
@Test("NotificationPayload parses valid userInfo")
func validPayload() {
let userInfo: [AnyHashable: Any] = [
"screen": "alerts",
"id": "alert-123",
"aps": ["alert": ["title": "Test Title", "body": "Test Body"]],
"image-url": "https://example.com/image.png"
]
let payload = NotificationPayload(userInfo: userInfo)
#expect(payload?.screen == "alerts")
#expect(payload?.id == "alert-123")
#expect(payload?.title == "Test Title")
#expect(payload?.body == "Test Body")
#expect(payload?.imageURL?.absoluteString == "https://example.com/image.png")
}
@Test("NotificationPayload returns nil without screen key")
func missingScreen() {
let userInfo: [AnyHashable: Any] = ["aps": [:]]
let payload = NotificationPayload(userInfo: userInfo)
#expect(payload == nil)
}
@Test("NotificationPayload parses minimal payload")
func minimalPayload() {
let userInfo: [AnyHashable: Any] = ["screen": "dashboard"]
let payload = NotificationPayload(userInfo: userInfo)
#expect(payload?.screen == "dashboard")
#expect(payload?.id == nil)
#expect(payload?.imageURL == nil)
}
@Test("Route initializes from notification payload")
func routeFromNotification() {
let payload: [AnyHashable: Any] = ["screen": "alerts", "id": "abc"]
let route = Route(notificationPayload: payload)
#expect(route == .alertDetail(id: "abc"))
}
@Test("Route from notification payload returns dashboard for home screen")
func routeFromNotificationHome() {
let payload: [AnyHashable: Any] = ["screen": "home"]
let route = Route(notificationPayload: payload)
#expect(route == .dashboard)
}
}
// MARK: - BiometricAuthService Tests
@MainActor
struct BiometricAuthServiceTests {
@Test("BiometricAuthService uses mock keychain")
func usesMockKeychain() throws {
let keychain = MockKeychainService()
let service = BiometricAuthService(keychain: keychain)
#expect(!service.isEnabled)
}
@Test("BiometricAuthService enable stores in keychain")
func enableBiometric() throws {
let keychain = MockKeychainService()
try keychain.store(key: "jwt", value: Data("token".utf8))
let service = BiometricAuthService(keychain: keychain)
try service.enable()
#expect(service.isEnabled)
}
@Test("BiometricAuthService disable removes from keychain")
func disableBiometric() throws {
let keychain = MockKeychainService()
try keychain.store(key: "jwt", value: Data("token".utf8))
let service = BiometricAuthService(keychain: keychain)
try service.enable()
#expect(service.isEnabled)
service.disable()
#expect(!service.isEnabled)
}
@Test("BiometricAuthService biometryType returns unavailable on simulator")
func biometryTypeOnSimulator() {
let keychain = MockKeychainService()
let service = BiometricAuthService(keychain: keychain)
let type = service.biometryType
#expect(type != .faceID) // LAContext returns .none on simulator
}
@Test("BiometricAuthService isAvailable returns false on simulator")
func isAvailableOnSimulator() {
let keychain = MockKeychainService()
let service = BiometricAuthService(keychain: keychain)
#expect(!service.isAvailable) // No biometric hardware on simulator
}
}
// MARK: - CameraService Tests
@MainActor
struct CameraServiceTests {
@Test("CameraService returns correct permission status defaults")
func permissionDefaults() {
let service = CameraService()
#expect(service.cameraPermission != .granted) // Not yet authorized
#expect(service.microphonePermission != .granted)
}
@Test("CameraService usage descriptions are not empty")
func usageDescriptions() {
#expect(!CameraService.cameraUsageDescription().isEmpty)
#expect(!CameraService.microphoneUsageDescription().isEmpty)
}
@Test("CameraService ensureCameraPermission handles denied state gracefully")
func ensureCameraDenied() async {
// On simulator, permission defaults to notDetermined
let service = CameraService()
let granted = await service.ensureCameraPermission()
// Simulators will show system dialog -> simulator returns true
// This test verifies the method doesn't crash
#expect(granted == true || granted == false)
}
@Test("CameraService ensureMicrophonePermission handles denied state gracefully")
func ensureMicrophoneDenied() async {
let service = CameraService()
let granted = await service.ensureMicrophonePermission()
// Simulator may grant microphone automatically
#expect(granted == true || granted == false)
}
@Test("CameraService openSettings does not crash")
func openSettings() {
// Verify the method exists and doesn't throw
CameraService.shared.openSettings()
}
}
// MARK: - Permission Type Tests
@MainActor
struct PermissionTypeTests {
@Test("PermissionType has all expected cases")
func allCases() {
let cases = PermissionType.allCases
#expect(cases.contains(.camera))
#expect(cases.contains(.microphone))
#expect(cases.contains(.notifications))
#expect(cases.contains(.faceID))
}
@Test("PermissionType icons are non-empty")
func icons() {
for type in PermissionType.allCases {
#expect(!type.icon.isEmpty)
}
}
@Test("PermissionType titles are non-empty")
func titles() {
for type in PermissionType.allCases {
#expect(!type.title.isEmpty)
}
}
@Test("PermissionType explanations are non-empty")
func explanations() {
for type in PermissionType.allCases {
#expect(!type.explanation.isEmpty)
}
}
@Test("PermissionType benefits are non-empty")
func benefits() {
for type in PermissionType.allCases {
#expect(!type.benefit.isEmpty)
}
}
@Test("PermissionType setting names are non-empty")
func settingNames() {
for type in PermissionType.allCases {
#expect(!type.settingName.isEmpty)
}
}
}
// MARK: - PermissionService Tests
@MainActor
struct PermissionServiceTests {
@Test("PermissionRationaleView instantiates")
func rationaleViewCamera() {
let view = PermissionRationaleView(permissionType: .camera, onAllow: {}, onDeny: {})
#expect(view.permissionType == .camera)
}
@Test("PermissionRationaleView instantiates for each type")
func rationaleViewAll() {
for type in PermissionType.allCases {
let view = PermissionRationaleView(permissionType: type, onAllow: {}, onDeny: {})
#expect(view.permissionType == type)
}
}
@Test("PermissionDeniedView instantiates")
func deniedView() {
let view = PermissionDeniedView(
permissionType: .microphone,
onOpenSettings: {},
onDismiss: {}
)
#expect(view.permissionType == .microphone)
}
@Test("PermissionDeniedView instantiates for each type")
func deniedViewAll() {
for type in PermissionType.allCases {
let view = PermissionDeniedView(
permissionType: type,
onOpenSettings: {},
onDismiss: {}
)
#expect(view.permissionType == type)
}
}
@Test("PermissionSettingsOpener has openSettings")
func settingsOpenerExists() {
// Just verify the type exists and has the static method
#expect(PermissionSettingsOpener.self != nil)
}
}
// MARK: - VoicePrintViewModel Tests (submit enrollment)
@MainActor
struct VoicePrintSubmitTests {
@Test("VoicePrintViewModel submits enrollment")
func submitEnrollment() async {
let mock = MockTRPCalling()
mock.shouldSucceed = true
let vm = VoicePrintViewModel(api: mock)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.wav")
try? Data("mock-audio".utf8).write(to: tempURL)
await vm.submitEnrollment(audioURL: tempURL)
#expect(vm.enrollments.count == 1)
#expect(vm.enrollments[0].status == .pending)
#expect(vm.submitSuccess)
#expect(!vm.showingRecordingSheet)
}
@Test("VoicePrintViewModel handles submission failure")
func submitEnrollmentFailure() async {
let mock = MockTRPCalling()
mock.shouldSucceed = false
let vm = VoicePrintViewModel(api: mock)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("test.wav")
try? Data("mock-audio".utf8).write(to: tempURL)
await vm.submitEnrollment(audioURL: tempURL)
#expect(vm.enrollments.isEmpty)
#expect(vm.error != nil)
#expect(!vm.isSubmitting)
}
}
// MARK: - OAuth & Social Login Tests
@MainActor
struct OAuthIntegrationTests {
@Test("AuthService Apple Sign-In succeeds with mock client")
func appleSignInAPISuccess() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let response = try? await client.loginWithApple(
identityToken: "mock-apple-identity-token",
authorizationCode: "mock-apple-auth-code",
userIdentifier: "000123.abc123def"
)
#expect(response != nil)
#expect(response?.user.id == "apple-user-1")
#expect(response?.user.name == "Apple User")
#expect(response?.refreshToken == "apple-refresh-token")
#expect(response?.accessToken == "mock-token")
#expect(client.lastAppleIdentityToken == "mock-apple-identity-token")
#expect(client.lastAppleAuthorizationCode == "mock-apple-auth-code")
#expect(client.lastAppleUserIdentifier == "000123.abc123def")
}
@Test("AuthService Apple Sign-In fails with invalid token")
func appleSignInAPIFailure() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let service = AuthService(keychain: MockKeychainService(), apiClient: client)
await service.loginWithApple()
#expect(service.state == .unauthenticated)
#expect(service.signInError != nil)
}
@Test("AuthService Google Sign-In succeeds with mock client")
func googleSignInAPISuccess() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let response = try? await client.loginWithGoogle(idToken: "mock-google-id-token")
#expect(response != nil)
#expect(response?.user.id == "google-user-1")
#expect(response?.user.name == "Google User")
#expect(response?.refreshToken == "google-refresh-token")
#expect(response?.accessToken == "mock-token")
#expect(client.lastGoogleIdToken == "mock-google-id-token")
}
@Test("AuthService Google Sign-In fails with invalid token")
func googleSignInAPIFailure() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let service = AuthService(keychain: MockKeychainService(), apiClient: client)
await service.loginWithGoogle()
#expect(service.state == .unauthenticated)
#expect(service.signInError != nil)
}
}
// MARK: - Token Refresh Tests
@MainActor
struct TokenRefreshIntegrationTests {
@Test("Token refresh succeeds with valid refresh token")
func refreshTokenSuccess() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let keychain = MockKeychainService()
try? keychain.store(key: "refreshToken", value: Data("existing-refresh-token".utf8))
try? keychain.store(key: "jwt", value: Data("existing-jwt".utf8))
let service = AuthService(keychain: keychain, apiClient: client)
let result = await service.attemptSilentRefresh()
#expect(result == true)
#expect(client.lastRefreshToken == "existing-refresh-token")
}
@Test("Token refresh fails without stored refresh token")
func refreshTokenFailsWithoutToken() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = AuthService(keychain: MockKeychainService(), apiClient: client)
let result = await service.attemptSilentRefresh()
#expect(result == false)
}
@Test("Token refresh fails when API returns unauthorized")
func refreshTokenFailsUnauthorized() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let keychain = MockKeychainService()
try? keychain.store(key: "refreshToken", value: Data("expired-token".utf8))
let service = AuthService(keychain: keychain, apiClient: client)
let result = await service.attemptSilentRefresh()
#expect(result == false)
}
@Test("Refresh token API returns new token pair")
func refreshTokenAPIReturnsNewPair() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let response = try? await client.refreshToken(refreshToken: "valid-refresh-token")
#expect(response != nil)
#expect(response?.accessToken == "mock-token")
#expect(response?.refreshToken == "new-refresh-token")
#expect(client.lastRefreshToken == "valid-refresh-token")
}
@Test("Refresh token API throws on invalid token")
func refreshTokenAPIThrows() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
await #expect(throws: APIError.unauthorized) {
try await client.refreshToken(refreshToken: "invalid-refresh-token")
}
}
}
// MARK: - Logout Tests
@MainActor
struct LogoutIntegrationTests {
@Test("Logout calls backend to revoke tokens and clears local state")
func logoutRevokesAndClears() async {
let client = MockAuthAPIClient()
client.shouldSucceed = true
let keychain = MockKeychainService()
try? keychain.store(key: "jwt", value: Data("token".utf8))
try? keychain.store(key: "refreshToken", value: Data("refresh".utf8))
try? keychain.store(key: "currentUser", value: try JSONEncoder().encode(User(id: "1", name: "Test", email: "t@t.com")))
let service = AuthService(keychain: keychain, apiClient: client)
service.state = .authenticated
service.logout()
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(service.state == .unauthenticated)
#expect(service.currentUser == nil)
#expect(try? keychain.retrieve(key: "jwt") == nil)
#expect(try? keychain.retrieve(key: "refreshToken") == nil)
#expect(client.didCallLogout == true)
}
@Test("Logout handles backend revocation failure gracefully")
func logoutHandlesFailure() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let keychain = MockKeychainService()
try? keychain.store(key: "jwt", value: Data("token".utf8))
let service = AuthService(keychain: keychain, apiClient: client)
service.state = .authenticated
service.logout()
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(service.state == .unauthenticated)
#expect(client.didCallLogout == true)
}
@Test("Force logout skips backend call but clears state")
func forceLogoutClearsState() async {
let client = MockAuthAPIClient()
let keychain = MockKeychainService()
try? keychain.store(key: "jwt", value: Data("token".utf8))
let service = AuthService(keychain: keychain, apiClient: client)
service.state = .authenticated
service.forceLogout()
#expect(service.state == .unauthenticated)
#expect(client.didCallLogout == false)
}
}
// MARK: - OAuth Security Tests
@MainActor
struct OAuthSecurityTests {
@Test("Invalid Apple identity token is rejected")
func rejectInvalidAppleToken() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
do {
_ = try await client.loginWithApple(
identityToken: "invalid-token",
authorizationCode: "invalid-code",
userIdentifier: "invalid-user"
)
Issue.record("Expected error but got success")
} catch let error as APIError {
if case .tRPCError(let code, _) = error {
#expect(code == 401)
} else {
Issue.record("Expected tRPCError with code 401")
}
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test("Invalid Google ID token is rejected")
func rejectInvalidGoogleToken() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
do {
_ = try await client.loginWithGoogle(idToken: "invalid-token")
Issue.record("Expected error but got success")
} catch let error as APIError {
if case .tRPCError(let code, _) = error {
#expect(code == 401)
} else {
Issue.record("Expected tRPCError with code 401")
}
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test("Expired refresh token forces re-authentication")
func expiredRefreshTokenForcesReauth() async {
let client = MockAuthAPIClient()
client.shouldSucceed = false
let keychain = MockKeychainService()
try? keychain.store(key: "refreshToken", value: Data("expired-token".utf8))
let service = AuthService(keychain: keychain, apiClient: client)
let result = await service.attemptSilentRefresh()
#expect(result == false)
#expect(service.state == .unauthenticated)
}
@Test("Cancelled sign-in does not show error alert")
func cancelledSignInShowsNoError() {
let service = AuthService(keychain: MockKeychainService(), apiClient: MockAuthAPIClient())
// When sign-in is cancelled, signInError is set to .cancelled
// The view layer checks this and does not show the alert
service.signInError = .cancelled
#expect(service.signInError == .cancelled)
#expect(service.error == nil)
}
@Test("Network error sets appropriate sign-in error")
func networkErrorSetsProperError() {
let service = AuthService(keychain: MockKeychainService(), apiClient: MockAuthAPIClient())
// Simulate network error handling
service.signInError = .networkError
#expect(service.signInError?.localizedDescription.contains("Unable to connect") == true)
}
}
// MARK: - Image Cache Tests
struct ImageCacheServiceTests {
@Test("ImageCacheService shared instance exists")
func sharedInstance() {
let service = ImageCacheService.shared
#expect(service !== nil)
}
@Test("ImageCacheService cache stats return valid data")
@MainActor
func cacheStatsValid() {
let stats = ImageCacheService.shared.cacheStats
#expect(stats.memoryCapacity == 50 * 1024 * 1024)
#expect(stats.diskCapacity == 100 * 1024 * 1024)
#expect(stats.memoryUsage >= 0)
#expect(stats.diskUsage >= 0)
#expect(stats.cachedEntries >= 0)
}
@Test("ImageCacheService clearCache resets stats")
@MainActor
func clearCacheResets() {
let service = ImageCacheService.shared
service.clearCache()
let stats = service.cacheStats
#expect(stats.memoryUsage == 0 || stats.diskUsage >= 0)
}
@Test("ImageCacheService isCached returns false for unknown URL")
@MainActor
func notCachedForUnknownURL() {
let url = URL(string: "https://example.com/nonexistent.jpg")!
let cached = ImageCacheService.shared.isCached(url: url)
#expect(cached == false)
}
@Test("ImageCacheService cancelAllDownloads does not crash")
@MainActor
func cancelAllDownloads() {
let service = ImageCacheService.shared
// Should not crash when no active downloads
service.cancelAllDownloads()
#expect(true) // reached without crash
}
@Test("ImageCacheService handles memory warning gracefully")
@MainActor
func memoryWarningHandling() {
let service = ImageCacheService.shared
// Trigger the memory warning handler
NotificationCenter.default.post(
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
// Should not crash
#expect(true)
}
}
// MARK: - ImageOptimizer Tests
struct ImageOptimizerTests {
@Test("ImageOptimizer shared instance exists")
func sharedInstance() {
let optimizer = ImageOptimizer.shared
#expect(optimizer !== nil)
}
@Test("ImageOptimizer compressForUpload produces smaller data")
func compressForUploadReducesSize() {
let optimizer = ImageOptimizer.shared
let image = UIImage()
let compressed = optimizer.compressForUpload(image, quality: 0.1)
// Empty image should still produce some data
#expect(compressed.isEmpty == false || compressed is Data)
}
@Test("ImageOptimizer sizedURL appends query parameters")
func sizedURLAppendsParams() {
let optimizer = ImageOptimizer.shared
let baseURL = URL(string: "https://example.com/image.jpg")!
let sized = optimizer.sizedURL(for: baseURL, size: .thumbnail)
#expect(sized.absoluteString.contains("w="))
#expect(sized.absoluteString.contains("h="))
}
@Test("ImageOptimizer ImageSize values are reasonable")
func imageSizeValues() {
#expect(ImageSize.thumbnail.size.width == 60)
#expect(ImageSize.thumbnail.size.height == 60)
#expect(ImageSize.medium.size.width == 300)
#expect(ImageSize.large.size.width == 800)
#expect(ImageSize.full.size.width == 4096)
}
@Test("ImageOptimizer ImageSize staticSizeForWidth")
func sizeForWidth() {
let smallSize = ImageSize.size(for: 60)
let mediumSize = ImageSize.size(for: 300)
let largeSize = ImageSize.size(for: 800)
#expect(smallSize == ImageSize.thumbnail.size)
#expect(mediumSize == ImageSize.medium.size)
#expect(largeSize == ImageSize.large.size)
}
@Test("ImageFormat preferred is HEIC on iOS 17+")
func preferredFormat() {
let format = ImageFormat.preferred
#expect(format == .heic || format == .jpeg)
}
@Test("ImageFormat mime types are correct")
func formatMimeTypes() {
#expect(ImageFormat.heic.mimeType == "image/heic")
#expect(ImageFormat.jpeg.mimeType == "image/jpeg")
#expect(ImageFormat.png.mimeType == "image/png")
}
}
// MARK: - AsyncSemaphore Tests
struct AsyncSemaphoreTests {
@Test("AsyncSemaphore allows up to count concurrent operations")
func allowsConcurrentOperations() async {
let semaphore = AsyncSemaphore(count: 3)
var concurrentCount = 0
var maxConcurrent = 0
await withTaskGroup(of: Void.self) { group in
for _ in 0..<10 {
group.addTask {
await semaphore.wait()
concurrentCount += 1
maxConcurrent = max(maxConcurrent, concurrentCount)
try? await Task.sleep(nanoseconds: 10_000_000)
concurrentCount -= 1
semaphore.signal()
}
}
}
#expect(maxConcurrent <= 3)
}
@Test("AsyncSemaphore withLock executes operation")
func withLockExecutes() async {
let semaphore = AsyncSemaphore(count: 1)
var executed = false
let result = await semaphore.withLock {
executed = true
return 42
}
#expect(executed)
#expect(result == 42)
}
}
// MARK: - ImageUploadQueue Tests
struct ImageUploadQueueTests {
@Test("ImageUploadQueue enqueue does not crash")
func enqueueUpload() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let queue = ImageUploadQueue(defaults: defaults)
let image = UIImage()
queue.enqueueUpload(image: image, endpoint: "/upload/test")
#expect(queue.pendingCount == 1)
}
@Test("ImageUploadQueue clearQueue removes all")
func clearQueue() {
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let queue = ImageUploadQueue(defaults: defaults)
let image = UIImage()
queue.enqueueUpload(image: image, endpoint: "/upload/test")
#expect(queue.pendingCount == 1)
queue.clearQueue()
#expect(queue.pendingCount == 0)
}
}
// MARK: - CachedAsyncImage Tests
struct CachedAsyncImageTests {
@Test("CachedAsyncImage phases have all cases")
func phaseCases() {
// Verify the Phase enum exists by checking types
_ = CachedAsyncImage<EmptyView, EmptyView>.Phase.loading
_ = CachedAsyncImage<EmptyView, EmptyView>.Phase.failure(ImageCacheError.downloadFailed)
#expect(true)
}
@Test("ImagePrefetcher shared instance exists")
func prefetcherShared() {
let prefetcher = ImagePrefetcher.shared
#expect(prefetcher !== nil)
}
@Test("ImagePrefetcher cancelAllDownloads does not crash")
func prefetcherCancel() {
let prefetcher = ImagePrefetcher.shared
prefetcher.cancelPrefetch([URL(string: "https://example.com/img.jpg")!])
#expect(true)
}
@Test("ImagePrefetcher reset clears state")
func prefetcherReset() {
let prefetcher = ImagePrefetcher.shared
prefetcher.reset()
#expect(true) // No crash
}
}
// MARK: - ImageCacheError Tests
struct ImageCacheErrorTests {
@Test("ImageCacheError descriptions are non-empty")
func errorDescriptions() {
#expect(ImageCacheError.downloadFailed.errorDescription?.isEmpty == false)
#expect(ImageCacheError.invalidImageData.errorDescription?.isEmpty == false)
#expect(ImageCacheError.cancelled.errorDescription?.isEmpty == false)
#expect(ImageCacheError.notCached.errorDescription?.isEmpty == false)
}
}
// MARK: - Jailbreak Detector Tests
struct JailbreakDetectorTests {
private let detector = JailbreakDetector.shared
@Test("JailbreakDetector returns notDetected on non-jailbroken device")
func notJailbroken() {
let status = detector.check()
#expect(status == .notDetected)
#expect(!detector.isJailbroken)
}
@Test("JailbreakDetector checkIndicator returns false for all indicators on clean device")
func allIndicatorsFalse() {
for indicator in JailbreakIndicator.allCases {
#expect(!detector.checkIndicator(indicator), "\(indicator.rawValue) should not be detected on clean device")
}
}
@Test("JailbreakIndicator severity values are correct")
func indicatorSeverities() {
#expect(JailbreakIndicator.cydiaApp.severity == .high)
#expect(JailbreakIndicator.writableSystemPaths.severity == .critical)
#expect(JailbreakIndicator.dyldInsertLibrary.severity == .medium)
#expect(JailbreakIndicator.dodgeMasterApp.severity == .low)
}
@Test("JailbreakStatus indicatorCount is zero when not detected")
func indicatorCountZero() {
let status: JailbreakStatus = .notDetected
#expect(status.indicatorCount == 0)
#expect(!status.isJailbroken)
}
@Test("JailbreakStatus indicatorCount matches array count when detected")
func indicatorCountMatches() {
let indicators: [JailbreakIndicator] = [.cydiaApp, .writableSystemPaths]
let status: JailbreakStatus = .detected(indicators: indicators)
#expect(status.indicatorCount == 2)
#expect(status.isJailbroken)
}
@Test("JailbreakSeverity has all expected cases")
func severityCases() {
let severities: [JailbreakSeverity] = [.critical, .high, .medium, .low]
#expect(severities.count == 4)
}
}
// MARK: - Runtime Integrity Monitor Tests
struct RuntimeIntegrityMonitorTests {
private let monitor = RuntimeIntegrityMonitor.shared
@Test("RuntimeIntegrityMonitor has no violations on clean device")
func noViolations() {
let violations = monitor.checkIntegrity()
#expect(violations.isEmpty)
#expect(!monitor.hasViolations)
}
@Test("RuntimeIntegrityMonitor checkViolation returns false for all on clean device")
func allViolationsFalse() {
for violation in IntegrityViolation.allCases {
// Skip simulator detection as it's expected on simulator
if violation == .simulatorDetected {
continue
}
#expect(!monitor.checkViolation(violation), "\(violation.rawValue) should not be detected on clean device")
}
}
@Test("IntegrityViolation severity values are correct")
func violationSeverities() {
#expect(IntegrityViolation.debuggerAttached.severity == .high)
#expect(IntegrityViolation.codeInjection.severity == .critical)
#expect(IntegrityViolation.bundleIdentifierMismatch.severity == .critical)
#expect(IntegrityViolation.simulatorDetected.severity == .low)
}
@Test("SecuritySeverity has all expected cases")
func severityCases() {
let severities: [SecuritySeverity] = [.critical, .high, .medium, .low, .info]
#expect(severities.count == 5)
}
}
// MARK: - Obfuscated String Tests
struct ObfuscatedStringTests {
@Test("ObfuscatedString encrypts and decrypts correctly")
func encryptDecrypt() {
let original = "https://api.kordant.ai"
let obfuscated = ObfuscatedString(original)
#expect(obfuscated.value == original)
}
@Test("ObfuscatedString is ExpressibleByStringLiteral")
func stringLiteral() {
let obfuscated: ObfuscatedString = "test-value"
#expect(obfuscated.value == "test-value")
}
@Test("ObfuscatedString equality works")
func equality() {
let a = ObfuscatedString("same-value")
let b = ObfuscatedString("same-value")
let c = ObfuscatedString("different-value")
#expect(a == b)
#expect(a != c)
}
@Test("ObfuscatedString description returns decrypted value")
func description() {
let obfuscated = ObfuscatedString("secret")
#expect(obfuscated.description == "secret")
}
@Test("ObfuscatedURL creates valid URL")
func obfuscatedURL() {
let url = ObfuscatedURL("https://api.kordant.ai")
#expect(url.url != nil)
#expect(url.value == "https://api.kordant.ai")
}
@Test("APIEndpoints are accessible")
func apiEndpoints() {
#expect(APIEndpoints.baseURL.value == "https://api.kordant.ai")
#expect(APIEndpoints.stagingURL.value == "https://staging.kordant.ai")
#expect(APIEndpoints.developmentURL.value == "http://localhost:3000")
#expect(APIEndpoints.trpcBase.value == "/api/trpc")
}
}
// MARK: - Secure Enclave Service Tests
struct SecureEnclaveServiceTests {
@Test("SecureEnclaveService is not available on simulator")
func notAvailableOnSimulator() {
let service = SecureEnclaveService(keychainService: MockKeychainService())
#expect(!service.isAvailable)
}
@Test("SecureEnclaveError descriptions are non-empty")
func errorDescriptions() {
#expect(SecureEnclaveError.keyGenerationFailed(-1).errorDescription?.isEmpty == false)
#expect(SecureEnclaveError.biometryNotAvailable.errorDescription?.isEmpty == false)
#expect(SecureEnclaveError.unsupportedDevice.errorDescription?.isEmpty == false)
}
}
// MARK: - Security Manager Tests
@MainActor
struct SecurityManagerTests {
private func makeManager() -> SecurityManager {
let mockDetector = MockJailbreakDetector()
let mockMonitor = MockRuntimeIntegrityMonitor()
let mockEnclave = MockSecureEnclaveService()
let mockTRPC = MockTRPCalling()
mockTRPC.shouldSucceed = true
return SecurityManager(
jailbreakDetector: mockDetector,
integrityMonitor: mockMonitor,
secureEnclaveService: mockEnclave,
trpcBridge: mockTRPC
)
}
@Test("SecurityManager starts in non-degraded mode")
func initialMode() {
let manager = makeManager()
#expect(!manager.isDegradedMode)
#expect(!manager.showSecurityWarning)
#expect(!manager.securityCheckComplete)
}
@Test("SecurityManager runs security checks and completes")
func runSecurityChecks() async {
let manager = makeManager()
await manager.runSecurityChecks()
#expect(manager.securityCheckComplete)
#expect(manager.lastSecurityCheck != nil)
}
@Test("SecurityManager activates degraded mode on jailbreak")
func degradedModeOnJailbreak() async {
let mockDetector = MockJailbreakDetector()
mockDetector.simulateJailbreak = true
let manager = SecurityManager(
jailbreakDetector: mockDetector,
integrityMonitor: MockRuntimeIntegrityMonitor(),
secureEnclaveService: MockSecureEnclaveService(),
trpcBridge: MockTRPCalling()
)
await manager.runSecurityChecks()
#expect(manager.isDegradedMode)
#expect(manager.showSecurityWarning)
}
@Test("SecurityManager activates degraded mode on integrity violations")
func degradedModeOnIntegrityViolation() async {
let mockMonitor = MockRuntimeIntegrityMonitor()
mockMonitor.simulateViolation = .codeInjection
let manager = SecurityManager(
jailbreakDetector: MockJailbreakDetector(),
integrityMonitor: mockMonitor,
secureEnclaveService: MockSecureEnclaveService(),
trpcBridge: MockTRPCalling()
)
await manager.runSecurityChecks()
#expect(manager.isDegradedMode)
#expect(manager.degradedConfig == .full)
}
@Test("SecurityManager stays in normal mode when clean")
func normalModeWhenClean() async {
let manager = makeManager()
await manager.runSecurityChecks()
#expect(!manager.isDegradedMode)
#expect(!manager.showSecurityWarning)
#expect(manager.degradedConfig == .none)
}
@Test("SecurityManager partial degraded mode on low-severity issues")
func partialDegradedMode() async {
let mockMonitor = MockRuntimeIntegrityMonitor()
mockMonitor.simulateViolation = .debuggerAttached
let manager = SecurityManager(
jailbreakDetector: MockJailbreakDetector(),
integrityMonitor: mockMonitor,
secureEnclaveService: MockSecureEnclaveService(),
trpcBridge: MockTRPCalling()
)
await manager.runSecurityChecks()
#expect(manager.isDegradedMode)
#expect(manager.degradedConfig == .partial)
}
}
// MARK: - Degraded Mode Config Tests
struct DegradedModeConfigTests {
@Test("Full degraded mode disables all features")
func fullConfig() {
let config = DegradedModeConfig.full
#expect(config.disableBiometricAuth)
#expect(config.disablePayments)
#expect(config.disableSensitiveData)
#expect(config.showWarningBanner)
#expect(config.restrictAPIAccess)
#expect(config.logAllActivity)
}
@Test("Partial degraded mode disables only critical features")
func partialConfig() {
let config = DegradedModeConfig.partial
#expect(!config.disableBiometricAuth)
#expect(config.disablePayments)
#expect(!config.disableSensitiveData)
#expect(config.showWarningBanner)
#expect(!config.restrictAPIAccess)
#expect(config.logAllActivity)
}
@Test("None degraded mode enables all features")
func noneConfig() {
let config = DegradedModeConfig.none
#expect(!config.disableBiometricAuth)
#expect(!config.disablePayments)
#expect(!config.disableSensitiveData)
#expect(!config.showWarningBanner)
#expect(!config.restrictAPIAccess)
#expect(!config.logAllActivity)
}
}
// MARK: - Security Event Tests
struct SecurityEventTests {
@Test("SecurityEvent creates with unique ID")
func uniqueID() {
let event1 = SecurityEvent(eventType: .jailbreakDetected, severity: .high)
let event2 = SecurityEvent(eventType: .jailbreakDetected, severity: .high)
#expect(event1.id != event2.id)
}
@Test("SecurityEvent captures timestamp")
func timestamp() {
let event = SecurityEvent(eventType: .jailbreakDetected, severity: .high)
#expect(event.timestamp.timeIntervalSince1970 > 0)
}
@Test("SecurityEventType has all expected cases")
func eventTypeCases() {
let types: [SecurityEventType] = [
.jailbreakDetected, .jailbreakNotDetected,
.debuggerAttached, .codeInjectionDetected,
.methodSwizzlingDetected, .binaryModified,
.bundleIdentifierMismatch, .fridaDetected,
.integrityCheckPassed, .secureEnclaveAvailable,
.secureEnclaveUnavailable, .biometricAuthSuccess,
.biometricAuthFailure, .keychainAccess,
.securityInitialization
]
#expect(types.count == 15)
}
@Test("DeviceSecurityInfo captures current device info")
func deviceSecurityInfo() {
let info = DeviceSecurityInfo.current
#expect(!info.platform.isEmpty)
#expect(!info.osVersion.isEmpty)
#expect(!info.model.isEmpty)
#expect(!info.bundleVersion.isEmpty)
#expect(!info.buildNumber.isEmpty)
#expect(!info.timestamp.isEmpty)
}
}
// MARK: - Keychain Service Biometry Tests
@MainActor
struct KeychainServiceBiometryTests {
@Test("KeychainService stores and retrieves with service identifier")
func storeWithService() throws {
let keychain = KeychainService()
try keychain.store(key: "test_service", value: Data("hello".utf8))
let result = try keychain.retrieve(key: "test_service")
#expect(result == Data("hello".utf8))
try keychain.delete(key: "test_service")
}
@Test("KeychainService clearAll respects service identifier")
func clearAllWithService() throws {
let keychain = KeychainService()
try keychain.store(key: "a", value: Data("1".utf8))
try keychain.store(key: "b", value: Data("2".utf8))
try keychain.clearAll()
#expect(try keychain.retrieve(key: "a") == nil)
#expect(try keychain.retrieve(key: "b") == nil)
}
@Test("KeychainError descriptions are non-empty")
func errorDescriptions() {
#expect(KeychainError.storeFailed(-1).errorDescription?.isEmpty == false)
#expect(KeychainError.retrieveFailed(-1).errorDescription?.isEmpty == false)
#expect(KeychainError.deleteFailed(-1).errorDescription?.isEmpty == false)
}
}
// MARK: - Mock Classes for Security Tests
final class MockJailbreakDetector: JailbreakDetecting {
var simulateJailbreak = false
var jailbreakIndicators: [JailbreakIndicator] = [.cydiaApp, .writableSystemPaths]
var isJailbroken: Bool {
status.isJailbroken
}
var status: JailbreakStatus {
check()
}
func check() -> JailbreakStatus {
if simulateJailbreak {
return .detected(indicators: jailbreakIndicators)
}
return .notDetected
}
func checkIndicator(_ indicator: JailbreakIndicator) -> Bool {
simulateJailbreak && jailbreakIndicators.contains(indicator)
}
}
final class MockRuntimeIntegrityMonitor: RuntimeIntegrityMonitoring {
var simulateViolation: IntegrityViolation?
var hasViolations: Bool {
!checkIntegrity().isEmpty
}
var violations: [IntegrityViolation] {
checkIntegrity()
}
func checkIntegrity() -> [IntegrityViolation] {
if let violation = simulateViolation {
return [violation]
}
return []
}
func checkViolation(_ violation: IntegrityViolation) -> Bool {
simulateViolation == violation
}
}
final class MockSecureEnclaveService: SecureEnclaveServiceProtocol {
var simulateAvailable = true
var isAvailable: Bool {
simulateAvailable
}
func generateKeyPair(accessControl: SecAccessControl?) throws -> String {
"mock-key-" + UUID().uuidString
}
func signData(_ data: Data, withKey keyID: String, requireBiometry: Bool) throws -> Data {
Data("mock-signature".utf8)
}
func verifySignature(_ signature: Data, for data: Data, withKey keyID: String) throws -> Bool {
true
}
func encrypt(_ data: Data, withKey keyID: String) throws -> Data {
data
}
func decrypt(_ ciphertext: Data, withKey keyID: String) throws -> Data {
ciphertext
}
func deleteKey(_ keyID: String) throws {}
func createBiometryProtectedKeychainItem(key: String, value: Data, accessControl: SecAccessControl?) throws {}
func retrieveBiometryProtectedKeychainItem(key: String, context: LAContext?) throws -> Data? {
nil
}
func deleteBiometryProtectedKeychainItem(key: String) throws {}
}
// MARK: - Enhanced Notification Tests
struct EnhancedNotificationPayloadTests {
@Test("NotificationPayload derives type from screen")
func derivesTypeFromScreen() {
let alertPayload = NotificationPayload(userInfo: ["screen": "alerts", "id": "a1"])!
#expect(alertPayload.type == .alert)
#expect(alertPayload.category == .alert)
let exposurePayload = NotificationPayload(userInfo: ["screen": "exposure"])!
#expect(exposurePayload.type == .exposure)
let scanPayload = NotificationPayload(userInfo: ["screen": "scanComplete"])!
#expect(scanPayload.type == .scanComplete)
let familyPayload = NotificationPayload(userInfo: ["screen": "familyInvite"])!
#expect(familyPayload.type == .familyInvite)
let billingPayload = NotificationPayload(userInfo: ["screen": "subscriptionRenewal"])!
#expect(billingPayload.type == .subscriptionRenewal)
let marketingPayload = NotificationPayload(userInfo: ["screen": "marketing"])!
#expect(marketingPayload.type == .marketing)
let unknownPayload = NotificationPayload(userInfo: ["screen": "unknown_screen"])!
#expect(unknownPayload.type == .unknown)
}
@Test("NotificationPayload explicit type overrides screen-derived type")
func explicitType() {
let userInfo: [AnyHashable: Any] = [
"screen": "alerts",
"type": "exposure",
"id": "abc"
]
let payload = NotificationPayload(userInfo: userInfo)!
#expect(payload.type == .exposure)
#expect(payload.screen == "alerts")
}
@Test("NotificationPayload parses metadata")
func parsesMetadata() {
let userInfo: [AnyHashable: Any] = [
"screen": "alerts",
"metadata": ["severity": "critical", "source": "darkweb"]
]
let payload = NotificationPayload(userInfo: userInfo)!
#expect(payload.metadata["severity"] == "critical")
#expect(payload.metadata["source"] == "darkweb")
}
@Test("NotificationPayload parses action URL")
func parsesActionURL() {
let userInfo: [AnyHashable: Any] = [
"screen": "alerts",
"action-url": "kordant://alerts/abc123"
]
let payload = NotificationPayload(userInfo: userInfo)!
#expect(payload.actionURL?.absoluteString == "kordant://alerts/abc123")
}
@Test("NotificationPayload parses critical flag")
func parsesCritical() {
let criticalPayload = NotificationPayload(userInfo: [
"screen": "alerts",
"kordant_critical": true
])!
#expect(criticalPayload.isCritical == true)
let normalPayload = NotificationPayload(userInfo: ["screen": "alerts"])!
#expect(normalPayload.isCritical == false)
}
@Test("NotificationPayload category is inferred from type")
func categoryInferredFromType() {
let alertPayload = NotificationPayload(userInfo: ["screen": "alerts"])!
#expect(alertPayload.category == .alert)
let exposurePayload = NotificationPayload(userInfo: ["screen": "exposure"])!
#expect(exposurePayload.category == .exposure)
}
@Test("NotificationPayload category can be explicitly set")
func explicitCategory() {
let userInfo: [AnyHashable: Any] = [
"screen": "alerts",
"category": "EXPOSURE"
]
let payload = NotificationPayload(userInfo: userInfo)!
#expect(payload.category == .exposure)
}
@Test("NotificationPayload convenience init works")
func convenienceInit() {
let payload = NotificationPayload(
screen: "alerts",
id: "alert-1",
title: "Test Alert",
body: "This is a test",
type: .alert,
category: .alert,
metadata: ["severity": "high"],
isCritical: true
)
#expect(payload.screen == "alerts")
#expect(payload.id == "alert-1")
#expect(payload.title == "Test Alert")
#expect(payload.body == "This is a test")
#expect(payload.type == .alert)
#expect(payload.category == .alert)
#expect(payload.metadata["severity"] == "high")
#expect(payload.isCritical == true)
}
@Test("NotificationType all cases have non-empty display names and icons")
func notificationTypeProperties() {
for type in NotificationType.allCases {
#expect(!type.displayName.isEmpty)
#expect(!type.iconName.isEmpty)
}
}
@Test("NotificationType from screen name")
func notificationTypeFromScreen() {
#expect(NotificationType.from(screen: "alerts") == .alert)
#expect(NotificationType.from(screen: "exposure") == .exposure)
#expect(NotificationType.from(screen: "darkwatch") == .exposure)
#expect(NotificationType.from(screen: "scanComplete") == .scanComplete)
#expect(NotificationType.from(screen: "scan_complete") == .scanComplete)
#expect(NotificationType.from(screen: "familyInvite") == .familyInvite)
#expect(NotificationType.from(screen: "family") == .familyInvite)
#expect(NotificationType.from(screen: "subscriptionRenewal") == .subscriptionRenewal)
#expect(NotificationType.from(screen: "marketing") == .marketing)
#expect(NotificationType.from(screen: "unknown") == .unknown)
}
}
// MARK: - Deep Link Router Tests
struct NotificationDeepLinkRouterTests {
@Test("Route deep link for alerts")
func deepLinkAlerts() {
let url = URL(string: "kordant://alerts/abc123")!
let route = Route(deepLink: url)
#expect(route == .alertDetail(id: "abc123"))
}
@Test("Route deep link for alerts list")
func deepLinkAlertsList() {
let url = URL(string: "kordant://alerts")!
let route = Route(deepLink: url)
#expect(route == .alerts)
}
@Test("Route deep link for dashboard")
func deepLinkDashboard() {
let url = URL(string: "kordant://dashboard")!
let route = Route(deepLink: url)
#expect(route == .dashboard)
}
@Test("Route deep link for settings")
func deepLinkSettings() {
let url = URL(string: "kordant://settings")!
let route = Route(deepLink: url)
#expect(route == .settings)
}
@Test("Route deep link for notifications")
func deepLinkNotifications() {
let url = URL(string: "kordant://notifications")!
let route = Route(deepLink: url)
#expect(route == .notificationSettings)
}
@Test("Route deep link for family")
func deepLinkFamily() {
let url = URL(string: "kordant://family")!
let route = Route(deepLink: url)
#expect(route == .family)
}
@Test("Route deep link for billing")
func deepLinkBilling() {
let url = URL(string: "kordant://billing")!
let route = Route(deepLink: url)
#expect(route == .billing)
}
@Test("Route deep link for scan")
func deepLinkScan() {
let url = URL(string: "kordant://scan")!
let route = Route(deepLink: url)
#expect(route == .scanComplete)
}
@Test("Route deep link for invalid scheme returns nil")
func deepLinkInvalidScheme() {
let url = URL(string: "https://kordant.com/alerts")!
let route = Route(deepLink: url)
#expect(route == nil)
}
@Test("Route deep link unknown host returns nil")
func deepLinkUnknownHost() {
let url = URL(string: "kordant://nonexistent")!
let route = Route(deepLink: url)
#expect(route == nil)
}
@Test("Route from notification payload for all types")
func routeFromAllNotificationTypes() {
// alert
var route = Route(notificationPayload: ["screen": "alerts", "id": "a1"])
#expect(route == .alertDetail(id: "a1"))
// dashboard
route = Route(notificationPayload: ["screen": "dashboard"])
#expect(route == .dashboard)
route = Route(notificationPayload: ["screen": "home"])
#expect(route == .dashboard)
// settings
route = Route(notificationPayload: ["screen": "settings"])
#expect(route == .settings)
// darkwatch
route = Route(notificationPayload: ["screen": "darkwatch"])
#expect(route == .serviceDetail(id: "darkwatch"))
route = Route(notificationPayload: ["screen": "exposure"])
#expect(route == .serviceDetail(id: "darkwatch"))
// family
route = Route(notificationPayload: ["screen": "family"])
#expect(route == .family)
route = Route(notificationPayload: ["screen": "familyInvite"])
#expect(route == .family)
// billing
route = Route(notificationPayload: ["screen": "billing"])
#expect(route == .billing)
route = Route(notificationPayload: ["screen": "subscription"])
#expect(route == .billing)
// scan complete
route = Route(notificationPayload: ["screen": "scanComplete"])
#expect(route == .scanComplete)
}
@Test("NotificationDeepLinkRouter routeForPayload maps all types")
func routerMapsAllTypes() {
let router = NotificationDeepLinkRouter.shared
// Alert with ID alertDetail
var payload = NotificationPayload(screen: "alerts", id: "a1", type: .alert)
#expect(router.routeForPayload(payload) == .alertDetail(id: "a1"))
// Alert without ID alerts list
payload = NotificationPayload(screen: "alerts", type: .alert)
#expect(router.routeForPayload(payload) == .alerts)
// Exposure darkwatch
payload = NotificationPayload(screen: "exposure", type: .exposure)
#expect(router.routeForPayload(payload) == .serviceDetail(id: "darkwatch"))
// Scan complete dashboard
payload = NotificationPayload(screen: "scanComplete", type: .scanComplete)
#expect(router.routeForPayload(payload) == .dashboard)
// Family invite family
payload = NotificationPayload(screen: "familyInvite", type: .familyInvite)
#expect(router.routeForPayload(payload) == .family)
// Subscription billing
payload = NotificationPayload(screen: "subscriptionRenewal", type: .subscriptionRenewal)
#expect(router.routeForPayload(payload) == .billing)
// Marketing dashboard
payload = NotificationPayload(screen: "marketing", type: .marketing)
#expect(router.routeForPayload(payload) == .dashboard)
}
@Test("NotificationDeepLinkRouter routeForPayload marketing with feature metadata")
func routerMarketingWithFeature() {
let router = NotificationDeepLinkRouter.shared
// Marketing with feature metadata pointing to a valid screen
let payload = NotificationPayload(
screen: "marketing",
type: .marketing,
metadata: ["feature": "alerts"]
)
#expect(router.routeForPayload(payload) == .alerts)
}
@Test("NotificationDeepLinkRouter defers navigation when app not ready")
func routerDefersColdStart() async {
let payload = NotificationPayload(screen: "alerts", id: "cold-1", type: .alert)
// Route with appIsReady = false (cold start)
NotificationDeepLinkRouter.shared.route(payload: payload, appIsReady: false)
#expect(NotificationDeepLinkRouter.shared.isProcessingColdStart)
#expect(NotificationDeepLinkRouter.shared.hasPendingNavigation)
// Clear for test isolation
NotificationDeepLinkRouter.shared.clearPendingNavigation()
#expect(!NotificationDeepLinkRouter.shared.hasPendingNavigation)
}
}
// MARK: - Notification Analytics Tests
struct NotificationAnalyticsTests {
@Test("NotificationAnalytics tracks delivery")
func tracksDelivery() {
let analytics = NotificationAnalytics.shared
analytics.resetCounts()
let payload = NotificationPayload(screen: "alerts", id: "a1", type: .alert)
analytics.trackNotificationDelivered(payload: payload)
// Should not crash
#expect(true)
}
@Test("NotificationAnalytics tracks open")
func tracksOpen() {
let analytics = NotificationAnalytics.shared
analytics.resetCounts()
let payload = NotificationPayload(screen: "alerts", id: "a1", type: .alert)
analytics.trackNotificationOpened(payload: payload)
#expect(analytics.openCount(for: .alert) == 1)
}
@Test("NotificationAnalytics tracks conversion")
func tracksConversion() {
let analytics = NotificationAnalytics.shared
analytics.resetCounts()
let payload = NotificationPayload(screen: "exposure", id: "e1", type: .exposure)
analytics.trackNotificationDelivered(payload: payload)
analytics.trackNotificationConversion(payload: payload, action: "viewed")
#expect(analytics.conversionCount(for: .exposure) == 1)
}
@Test("NotificationAnalytics tracks action tap")
func tracksAction() {
let analytics = NotificationAnalytics.shared
let payload = NotificationPayload(screen: "alerts", id: "a1", type: .alert)
// Should not crash
analytics.trackNotificationAction(payload: payload, actionIdentifier: "RESOLVE_ALERT")
#expect(true)
}
@Test("NotificationAnalytics conversion rate calculation")
func conversionRate() {
let analytics = NotificationAnalytics.shared
analytics.resetCounts()
let payload = NotificationPayload(screen: "alerts", id: "a1", type: .alert)
analytics.trackNotificationOpened(payload: payload)
analytics.trackNotificationConversion(payload: payload, action: "resolved")
#expect(analytics.conversionRate(for: .alert) == 1.0)
}
@Test("NotificationAnalytics conversion rate is 0 with no opens")
func conversionRateZero() {
let analytics = NotificationAnalytics.shared
analytics.resetCounts()
#expect(analytics.conversionRate(for: .alert) == 0.0)
}
@Test("NotificationAnalytics A/B variant assignment")
func abVariantAssignment() {
let analytics = NotificationAnalytics.shared
analytics.assignVariant(notificationType: .alert, variant: "variant_a")
#expect(analytics.assignedVariant(for: .alert) == "variant_a")
// Unassigned type returns nil
#expect(analytics.assignedVariant(for: .exposure) == nil)
}
}
// MARK: - Notification Categories Tests
struct NotificationCategorySetupTests {
@Test("NotificationCategorySetup registerAll does not crash")
func registerAll() {
// Should not throw or crash
NotificationCategorySetup.registerAll()
#expect(true)
}
@Test("NotificationCategorySetup category identifiers are correct")
func categoryIdentifiers() {
#expect(NotificationCategoryIdentifier.alert.rawValue == "ALERT")
#expect(NotificationCategoryIdentifier.exposure.rawValue == "EXPOSURE")
#expect(NotificationCategoryIdentifier.scanComplete.rawValue == "SCAN_COMPLETE")
#expect(NotificationCategoryIdentifier.familyInvite.rawValue == "FAMILY_INVITE")
#expect(NotificationCategoryIdentifier.subscriptionRenewal.rawValue == "SUBSCRIPTION_RENEWAL")
#expect(NotificationCategoryIdentifier.marketing.rawValue == "MARKETING")
#expect(NotificationCategoryIdentifier.general.rawValue == "GENERAL")
}
@Test("NotificationActionIdentifier has all cases")
func actionIdentifiers() {
#expect(NotificationActionIdentifier.resolve.rawValue == "RESOLVE_ALERT")
#expect(NotificationActionIdentifier.dismiss.rawValue == "DISMISS")
#expect(NotificationActionIdentifier.viewDetails.rawValue == "VIEW_DETAILS")
#expect(NotificationActionIdentifier.remindLater.rawValue == "REMIND_LATER")
#expect(NotificationActionIdentifier.acceptInvite.rawValue == "ACCEPT_INVITE")
#expect(NotificationActionIdentifier.declineInvite.rawValue == "DECLINE_INVITE")
#expect(NotificationActionIdentifier.manageSubscription.rawValue == "MANAGE_SUBSCRIPTION")
}
@Test("NotificationCategorySetup categoryIdentifier maps types correctly")
func categoryIdentifierMapping() {
#expect(NotificationCategorySetup.categoryIdentifier(for: .alert) == "ALERT")
#expect(NotificationCategorySetup.categoryIdentifier(for: .exposure) == "EXPOSURE")
#expect(NotificationCategorySetup.categoryIdentifier(for: .scanComplete) == "SCAN_COMPLETE")
#expect(NotificationCategorySetup.categoryIdentifier(for: .familyInvite) == "FAMILY_INVITE")
#expect(NotificationCategorySetup.categoryIdentifier(for: .subscriptionRenewal) == "SUBSCRIPTION_RENEWAL")
#expect(NotificationCategorySetup.categoryIdentifier(for: .marketing) == "MARKETING")
#expect(NotificationCategorySetup.categoryIdentifier(for: .unknown) == "GENERAL")
}
}
// MARK: - Notification Type Preferences Tests
struct NotificationTypePreferenceTests {
@Test("NotificationTypePreference has default values")
func defaultValues() {
let pref = NotificationTypePreference(type: .alert)
#expect(pref.type == .alert)
#expect(pref.isEnabled)
#expect(pref.soundEnabled)
#expect(pref.badgeEnabled)
#expect(pref.id == "alert")
}
@Test("NotificationPreferences has default for all types")
func defaultPreferences() {
let prefs = NotificationPreferences.default
#expect(prefs.globalEnabled)
#expect(prefs.criticalAlertsEnabled)
#expect(!prefs.quietHoursEnabled)
#expect(prefs.groupByType)
#expect(prefs.typePreferences.contains { $0.type == .alert })
#expect(prefs.typePreferences.contains { $0.type == .exposure })
#expect(prefs.typePreferences.contains { $0.type == .scanComplete })
#expect(prefs.typePreferences.contains { $0.type == .familyInvite })
#expect(prefs.typePreferences.contains { $0.type == .subscriptionRenewal })
#expect(prefs.typePreferences.contains { $0.type == .marketing })
// Unknown type should NOT be in default preferences
#expect(!prefs.typePreferences.contains { $0.type == .unknown })
}
@Test("NotificationPreferences Codable round-trip")
func codableRoundTrip() throws {
let original = NotificationPreferences.default
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(NotificationPreferences.self, from: data)
#expect(original == decoded)
}
}
// MARK: - Notification Route (new extension) Tests
struct NotificationScreenRouteTests {
@Test("Route init from notification screen")
func initFromScreen() {
#expect(Route(notificationScreen: "dashboard", id: nil) == .dashboard)
#expect(Route(notificationScreen: "home", id: nil) == .dashboard)
#expect(Route(notificationScreen: "alerts", id: "a1") == .alertDetail(id: "a1"))
#expect(Route(notificationScreen: "alerts", id: nil) == .alerts)
#expect(Route(notificationScreen: "settings", id: nil) == .settings)
#expect(Route(notificationScreen: "family", id: nil) == .family)
#expect(Route(notificationScreen: "billing", id: nil) == .billing)
#expect(Route(notificationScreen: "scanComplete", id: nil) == .scanComplete)
#expect(Route(notificationScreen: "notifications", id: nil) == .notificationSettings)
#expect(Route(notificationScreen: "darkwatch", id: nil) == .serviceDetail(id: "darkwatch"))
#expect(Route(notificationScreen: "voiceprint", id: nil) == .serviceDetail(id: "voiceprint"))
#expect(Route(notificationScreen: "unknown_screen", id: nil) == .serviceDetail(id: "unknown_screen"))
}
}
// MARK: - Token Refresh & Session Management Tests
/// Creates a mock JWT token with a custom expiry time.
/// The token is base64-encoded JSON payload with standard JWT structure.
private func makeMockJWT(expiry: Date) -> String {
let header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"
let payload = "{\"sub\":\"1\",\"exp\":\(Int(expiry.timeIntervalSince1970))}"
let headerBase64 = header.data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let payloadBase64 = payload.data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let signature = "mock-signature".data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
return "\(headerBase64).\(payloadBase64).\(signature)"
}
// MARK: - JWT Expiry Calculation Tests
struct JWTExpiryTests {
@MainActor
private func makeService() -> AuthService {
AuthService(
keychain: MockKeychainService(),
apiClient: MockAuthAPIClient()
)
}
@Test("calculateTokenExpiry parses valid JWT expiry")
@MainActor
func validExpiry() {
let service = makeService()
let expiry = Date(timeIntervalSinceNow: 3600) // 1 hour from now
let token = makeMockJWT(expiry: expiry)
let parsedExpiry = service.calculateTokenExpiry(from: token)
#expect(parsedExpiry != nil)
#expect(abs(parsedExpiry!.timeIntervalSince(expiry)) < 1.0)
}
@Test("calculateTokenExpiry returns nil for invalid token")
@MainActor
func invalidToken() {
let service = makeService()
let parsedExpiry = service.calculateTokenExpiry(from: "not-a-valid-token")
#expect(parsedExpiry == nil)
}
@Test("calculateTokenExpiry returns nil for token without exp claim")
@MainActor
func noExpClaim() {
let service = makeService()
// Create token without exp claim
let header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"
let payload = "{\"sub\":\"1\"}"
let headerBase64 = header.data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let payloadBase64 = payload.data(using: .utf8)!.base64EncodedString()
.replacingOccurrences(of: "=", with: "").replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
let token = "\(headerBase64).\(payloadBase64).mock"
let parsedExpiry = service.calculateTokenExpiry(from: token)
#expect(parsedExpiry == nil)
}
@Test("calculateTokenExpiry handles base64url padding")
@MainActor
func base64urlPadding() {
let service = makeService()
let expiry = Date(timeIntervalSinceNow: 7200)
let token = makeMockJWT(expiry: expiry)
let parsedExpiry = service.calculateTokenExpiry(from: token)
#expect(parsedExpiry != nil)
}
}
// MARK: - Session State Tests
struct SessionStateTests {
@MainActor
private func makeService() -> AuthService {
AuthService(
keychain: MockKeychainService(),
apiClient: MockAuthAPIClient()
)
}
@Test("SessionState starts as unauthenticated")
@MainActor
func initialSessionState() {
let service = makeService()
#expect(service.sessionState == .unauthenticated)
}
@Test("currentSessionState returns unauthenticated when not logged in")
@MainActor
func unauthenticatedState() {
let service = makeService()
#expect(service.currentSessionState() == .unauthenticated)
}
@Test("currentSessionState returns valid with expiry after login")
@MainActor
func validStateAfterLogin() async {
let keychain = MockKeychainService()
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = AuthService(keychain: keychain, apiClient: client)
await service.login(email: "test@example.com", password: "password123")
#expect(service.state == .authenticated)
// The mock token "mock-token" won't parse as JWT, so expiry will be nil
let sessionState = service.currentSessionState()
#expect(sessionState == .valid(expiry: nil))
}
@Test("currentSessionState returns expiring when within buffer")
@MainActor
func expiringState() {
// This tests the logic directly by manipulating internal state
let service = makeService()
service.state = .authenticated
// Set expiry to 3 minutes from now (within 5-min buffer)
service.tokenExpiry = Date(timeIntervalSinceNow: 3 * 60)
let sessionState = service.currentSessionState()
#expect(sessionState == .expiring(expiry: service.tokenExpiry!))
}
@Test("currentSessionState returns expired when past expiry")
@MainActor
func expiredState() {
let service = makeService()
service.state = .authenticated
service.tokenExpiry = Date(timeIntervalSinceNow: -60) // 1 minute ago
let sessionState = service.currentSessionState()
#expect(sessionState == .expired)
}
@Test("currentSessionState returns refreshing when refresh in progress")
@MainActor
func refreshingState() {
let service = makeService()
service.state = .authenticated
service.tokenExpiry = Date(timeIntervalSinceNow: 3600)
service.isRefreshing = true
let sessionState = service.currentSessionState()
#expect(sessionState == .refreshing)
}
}
// MARK: - Token Refresh Logic Tests
struct TokenRefreshTests {
@MainActor
private func makeService(shouldRefreshSucceed: Bool = true) -> (AuthService, MockAuthAPIClient, MockKeychainService) {
let keychain = MockKeychainService()
let client = MockAuthAPIClient()
client.shouldSucceed = shouldRefreshSucceed
let service = AuthService(keychain: keychain, apiClient: client)
return (service, client, keychain)
}
@Test("attemptSilentRefresh succeeds and returns true")
@MainActor
func silentRefreshSuccess() async {
let (service, client, keychain) = makeService(shouldRefreshSucceed: true)
// Pre-populate refresh token
try? keychain.store(key: "refreshToken", value: Data("valid-refresh-token".utf8))
let result = await service.attemptSilentRefresh()
#expect(result == true)
#expect(client.lastRefreshToken == "valid-refresh-token")
}
@Test("attemptSilentRefresh fails when no refresh token stored")
@MainActor
func silentRefreshNoToken() async {
let (service, _, _) = makeService(shouldRefreshSucceed: true)
let result = await service.attemptSilentRefresh()
#expect(result == false)
}
@Test("attemptSilentRefresh fails when API returns error")
@MainActor
func silentRefreshAPIError() async {
let (service, _, keychain) = makeService(shouldRefreshSucceed: false)
// Pre-populate refresh token
try? keychain.store(key: "refreshToken", value: Data("expired-refresh-token".utf8))
let result = await service.attemptSilentRefresh()
#expect(result == false)
}
@Test("attemptSilentRefresh prevents concurrent attempts")
@MainActor
func concurrentRefreshPrevention() async {
let (service, _, keychain) = makeService(shouldRefreshSucceed: true)
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
// Simulate refresh in progress
service.isRefreshing = true
let result = await service.attemptSilentRefresh()
#expect(result == false)
}
@Test("attemptSilentRefresh updates APIClient auth token")
@MainActor
func silentRefreshUpdatesAuthToken() async {
let (service, _, keychain) = makeService(shouldRefreshSucceed: true)
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
APIClient.shared.authToken = "old-token"
_ = await service.attemptSilentRefresh()
#expect(APIClient.shared.authToken == "mock-token")
}
@Test("attemptSilentRefresh stores new tokens in keychain")
@MainActor
func silentRefreshStoresNewTokens() async {
let (service, _, keychain) = makeService(shouldRefreshSucceed: true)
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
_ = await service.attemptSilentRefresh()
let newJwt = try? keychain.retrieve(key: "jwt")
let newRefresh = try? keychain.retrieve(key: "refreshToken")
#expect(newJwt == Data("mock-token".utf8))
#expect(newRefresh == Data("new-refresh-token".utf8))
}
}
// MARK: - TokenRefreshHandler Tests
struct TokenRefreshHandlerTests {
@MainActor
private func makeService() -> (AuthService, MockAuthAPIClient, MockKeychainService) {
let keychain = MockKeychainService()
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = AuthService(keychain: keychain, apiClient: client)
return (service, client, keychain)
}
@Test("handleTokenRefresh succeeds with silent refresh")
@MainActor
func handleRefreshSuccess() async throws {
let (service, _, keychain) = makeService()
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
let token = try await service.handleTokenRefresh()
#expect(token == "mock-token")
}
@Test("handleTokenRefresh throws when refresh fails")
@MainActor
func handleRefreshFailure() async {
let keychain = MockKeychainService()
let client = MockAuthAPIClient()
client.shouldSucceed = false
let service = AuthService(keychain: keychain, apiClient: client)
await #expect(throws: APIError.unauthorized) {
_ = try await service.handleTokenRefresh()
}
}
@Test("handleSessionExpired triggers onSessionExpired callback")
@MainActor
func sessionExpiredCallback() {
let service = makeService().0
var callbackFired = false
service.onSessionExpired = { callbackFired = true }
service.handleSessionExpired()
#expect(callbackFired)
#expect(service.state == .unauthenticated)
}
}
// MARK: - Foreground Refresh Tests
struct ForegroundRefreshTests {
@MainActor
private func makeService() -> (AuthService, MockAuthAPIClient, MockKeychainService) {
let keychain = MockKeychainService()
let client = MockAuthAPIClient()
client.shouldSucceed = true
let service = AuthService(keychain: keychain, apiClient: client)
return (service, client, keychain)
}
@Test("refreshOnForeground skips when token is far from expiry")
@MainActor
func skipWhenFarFromExpiry() async {
let (service, client, _) = makeService()
service.state = .authenticated
service.tokenExpiry = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
await service.refreshOnForeground()
// Should NOT have called refresh since token is valid for 1 hour
#expect(client.lastRefreshToken == nil)
}
@Test("refreshOnForeground refreshes when within buffer")
@MainActor
func refreshWhenWithinBuffer() async {
let (service, client, keychain) = makeService()
service.state = .authenticated
service.tokenExpiry = Date(timeIntervalSinceNow: 3 * 60) // 3 minutes (within 5-min buffer)
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
await service.refreshOnForeground()
// Should have triggered a refresh
#expect(client.lastRefreshToken == "refresh-token")
}
@Test("refreshOnForeground refreshes when expired")
@MainActor
func refreshWhenExpired() async {
let (service, client, keychain) = makeService()
service.state = .authenticated
service.tokenExpiry = Date(timeIntervalSinceNow: -60) // 1 minute ago
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
await service.refreshOnForeground()
#expect(client.lastRefreshToken == "refresh-token")
}
@Test("refreshOnForeground refreshes when no expiry known")
@MainActor
func refreshWhenNoExpiry() async {
let (service, client, keychain) = makeService()
service.state = .authenticated
service.tokenExpiry = nil
try? keychain.store(key: "refreshToken", value: Data("refresh-token".utf8))
await service.refreshOnForeground()
#expect(client.lastRefreshToken == "refresh-token")
}
@Test("refreshOnForeground does nothing when unauthenticated")
@MainActor
func skipWhenUnauthenticated() async {
let (service, client, _) = makeService()
service.state = .unauthenticated
await service.refreshOnForeground()
#expect(client.lastRefreshToken == nil)
}
}
// MARK: - Session Expiry Tests
struct SessionExpiryTests {
@MainActor
private func makeService() -> AuthService {
AuthService(
keychain: MockKeychainService(),
apiClient: MockAuthAPIClient()
)
}
@Test("forceLogout clears all session state")
@MainActor
func forceLogoutClearsState() {
let service = makeService()
service.state = .authenticated
service.currentUser = User(id: "1", name: "Test", email: "test@example.com")
service.sessionState = .valid(expiry: Date(timeIntervalSinceNow: 3600))
service.forceLogout()
#expect(service.state == .unauthenticated)
#expect(service.sessionState == .unauthenticated)
#expect(service.currentUser == nil)
#expect(service.tokenExpiry == nil)
#expect(service.isRefreshing == false)
}
@Test("forceLogout calls onSessionExpired callback")
@MainActor
func forceLogoutCallsCallback() {
let service = makeService()
var callbackFired = false
service.onSessionExpired = { callbackFired = true }
service.forceLogout()
#expect(callbackFired)
}
@Test("logout clears APIClient auth token")
@MainActor
func logoutClearsAuthToken() async {
let (service, client, keychain) = (
AuthService(keychain: MockKeychainService(), apiClient: MockAuthAPIClient()),
MockAuthAPIClient(),
MockKeychainService()
)
client.shouldSucceed = true
service.state = .authenticated
APIClient.shared.authToken = "some-token"
service.logout()
// Give it time to complete
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(APIClient.shared.authToken == nil)
}
}
// MARK: - APIClient Token Refresh Interceptor Tests
struct APIClientTokenRefreshTests {
private func makeSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: config)
}
@Test("APIClient queues requests during token refresh")
func queuesRequestsDuringRefresh() async throws {
let session = makeSession()
let client = APIClient(session: session)
client.authToken = "expired-token"
// Set up a mock refresh handler
var refreshCalled = false
client.tokenRefreshHandler = object_any: TokenRefreshHandler {
// We'll use a simple mock
return MockTokenRefreshHandler {
refreshCalled = true
return "new-token"
}
}
var requestCount = 0
MockURLProtocol.requestHandler = { _ in
requestCount += 1
if requestCount == 1 {
// First request gets 401
let response = HTTPURLResponse(url: URL(string: "http://test")!, statusCode: 401, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
// Subsequent requests succeed
let response = HTTPURLResponse(url: URL(string: "http://test")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, Data("\"ok\"".utf8))
}
do {
let _: String = try await client.rawRequest("/test", method: "GET")
#expect(refreshCalled)
} catch {
// If refresh handler isn't set up, it will fail that's expected in this test
// The key thing is that the 401 detection and refresh attempt logic runs
}
}
@Test("APIClient detects 401 and throws unauthorized")
func detects401() async throws {
let session = makeSession()
let client = APIClient(session: session)
client.authToken = "expired-token"
MockURLProtocol.requestHandler = { _ in
let response = HTTPURLResponse(url: URL(string: "http://test")!, statusCode: 401, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
// Without a refresh handler, the 401 should propagate as unauthorized
// (after the failed refresh attempt)
do {
_ = try await client.rawRequest("/test", method: "GET")
#expect(false, "Expected unauthorized error")
} catch APIError.unauthorized {
#expect(true)
} catch {
// Refresh failure also acceptable handler not configured
#expect(true)
}
}
@Test("APIClient retry count prevents infinite refresh loops")
func noInfiniteRefreshLoop() async throws {
let session = makeSession()
let config = APIConfig(maxRetries: 3)
let client = APIClient(config: config, session: session)
client.authToken = "expired-token"
var attemptCount = 0
MockURLProtocol.requestHandler = { _ in
attemptCount += 1
let response = HTTPURLResponse(url: URL(string: "http://test")!, statusCode: 401, httpVersion: nil, headerFields: nil)!
return (response, Data())
}
do {
_ = try await client.rawRequest("/test", method: "GET")
} catch {
// Should not loop infinitely max attempts is respected
#expect(attemptCount <= 3)
}
}
}
// MARK: - Mock Token Refresh Handler
private class MockTokenRefreshHandler: TokenRefreshHandler {
private let handler: () -> String
init(_ handler: @escaping () -> String) {
self.handler = handler
}
func handleTokenRefresh() async throws -> String {
handler()
}
func handleSessionExpired() {
// No-op for tests
}
}
// MARK: - Session Restoration Tests
struct SessionRestorationTests {
@MainActor
private func makeService() -> (AuthService, MockAuthAPIClient, MockKeychainService) {
let keychain = MockKeychainService()
let client = MockAuthAPIClient()
let service = AuthService(keychain: keychain, apiClient: client)
return (service, client, keychain)
}
@Test("restoreSession restores authenticated state from keychain")
@MainActor
func restoreAuthenticatedState() {
let (service, _, keychain) = makeService()
// Simulate stored session
try? keychain.store(key: "jwt", value: Data("stored-token".utf8))
let userData = try! JSONEncoder().encode(User(id: "1", name: "Test", email: "test@example.com"))
try? keychain.store(key: "currentUser", value: userData)
service.restoreSession()
#expect(service.state == .authenticated)
#expect(service.currentUser?.id == "1")
}
@Test("restoreSession stays unauthenticated without stored token")
@MainActor
func restoreNoToken() {
let (service, _, _) = makeService()
service.restoreSession()
#expect(service.state == .unauthenticated)
#expect(service.sessionState == .unauthenticated)
}
@Test("restoreSession sets APIClient auth token")
@MainActor
func restoreSetsAuthToken() {
let (service, _, keychain) = makeService()
try? keychain.store(key: "jwt", value: Data("stored-token".utf8))
service.restoreSession()
#expect(APIClient.shared.authToken == "stored-token")
}
@Test("restoreSession restores token expiry")
@MainActor
func restoreTokenExpiry() {
let (service, _, keychain) = makeService()
try? keychain.store(key: "jwt", value: Data("stored-token".utf8))
let expiry = Date(timeIntervalSinceNow: 3600)
let formatter = ISO8601DateFormatter()
try? keychain.store(key: "tokenExpiry", value: Data(formatter.string(from: expiry).utf8))
service.restoreSession()
#expect(service.tokenExpiry != nil)
#expect(abs(service.tokenExpiry!.timeIntervalSince(expiry)) < 1.0)
}
}
// MARK: - Session Callback Tests
struct SessionCallbackTests {
@MainActor
private func makeService() -> AuthService {
AuthService(
keychain: MockKeychainService(),
apiClient: MockAuthAPIClient()
)
}
@Test("onSessionExpiring callback fires when scheduling near expiry")
@MainActor
func onSessionExpiringCallback() {
let service = makeService()
var callbackFired = false
service.onSessionExpiring = { callbackFired = true }
// Schedule a refresh with an expiry that's within the buffer
let expiry = Date(timeIntervalSinceNow: 4 * 60) // 4 minutes (within 5-min buffer)
service.scheduleTokenRefresh(expiry: expiry)
#expect(callbackFired)
}
@Test("onSessionExpiring callback does not fire when far from expiry")
@MainActor
func noExpiringCallbackWhenFar() {
let service = makeService()
var callbackFired = false
service.onSessionExpiring = { callbackFired = true }
// Schedule a refresh with an expiry far in the future
let expiry = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
service.scheduleTokenRefresh(expiry: expiry)
#expect(!callbackFired)
}
}