- 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
4245 lines
145 KiB
Swift
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)
|
|
}
|
|
}
|
|
|