1915 lines
64 KiB
Swift
1915 lines
64 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
|
|
|
|
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.notImplemented
|
|
}
|
|
|
|
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.notImplemented
|
|
}
|
|
|
|
func resetPassword(email: String) async throws {
|
|
if !shouldSucceed {
|
|
throw APIError.notImplemented
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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?
|
|
|
|
func callProcedure<T: Decodable>(path: String, input: (any Encodable)?) async throws -> T {
|
|
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: - 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("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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|