diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.android.kt new file mode 100644 index 0000000..080223f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.android.kt @@ -0,0 +1,57 @@ +package com.example.casera.platform + +import android.app.Activity +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import com.example.casera.ui.subscription.UpgradeScreen +import kotlinx.coroutines.launch + +@Composable +actual fun PlatformUpgradeScreen( + onNavigateBack: () -> Unit, + onSubscriptionChanged: () -> Unit +) { + val context = LocalContext.current + val activity = context as? Activity + val billingManager = remember { BillingManager.getInstance(context) } + val scope = rememberCoroutineScope() + + // Load products on launch + LaunchedEffect(Unit) { + billingManager.startConnection( + onSuccess = { + scope.launch { billingManager.loadProducts() } + } + ) + } + + // Watch for successful purchase + val purchasedProductIDs by billingManager.purchasedProductIDs.collectAsState() + var initialPurchaseCount by remember { mutableStateOf(purchasedProductIDs.size) } + + LaunchedEffect(purchasedProductIDs) { + if (purchasedProductIDs.size > initialPurchaseCount) { + onSubscriptionChanged() + onNavigateBack() + } + } + + UpgradeScreen( + onNavigateBack = onNavigateBack, + onPurchase = { planId -> + val product = billingManager.getProduct(planId) + if (product != null && activity != null) { + billingManager.launchPurchaseFlow(activity, product) + } + }, + onRestorePurchases = { + scope.launch { + val restored = billingManager.restorePurchases() + if (restored) { + onSubscriptionChanged() + onNavigateBack() + } + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index 3d4aef9..505b253 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -44,7 +44,6 @@ import com.example.casera.ui.screens.MainScreen import com.example.casera.ui.screens.ManageUsersScreen import com.example.casera.ui.screens.NotificationPreferencesScreen import com.example.casera.ui.screens.ProfileScreen -import com.example.casera.ui.subscription.UpgradeScreen import com.example.casera.ui.theme.MyCribTheme import com.example.casera.ui.theme.ThemeManager import com.example.casera.navigation.* @@ -59,6 +58,7 @@ import com.example.casera.network.AuthApi import com.example.casera.data.DataManager import com.example.casera.network.APILayer import com.example.casera.platform.ContractorImportHandler +import com.example.casera.platform.PlatformUpgradeScreen import com.example.casera.platform.ResidenceImportHandler import casera.composeapp.generated.resources.Res @@ -613,16 +613,12 @@ fun App( } composable { - UpgradeScreen( + PlatformUpgradeScreen( onNavigateBack = { navController.popBackStack() }, - onPurchase = { planId -> - // Handle purchase - integrate with billing system - navController.popBackStack() - }, - onRestorePurchases = { - // Handle restore - integrate with billing system + onSubscriptionChanged = { + // Subscription state updated via DataManager } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt index e3b44a0..5a52aef 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Subscription.kt @@ -63,9 +63,33 @@ data class PurchaseVerificationRequest( @SerialName("product_id") val productId: String ) +/** + * Nested subscription info returned by backend purchase/restore endpoints. + */ +@Serializable +data class VerificationSubscriptionInfo( + val tier: String = "", + @SerialName("subscribed_at") val subscribedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("auto_renew") val autoRenew: Boolean = true, + @SerialName("cancelled_at") val cancelledAt: String? = null, + val platform: String = "", + @SerialName("is_active") val isActive: Boolean = false, + @SerialName("is_pro") val isPro: Boolean = false +) + +/** + * Response from backend purchase/restore endpoints. + * Backend returns: { "message": "...", "subscription": { "tier": "pro", ... } } + */ @Serializable data class VerificationResponse( - val success: Boolean, - val tier: String? = null, - val error: String? = null -) + val message: String = "", + val subscription: VerificationSubscriptionInfo? = null +) { + /** Backward-compatible: success when subscription is present */ + val success: Boolean get() = subscription != null + + /** Backward-compatible: tier extracted from nested subscription */ + val tier: String? get() = subscription?.tier +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 114b8dd..ca6cd2b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -153,6 +153,16 @@ object APILayer { println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}") } + // Load feature benefits (auth required) + println("πŸ”„ Fetching feature benefits...") + val featureBenefitsResult = subscriptionApi.getFeatureBenefits(token) + if (featureBenefitsResult is ApiResult.Success) { + println("βœ… Feature benefits loaded: ${featureBenefitsResult.data.size} features") + DataManager.setFeatureBenefits(featureBenefitsResult.data) + } else if (featureBenefitsResult is ApiResult.Error) { + println("❌ Failed to fetch feature benefits: ${featureBenefitsResult.message}") + } + // Load contractors if cache is empty or stale if (!DataManager.isCacheValid(DataManager.contractorsCacheTime)) { println("πŸ”„ Fetching contractors...") @@ -1391,6 +1401,18 @@ object APILayer { return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId) } + /** + * Fetch feature benefits from backend (requires auth). + */ + suspend fun getFeatureBenefits(): ApiResult> { + val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) + val result = subscriptionApi.getFeatureBenefits(token) + if (result is ApiResult.Success) { + DataManager.setFeatureBenefits(result.data) + } + return result + } + // ==================== Helper Methods ==================== /** diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt index d57ebea..79fce5f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/SubscriptionApi.kt @@ -42,9 +42,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getFeatureBenefits(): ApiResult> { + suspend fun getFeatureBenefits(token: String): ApiResult> { return try { - val response = client.get("$baseUrl/subscription/features/") + val response = client.get("$baseUrl/subscription/features/") { + header("Authorization", "Token $token") + } if (response.status.isSuccess()) { ApiResult.Success(response.body()) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.kt new file mode 100644 index 0000000..3cce837 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.kt @@ -0,0 +1,15 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable + +/** + * Platform-specific upgrade screen composable. + * On Android, wires BillingManager for Google Play purchases. + * On iOS, purchase flow is handled in Swift via StoreKitManager. + * On other platforms, shows the common UpgradeScreen with backend-only restore. + */ +@Composable +expect fun PlatformUpgradeScreen( + onNavigateBack: () -> Unit, + onSubscriptionChanged: () -> Unit +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt index ccce286..cc3a360 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/MainScreen.kt @@ -15,8 +15,8 @@ import com.example.casera.navigation.* import com.example.casera.repository.LookupsRepository import com.example.casera.models.Residence import com.example.casera.models.TaskDetail +import com.example.casera.platform.PlatformUpgradeScreen import com.example.casera.storage.TokenStorage -import com.example.casera.ui.subscription.UpgradeScreen import com.example.casera.ui.theme.* import casera.composeapp.generated.resources.* import kotlinx.serialization.json.Json @@ -315,16 +315,12 @@ fun MainScreen( composable { Box(modifier = Modifier.fillMaxSize()) { - UpgradeScreen( + PlatformUpgradeScreen( onNavigateBack = { navController.popBackStack() }, - onPurchase = { planId -> - // Handle purchase - integrate with billing system - navController.popBackStack() - }, - onRestorePurchases = { - // Handle restore - integrate with billing system + onSubscriptionChanged = { + // Subscription state updated via DataManager } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt index dea5012..d4f712f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt @@ -22,8 +22,7 @@ import com.example.casera.models.ResidenceCreateRequest import com.example.casera.models.ResidenceType import com.example.casera.models.ResidenceUser import com.example.casera.network.ApiResult -import com.example.casera.network.ResidenceApi -import com.example.casera.storage.TokenStorage +import com.example.casera.network.APILayer import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.AnalyticsEvents import com.example.casera.ui.theme.* @@ -78,25 +77,21 @@ fun ResidenceFormScreen( var userToRemove by remember { mutableStateOf(null) } var showRemoveUserConfirmation by remember { mutableStateOf(false) } - val residenceApi = remember { ResidenceApi() } val scope = rememberCoroutineScope() // Load users when in edit mode and user is owner LaunchedEffect(isEditMode, isCurrentUserOwner, existingResidence?.id) { if (isEditMode && isCurrentUserOwner && existingResidence != null) { isLoadingUsers = true - val token = TokenStorage.getToken() - if (token != null) { - when (val result = residenceApi.getResidenceUsers(token, existingResidence.id)) { - is ApiResult.Success -> { - // Filter out the owner from the list - users = result.data.filter { it.id != existingResidence.ownerId } - } - is ApiResult.Error -> { - // Silently fail - users list will be empty - } - else -> {} + when (val result = APILayer.getResidenceUsers(existingResidence.id)) { + is ApiResult.Success -> { + // Filter out the owner from the list + users = result.data.filter { it.id != existingResidence.ownerId } } + is ApiResult.Error -> { + // Silently fail - users list will be empty + } + else -> {} } isLoadingUsers = false } @@ -460,9 +455,8 @@ fun ResidenceFormScreen( onClick = { userToRemove?.let { user -> scope.launch { - val token = TokenStorage.getToken() - if (token != null && existingResidence != null) { - when (residenceApi.removeUser(token, existingResidence.id, user.id)) { + if (existingResidence != null) { + when (APILayer.removeUser(existingResidence.id, user.id)) { is ApiResult.Success -> { users = users.filter { it.id != user.id } } diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.ios.kt new file mode 100644 index 0000000..3b989d7 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.ios.kt @@ -0,0 +1,33 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import com.example.casera.network.APILayer +import com.example.casera.ui.subscription.UpgradeScreen +import kotlinx.coroutines.launch + +/** + * iOS: Purchase flow is handled in Swift via StoreKitManager. + * Restore calls backend to refresh subscription status. + */ +@Composable +actual fun PlatformUpgradeScreen( + onNavigateBack: () -> Unit, + onSubscriptionChanged: () -> Unit +) { + val scope = rememberCoroutineScope() + + UpgradeScreen( + onNavigateBack = onNavigateBack, + onPurchase = { _ -> + // iOS purchase flow is handled by StoreKitManager in Swift layer + onNavigateBack() + }, + onRestorePurchases = { + scope.launch { + APILayer.getSubscriptionStatus(forceRefresh = true) + onSubscriptionChanged() + } + } + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt index 2ca511e..f291118 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TokenManager.ios.kt @@ -3,33 +3,71 @@ package com.example.casera.storage import platform.Foundation.NSUserDefaults import kotlin.concurrent.Volatile +/** + * Protocol for iOS Keychain operations. Implemented in Swift (KeychainHelper) + * and injected before DataManager initialization. + * + * Kotlin/Native cannot directly use the Security framework (SecItem* APIs) + * because CFStringRef keys like kSecClass don't bridge to NSCopying. + */ +interface KeychainDelegate { + fun save(key: String, value: String): Boolean + fun get(key: String): String? + fun delete(key: String): Boolean +} + /** * iOS implementation of TokenManager. * - * SECURITY NOTE: Currently uses NSUserDefaults for token storage. - * For production hardening, migrate to iOS Keychain via a Swift helper - * exposed to KMP through an expect/actual boundary or SKIE bridge. - * NSUserDefaults is not encrypted and should not store long-lived auth tokens - * in apps handling sensitive data. + * Uses iOS Keychain via [KeychainDelegate] for secure token storage. + * Falls back to NSUserDefaults if delegate is not set (should not happen + * in production β€” delegate is set in iOSApp.init before DataManager init). * - * Migration plan: - * 1. Create a Swift KeychainHelper class with save/get/delete methods - * 2. Expose it to Kotlin via SKIE or a protocol-based expect/actual - * 3. Use service "com.tt.casera", account "auth_token" + * On first read, migrates any existing NSUserDefaults token to Keychain. */ actual class TokenManager { private val prefs = NSUserDefaults.standardUserDefaults actual fun saveToken(token: String) { - prefs.setObject(token, forKey = TOKEN_KEY) - prefs.synchronize() + val delegate = keychainDelegate + if (delegate != null) { + delegate.save(TOKEN_KEY, token) + // Clean up old NSUserDefaults entry if it exists + prefs.removeObjectForKey(TOKEN_KEY) + prefs.synchronize() + } else { + // Fallback (should not happen in production) + prefs.setObject(token, forKey = TOKEN_KEY) + prefs.synchronize() + } } actual fun getToken(): String? { + val delegate = keychainDelegate + + // Try Keychain first + if (delegate != null) { + val keychainToken = delegate.get(TOKEN_KEY) + if (keychainToken != null) return keychainToken + + // Check NSUserDefaults for migration + val oldToken = prefs.stringForKey(TOKEN_KEY) + if (oldToken != null) { + // Migrate to Keychain + delegate.save(TOKEN_KEY, oldToken) + prefs.removeObjectForKey(TOKEN_KEY) + prefs.synchronize() + return oldToken + } + return null + } + + // Fallback to NSUserDefaults (should not happen in production) return prefs.stringForKey(TOKEN_KEY) } actual fun clearToken() { + keychainDelegate?.delete(TOKEN_KEY) prefs.removeObjectForKey(TOKEN_KEY) prefs.synchronize() } @@ -37,6 +75,12 @@ actual class TokenManager { companion object { private const val TOKEN_KEY = "auth_token" + /** + * Set from Swift in iOSApp.init() BEFORE DataManager.initialize(). + * This enables Keychain storage for auth tokens. + */ + var keychainDelegate: KeychainDelegate? = null + @Volatile private var instance: TokenManager? = null diff --git a/composeApp/src/jsMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.js.kt b/composeApp/src/jsMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.js.kt new file mode 100644 index 0000000..d58c8b0 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.js.kt @@ -0,0 +1,16 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.ui.subscription.UpgradeScreen + +@Composable +actual fun PlatformUpgradeScreen( + onNavigateBack: () -> Unit, + onSubscriptionChanged: () -> Unit +) { + UpgradeScreen( + onNavigateBack = onNavigateBack, + onPurchase = { _ -> onNavigateBack() }, + onRestorePurchases = { } + ) +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.jvm.kt new file mode 100644 index 0000000..d58c8b0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.jvm.kt @@ -0,0 +1,16 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.ui.subscription.UpgradeScreen + +@Composable +actual fun PlatformUpgradeScreen( + onNavigateBack: () -> Unit, + onSubscriptionChanged: () -> Unit +) { + UpgradeScreen( + onNavigateBack = onNavigateBack, + onPurchase = { _ -> onNavigateBack() }, + onRestorePurchases = { } + ) +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.wasmJs.kt new file mode 100644 index 0000000..d58c8b0 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/platform/PlatformUpgradeScreen.wasmJs.kt @@ -0,0 +1,16 @@ +package com.example.casera.platform + +import androidx.compose.runtime.Composable +import com.example.casera.ui.subscription.UpgradeScreen + +@Composable +actual fun PlatformUpgradeScreen( + onNavigateBack: () -> Unit, + onSubscriptionChanged: () -> Unit +) { + UpgradeScreen( + onNavigateBack = onNavigateBack, + onPurchase = { _ -> onNavigateBack() }, + onRestorePurchases = { } + ) +} diff --git a/iosApp/CaseraTests/TaskMetricsTests.swift b/iosApp/CaseraTests/TaskMetricsTests.swift index 6e7ce35..651f9bb 100644 --- a/iosApp/CaseraTests/TaskMetricsTests.swift +++ b/iosApp/CaseraTests/TaskMetricsTests.swift @@ -5,8 +5,9 @@ // Unit tests for WidgetDataManager.TaskMetrics and task categorization logic. // +import Foundation import Testing -@testable import iosApp +@testable import Casera // MARK: - Column Name Constants Tests diff --git a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift index 143b73b..39a6a89 100644 --- a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift +++ b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift @@ -13,6 +13,7 @@ struct AccessibilityIdentifiers { static let forgotPasswordButton = "Login.ForgotPasswordButton" static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle" static let appleSignInButton = "Login.AppleSignInButton" + static let googleSignInButton = "Login.GoogleSignInButton" // Registration static let registerUsernameField = "Register.UsernameField" @@ -35,6 +36,7 @@ struct AccessibilityIdentifiers { static let contractorsTab = "TabBar.Contractors" static let documentsTab = "TabBar.Documents" static let profileTab = "TabBar.Profile" + static let settingsButton = "Navigation.SettingsButton" static let backButton = "Navigation.BackButton" } diff --git a/iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift b/iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift new file mode 100644 index 0000000..01d0008 --- /dev/null +++ b/iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift @@ -0,0 +1,111 @@ +import XCTest + +/// Critical path tests for authentication flows. +/// +/// Validates login, logout, registration entry, and password reset entry. +/// Zero sleep() calls β€” all waits are condition-based. +final class AuthCriticalPathTests: XCTestCase { + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = TestLaunchConfig.launchApp() + } + + override func tearDown() { + app = nil + super.tearDown() + } + + // MARK: - Login + + func testLoginWithValidCredentials() { + let login = LoginScreen(app: app) + guard login.emailField.waitForExistence(timeout: 15) else { + // Already logged in β€” verify main screen + let main = MainTabScreen(app: app) + XCTAssertTrue(main.isDisplayed, "Main screen should be visible when already logged in") + return + } + + let user = TestFixtures.TestUser.existing + login.login(email: user.email, password: user.password) + + let main = MainTabScreen(app: app) + XCTAssertTrue( + main.residencesTab.waitForExistence(timeout: 15), + "Should navigate to main screen after successful login" + ) + } + + func testLoginWithInvalidCredentials() { + let login = LoginScreen(app: app) + guard login.emailField.waitForExistence(timeout: 15) else { + return // Already logged in, skip + } + + login.login(email: "invaliduser", password: "wrongpassword") + + // Should stay on login screen β€” email field should still exist + XCTAssertTrue( + login.emailField.waitForExistence(timeout: 10), + "Should remain on login screen after invalid credentials" + ) + + // Tab bar should NOT appear + let tabBar = app.tabBars.firstMatch + XCTAssertFalse(tabBar.exists, "Tab bar should not appear after failed login") + } + + // MARK: - Logout + + func testLogoutFlow() { + let login = LoginScreen(app: app) + if login.emailField.waitForExistence(timeout: 15) { + let user = TestFixtures.TestUser.existing + login.login(email: user.email, password: user.password) + } + + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 15) else { + XCTFail("Main screen did not appear") + return + } + + main.logout() + + // Should be back on login screen + let loginAfterLogout = LoginScreen(app: app) + XCTAssertTrue( + loginAfterLogout.emailField.waitForExistence(timeout: 15), + "Should return to login screen after logout" + ) + } + + // MARK: - Registration Entry + + func testSignUpButtonNavigatesToRegistration() { + let login = LoginScreen(app: app) + guard login.emailField.waitForExistence(timeout: 15) else { + return // Already logged in, skip + } + + let register = login.tapSignUp() + XCTAssertTrue(register.isDisplayed, "Registration screen should appear after tapping Sign Up") + } + + // MARK: - Forgot Password Entry + + func testForgotPasswordButtonExists() { + let login = LoginScreen(app: app) + guard login.emailField.waitForExistence(timeout: 15) else { + return // Already logged in, skip + } + + XCTAssertTrue( + login.forgotPasswordButton.waitForExistence(timeout: 5), + "Forgot password button should exist on login screen" + ) + } +} diff --git a/iosApp/CaseraUITests/CriticalPath/NavigationCriticalPathTests.swift b/iosApp/CaseraUITests/CriticalPath/NavigationCriticalPathTests.swift new file mode 100644 index 0000000..96548f5 --- /dev/null +++ b/iosApp/CaseraUITests/CriticalPath/NavigationCriticalPathTests.swift @@ -0,0 +1,169 @@ +import XCTest + +/// Critical path tests for core navigation. +/// +/// Validates tab bar navigation, settings access, and screen transitions. +/// Requires a logged-in user. Zero sleep() calls β€” all waits are condition-based. +final class NavigationCriticalPathTests: XCTestCase { + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = TestLaunchConfig.launchApp() + ensureLoggedIn() + } + + override func tearDown() { + app = nil + super.tearDown() + } + + private func ensureLoggedIn() { + let login = LoginScreen(app: app) + if login.emailField.waitForExistence(timeout: 15) { + let user = TestFixtures.TestUser.existing + login.login(email: user.email, password: user.password) + } + let main = MainTabScreen(app: app) + _ = main.residencesTab.waitForExistence(timeout: 15) + } + + // MARK: - Tab Navigation + + func testAllTabsExist() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist") + XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist") + XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist") + XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist") + } + + func testNavigateToTasksTab() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToTasks() + XCTAssertTrue(main.tasksTab.isSelected, "Tasks tab should be selected") + } + + func testNavigateToContractorsTab() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToContractors() + XCTAssertTrue(main.contractorsTab.isSelected, "Contractors tab should be selected") + } + + func testNavigateToDocumentsTab() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToDocuments() + XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected") + } + + func testNavigateBackToResidencesTab() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToDocuments() + main.goToResidences() + XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected") + } + + // MARK: - Settings Access + + func testSettingsButtonExists() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToResidences() + XCTAssertTrue( + main.settingsButton.waitForExistence(timeout: 5), + "Settings button should exist on Residences screen" + ) + } + + // MARK: - Add Buttons + + func testResidenceAddButtonExists() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToResidences() + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + XCTAssertTrue( + addButton.waitForExistence(timeout: 5), + "Residence add button should exist" + ) + } + + func testTaskAddButtonExists() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToTasks() + let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton] + XCTAssertTrue( + addButton.waitForExistence(timeout: 5), + "Task add button should exist" + ) + } + + func testContractorAddButtonExists() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToContractors() + let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton] + XCTAssertTrue( + addButton.waitForExistence(timeout: 5), + "Contractor add button should exist" + ) + } + + func testDocumentAddButtonExists() { + let main = MainTabScreen(app: app) + guard main.residencesTab.waitForExistence(timeout: 10) else { + XCTFail("Main screen did not appear") + return + } + + main.goToDocuments() + let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton] + XCTAssertTrue( + addButton.waitForExistence(timeout: 5), + "Document add button should exist" + ) + } +} diff --git a/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift b/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift index a951819..5cda163 100644 --- a/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift +++ b/iosApp/CaseraUITests/CriticalPath/SmokeTests.swift @@ -5,6 +5,8 @@ import XCTest /// Tests that the app launches successfully, the auth screen renders correctly, /// and core navigation is functional. These are the minimum-viability tests /// that must pass before any PR can merge. +/// +/// Zero sleep() calls β€” all waits are condition-based. final class SmokeTests: XCTestCase { var app: XCUIApplication! @@ -70,7 +72,6 @@ final class SmokeTests: XCTestCase { func testMainTabsExistAfterLogin() { let login = LoginScreen(app: app) if login.emailField.waitForExistence(timeout: 15) { - // Need to login first let user = TestFixtures.TestUser.existing login.login(email: user.email, password: user.password) } @@ -81,11 +82,11 @@ final class SmokeTests: XCTestCase { return } + // App has 4 tabs: Residences, Tasks, Contractors, Documents XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist") XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist") XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist") XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist") - XCTAssertTrue(main.profileTab.exists, "Profile tab should exist") } func testTabNavigation() { @@ -111,9 +112,6 @@ final class SmokeTests: XCTestCase { main.goToDocuments() XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected") - main.goToProfile() - XCTAssertTrue(main.profileTab.isSelected, "Profile tab should be selected") - main.goToResidences() XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected") } diff --git a/iosApp/CaseraUITests/MyCribUITests.swift b/iosApp/CaseraUITests/MyCribUITests.swift deleted file mode 100644 index 1658421..0000000 --- a/iosApp/CaseraUITests/MyCribUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CaseraUITests.swift -// CaseraUITests -// -// Created by Trey Tartt on 11/19/25. -// - -import XCTest - -final class CaseraUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } -} diff --git a/iosApp/CaseraUITests/MyCribUITestsLaunchTests.swift b/iosApp/CaseraUITests/MyCribUITestsLaunchTests.swift deleted file mode 100644 index 301e865..0000000 --- a/iosApp/CaseraUITests/MyCribUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// CaseraUITestsLaunchTests.swift -// CaseraUITests -// -// Created by Trey Tartt on 11/19/25. -// - -import XCTest - -final class CaseraUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/iosApp/CaseraUITests/PageObjects/BaseScreen.swift b/iosApp/CaseraUITests/PageObjects/BaseScreen.swift index 271cd35..e2747b2 100644 --- a/iosApp/CaseraUITests/PageObjects/BaseScreen.swift +++ b/iosApp/CaseraUITests/PageObjects/BaseScreen.swift @@ -45,6 +45,30 @@ class BaseScreen { return element } + /// Waits until a condition evaluates to true, polling every 0.5s. + /// More flexible than element-based waits for complex state checks. + func waitForCondition( + _ description: String, + timeout: TimeInterval? = nil, + condition: () -> Bool + ) -> Bool { + let t = timeout ?? self.timeout + let deadline = Date().addingTimeInterval(t) + while Date() < deadline { + if condition() { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + return false + } + + /// Waits for an element to exist, then taps it. Convenience for the common wait+tap pattern. + @discardableResult + func tapElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement { + waitForElement(element, timeout: timeout) + element.tap() + return element + } + // MARK: - State Assertions /// Asserts that an element with the given accessibility identifier exists. diff --git a/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift b/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift index 1dd80a8..9134032 100644 --- a/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift +++ b/iosApp/CaseraUITests/PageObjects/MainTabScreen.swift @@ -2,30 +2,32 @@ import XCTest /// Page object for the main tab view that appears after login. /// -/// Provides navigation to each tab (Residences, Tasks, Contractors, Documents, Profile) -/// and a logout flow. Uses predicate-based element lookup to match the existing test patterns. +/// The app has 4 tabs: Residences, Tasks, Contractors, Documents. +/// Profile is accessed via the settings button on the Residences screen. +/// Uses accessibility identifiers for reliable element lookup. class MainTabScreen: BaseScreen { // MARK: - Tab Elements var residencesTab: XCUIElement { - app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab] } var tasksTab: XCUIElement { - app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + app.tabBars.buttons[AccessibilityIdentifiers.Navigation.tasksTab] } var contractorsTab: XCUIElement { - app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + app.tabBars.buttons[AccessibilityIdentifiers.Navigation.contractorsTab] } var documentsTab: XCUIElement { - app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch + app.tabBars.buttons[AccessibilityIdentifiers.Navigation.documentsTab] } - var profileTab: XCUIElement { - app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch + /// Settings button on the Residences tab (leads to profile/settings). + var settingsButton: XCUIElement { + app.buttons[AccessibilityIdentifiers.Navigation.settingsButton] } override var isDisplayed: Bool { @@ -58,18 +60,20 @@ class MainTabScreen: BaseScreen { return self } + /// Navigates to settings/profile via the settings button on Residences tab. @discardableResult - func goToProfile() -> Self { - waitForElement(profileTab).tap() + func goToSettings() -> Self { + goToResidences() + waitForElement(settingsButton).tap() return self } // MARK: - Logout - /// Logs out by navigating to the Profile tab and tapping the logout button. + /// Logs out by navigating to settings and tapping the logout button. /// Handles the confirmation alert automatically. func logout() { - goToProfile() + goToSettings() let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton] if logoutButton.waitForExistence(timeout: 5) { diff --git a/iosApp/CaseraUITests/README.md b/iosApp/CaseraUITests/README.md index 68cac38..d4c185c 100644 --- a/iosApp/CaseraUITests/README.md +++ b/iosApp/CaseraUITests/README.md @@ -4,31 +4,31 @@ ``` CaseraUITests/ -β”œβ”€β”€ PageObjects/ # Screen abstractions (Page Object pattern) -β”‚ β”œβ”€β”€ BaseScreen.swift # Common wait/assert utilities -β”‚ β”œβ”€β”€ LoginScreen.swift # Login screen elements and actions -β”‚ β”œβ”€β”€ RegisterScreen.swift -β”‚ └── MainTabScreen.swift -β”œβ”€β”€ TestConfiguration/ # Launch config, environment setup +β”œβ”€β”€ PageObjects/ # Screen abstractions (Page Object pattern) +β”‚ β”œβ”€β”€ BaseScreen.swift # Common wait/assert utilities +β”‚ β”œβ”€β”€ LoginScreen.swift # Login screen elements and actions +β”‚ β”œβ”€β”€ RegisterScreen.swift # Registration screen +β”‚ └── MainTabScreen.swift # Main tab navigation + settings + logout +β”œβ”€β”€ TestConfiguration/ # Launch config, environment setup β”‚ └── TestLaunchConfig.swift -β”œβ”€β”€ Fixtures/ # Test data builders +β”œβ”€β”€ Fixtures/ # Test data builders β”‚ └── TestFixtures.swift -β”œβ”€β”€ CriticalPath/ # Must-pass tests for CI gating -β”‚ └── SmokeTests.swift # Fast smoke suite (<2 min) -β”œβ”€β”€ Suite0-10_*.swift # Existing comprehensive test suites -β”œβ”€β”€ UITestHelpers.swift # Legacy shared helpers -β”œβ”€β”€ AccessibilityIdentifiers.swift # UI element IDs -└── README.md # This file +β”œβ”€β”€ CriticalPath/ # Must-pass tests for CI gating +β”‚ β”œβ”€β”€ SmokeTests.swift # Fast smoke suite (<2 min) +β”‚ β”œβ”€β”€ AuthCriticalPathTests.swift # Auth flow validation +β”‚ └── NavigationCriticalPathTests.swift # Tab + navigation validation +β”œβ”€β”€ UITestHelpers.swift # Shared login/logout/navigation helpers +β”œβ”€β”€ AccessibilityIdentifiers.swift # UI element IDs (synced with app-side copy) +└── README.md # This file ``` ## Test Suites | Suite | Purpose | CI Gate | Target Time | |-------|---------|---------|-------------| -| SmokeTests | App launches, auth, navigation | Every PR | <2 min | -| Suite0-2 | Onboarding, registration, auth | Nightly | <5 min | -| Suite3-8 | Feature CRUD (residence, task, etc) | Nightly | <15 min | -| Suite9-10 | E2E integration | Weekly | <30 min | +| SmokeTests | App launches, basic auth, tab existence | Every PR | <2 min | +| AuthCriticalPathTests | Login, logout, registration entry, forgot password | Every PR | <3 min | +| NavigationCriticalPathTests | Tab navigation, settings, add buttons | Every PR | <3 min | ## Patterns @@ -36,7 +36,7 @@ CaseraUITests/ Every screen has a corresponding PageObject in `PageObjects/`. Use these instead of raw XCUIElement queries in tests. Page objects encapsulate element lookups and common actions, making tests more readable and easier to maintain when the UI changes. ### Wait Helpers -NEVER use `sleep()` or `Thread.sleep()`. Use `waitForElement()`, `waitForElementToDisappear()`, or `waitForHittable()` from BaseScreen. These are condition-based waits that return as soon as the condition is met, making tests both faster and more reliable. +NEVER use `sleep()` or `Thread.sleep()`. Use `waitForElement()`, `waitForElementToDisappear()`, `waitForHittable()`, or `waitForCondition()` from BaseScreen. These are condition-based waits that return as soon as the condition is met, making tests both faster and more reliable. ### Test Data Use `TestFixtures` builders for consistent, unique test data. Random numbers and UUIDs ensure test isolation so tests can run in any order without interfering with each other. @@ -45,15 +45,17 @@ Use `TestFixtures` builders for consistent, unique test data. Random numbers and Use `TestLaunchConfig.launchApp()` for standard launches. Use `launchAuthenticated()` to skip login when the app supports test authentication bypass. The standard configuration disables animations and forces English locale. ### Accessibility Identifiers -All interactive elements must have identifiers defined in `AccessibilityIdentifiers.swift`. Use `.accessibilityIdentifier()` in SwiftUI views. Page objects reference these identifiers for element lookup. +All interactive elements must have identifiers defined in `AccessibilityIdentifiers.swift`. Use `.accessibilityIdentifier()` in SwiftUI views. Page objects reference these identifiers for element lookup. The test-side copy must stay in sync with the app-side copy at `iosApp/Helpers/AccessibilityIdentifiers.swift`. ## CI Configuration -### Smoke Suite (every PR) +### Critical Path (every PR) ```bash xcodebuild test -project iosApp.xcodeproj -scheme iosApp \ -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \ - -only-testing:CaseraUITests/SmokeTests + -only-testing:CaseraUITests/SmokeTests \ + -only-testing:CaseraUITests/AuthCriticalPathTests \ + -only-testing:CaseraUITests/NavigationCriticalPathTests ``` ### Full Regression (nightly) @@ -66,15 +68,16 @@ xcodebuild test -project iosApp.xcodeproj -scheme iosApp \ ## Flake Reduction - Target: <2% flake rate on critical-path suite -- All waits use condition-based predicates (no fixed sleeps) +- All waits use condition-based predicates (zero fixed sleeps) - Test data uses unique identifiers to prevent cross-test interference - UI animations disabled via launch arguments -- Element lookups use accessibility identifiers where possible, with predicate-based fallbacks +- Element lookups use accessibility identifiers exclusively ## Adding New Tests 1. If the screen does not have a page object yet, create one in `PageObjects/` that extends `BaseScreen`. 2. Define accessibility identifiers in `AccessibilityIdentifiers.swift` for any new UI elements. -3. Add test data builders to `TestFixtures.swift` if needed. -4. Write the test in the appropriate suite file, or create a new suite if the feature is new. -5. For critical-path tests (must pass on every PR), add to `CriticalPath/SmokeTests.swift`. +3. Sync the app-side copy of `AccessibilityIdentifiers.swift` with matching identifiers. +4. Add test data builders to `TestFixtures.swift` if needed. +5. Write the test in `CriticalPath/` for must-pass CI tests. +6. Verify zero `sleep()` calls before merging. diff --git a/iosApp/CaseraUITests/SimpleLoginTest.swift b/iosApp/CaseraUITests/SimpleLoginTest.swift deleted file mode 100644 index 8866d9f..0000000 --- a/iosApp/CaseraUITests/SimpleLoginTest.swift +++ /dev/null @@ -1,63 +0,0 @@ -import XCTest - -/// Simple test to verify basic app launch and login screen -/// This is the foundation test - if this works, we can build more complex tests -final class SimpleLoginTest: XCTestCase { - var app: XCUIApplication! - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - - // CRITICAL: Ensure we're logged out before each test - ensureLoggedOut() - } - - override func tearDownWithError() throws { - app = nil - } - - // MARK: - Helper Methods - - /// Ensures the user is logged out and on the login screen - private func ensureLoggedOut() { - UITestHelpers.ensureLoggedOut(app: app) - } - - // MARK: - Tests - - /// Test 1: App launches and shows login screen (or logs out if needed) - func testAppLaunchesAndShowsLoginScreen() { - // After ensureLoggedOut(), we should be on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "Login screen with 'Welcome Back' text should appear after logout") - - // Also check that we have a username field - let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch - XCTAssertTrue(usernameField.exists, "Username/email field should exist") - } - - /// Test 2: Can type in username and password fields - func testCanTypeInLoginFields() { - // Already logged out from setUp - - // Find and tap username field - let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch - XCTAssertTrue(usernameField.waitForExistence(timeout: 10), "Username field should exist") - - usernameField.tap() - usernameField.typeText("testuser") - - // Find password field (could be TextField or SecureField) - let passwordField = app.secureTextFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch - XCTAssertTrue(passwordField.exists, "Password field should exist") - - passwordField.tap() - passwordField.typeText("testpass123") - - // Verify we can see a Sign In button - let signInButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign In'")).firstMatch - XCTAssertTrue(signInButton.exists, "Sign In button should exist") - } -} diff --git a/iosApp/CaseraUITests/Suite0_OnboardingTests.swift b/iosApp/CaseraUITests/Suite0_OnboardingTests.swift deleted file mode 100644 index fc2e326..0000000 --- a/iosApp/CaseraUITests/Suite0_OnboardingTests.swift +++ /dev/null @@ -1,151 +0,0 @@ -import XCTest - -/// Onboarding flow tests -/// -/// SETUP REQUIREMENTS: -/// This test suite requires the app to be UNINSTALLED before running. -/// Add a Pre-action script to the CaseraUITests scheme (Edit Scheme β†’ Test β†’ Pre-actions): -/// /usr/bin/xcrun simctl uninstall booted com.tt.casera.CaseraDev -/// exit 0 -/// -/// There is ONE fresh-install test that runs the complete onboarding flow. -/// Additional tests for returning users (login screen) can run without fresh install. -final class Suite0_OnboardingTests: XCTestCase { - var app: XCUIApplication! - let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - sleep(2) - } - - override func tearDownWithError() throws { - app.terminate() - app = nil - } - - func test_onboarding() { - let app = XCUIApplication() - app.activate() - - sleep(3) - - let springboardApp = XCUIApplication(bundleIdentifier: "com.apple.springboard") - springboardApp/*@START_MENU_TOKEN@*/.buttons["Allow"]/*[[".otherElements.buttons[\"Allow\"]",".buttons[\"Allow\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - sleep(1) - - app/*@START_MENU_TOKEN@*/.buttons["Onboarding.StartFreshButton"]/*[[".buttons",".containing(.staticText, identifier: \"Start Fresh\")",".containing(.image, identifier: \"icon\")",".otherElements",".buttons[\"Start Fresh\"]",".buttons[\"Onboarding.StartFreshButton\"]"],[[[-1,5],[-1,4],[-1,3,2],[-1,0,1]],[[-1,2],[-1,1]],[[-1,5],[-1,4]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - sleep(1) - app.cells/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.swipeLeft() - - sleep(1) - app/*@START_MENU_TOKEN@*/.staticTexts["Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet."]/*[[".otherElements.staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]",".staticTexts[\"Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft() - - sleep(1) - app/*@START_MENU_TOKEN@*/.staticTexts["Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's coveredβ€”instantly."]/*[[".otherElements.staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's coveredβ€”instantly.\"]",".staticTexts[\"Snap a photo of your receipts and warranties. When something breaks, you'll know exactly what's coveredβ€”instantly.\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeLeft() - sleep(1) - - app/*@START_MENU_TOKEN@*/.staticTexts["I'm Ready!"]/*[[".buttons[\"I'm Ready!\"].staticTexts",".buttons.staticTexts[\"I'm Ready!\"]",".staticTexts[\"I'm Ready!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - sleep(1) - app/*@START_MENU_TOKEN@*/.textFields["Onboarding.ResidenceNameField"]/*[[".otherElements",".textFields[\"Xcuites\"]",".textFields[\"The Smith Residence\"]",".textFields[\"Onboarding.ResidenceNameField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest") - app/*@START_MENU_TOKEN@*/.staticTexts["That's Perfect!"]/*[[".buttons[\"Onboarding.NameResidenceContinueButton\"].staticTexts",".buttons.staticTexts[\"That's Perfect!\"]",".staticTexts[\"That's Perfect!\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - app/*@START_MENU_TOKEN@*/.staticTexts["Create Account with Email"]/*[[".buttons",".staticTexts",".staticTexts[\"Create Account with Email\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - sleep(1) - let scrollViewsQuery = app.scrollViews - let element = scrollViewsQuery/*@START_MENU_TOKEN@*/.firstMatch/*[[".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch",".firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/ - element.tap() - app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements.textFields[\"Username\"]",".textFields[\"Username\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - app/*@START_MENU_TOKEN@*/.textFields["Username"]/*[[".otherElements",".textFields[\"xcuitest\"]",".textFields[\"Username\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest") - scrollViewsQuery/*@START_MENU_TOKEN@*/.containing(.other, identifier: nil).firstMatch/*[[".element(boundBy: 0)",".containing(.other, identifier: \"Vertical scroll bar, 2 pages\").firstMatch",".containing(.other, identifier: nil).firstMatch"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() - - let element2 = app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements.textFields[\"Email\"]",".textFields[\"Email\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch - element2.tap() - element2.tap() - app/*@START_MENU_TOKEN@*/.textFields["Email"]/*[[".otherElements",".textFields[\"xcuitest@treymail.com\"]",".textFields[\"Email\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("xcuitest@treymail.com") - - let element3 = app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements.secureTextFields[\"Password\"]",".secureTextFields[\"Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch - element3.tap() - element3.tap() - app/*@START_MENU_TOKEN@*/.secureTextFields["Password"]/*[[".otherElements",".secureTextFields[\"β€’β€’β€’β€’β€’β€’β€’β€’\"]",".secureTextFields[\"Password\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("12345678") - - let element4 = app/*@START_MENU_TOKEN@*/.secureTextFields["Confirm Password"]/*[[".otherElements.secureTextFields[\"Confirm Password\"]",".secureTextFields[\"Confirm Password\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch - element4.tap() - element4.tap() - element4.typeText("12345678") - element.swipeUp() - app/*@START_MENU_TOKEN@*/.buttons["Onboarding.CreateAccountButton"]/*[[".otherElements",".buttons[\"Create Account\"]",".buttons[\"Onboarding.CreateAccountButton\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - - sleep(1) - let element5 = app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,2],[-1,1],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch - element5.tap() - element5.tap() - app/*@START_MENU_TOKEN@*/.textFields["Onboarding.VerificationCodeField"]/*[[".otherElements",".textFields[\"123456\"]",".textFields[\"Enter 6-digit code\"]",".textFields[\"Onboarding.VerificationCodeField\"]",".textFields"],[[[-1,3],[-1,2],[-1,1],[-1,4],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.typeText("123456") - sleep(1) - - app/*@START_MENU_TOKEN@*/.images["chevron.up"]/*[[".buttons",".images[\"Go Up\"]",".images[\"chevron.up\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - sleep(1) - app/*@START_MENU_TOKEN@*/.buttons["HVAC & Climate"]/*[[".buttons",".containing(.staticText, identifier: \"HVAC & Climate\")",".containing(.image, identifier: \"thermometer.medium\")",".otherElements.buttons[\"HVAC & Climate\"]",".buttons[\"HVAC & Climate\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp() - sleep(1) - - app/*@START_MENU_TOKEN@*/.staticTexts["Add Most Popular"]/*[[".buttons[\"Add Most Popular\"].staticTexts",".buttons.staticTexts[\"Add Most Popular\"]",".staticTexts[\"Add Most Popular\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - app/*@START_MENU_TOKEN@*/.buttons["Add 5 Tasks & Continue"]/*[[".buttons",".containing(.image, identifier: \"arrow.right\")",".containing(.staticText, identifier: \"Add 5 Tasks & Continue\")",".otherElements.buttons[\"Add 5 Tasks & Continue\"]",".buttons[\"Add 5 Tasks & Continue\"]"],[[[-1,4],[-1,3],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - sleep(1) - app/*@START_MENU_TOKEN@*/.staticTexts["All your warranties, receipts, and manuals in one searchable place"]/*[[".otherElements.staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]",".staticTexts[\"All your warranties, receipts, and manuals in one searchable place\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.swipeUp() - sleep(1) - - app/*@START_MENU_TOKEN@*/.buttons["Continue with Free"]/*[[".otherElements.buttons[\"Continue with Free\"]",".buttons[\"Continue with Free\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - sleep(2) - let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible") - - let xcuitestResidence = app.staticTexts["xcuitest"].waitForExistence(timeout: 10) - XCTAssertTrue(xcuitestResidence, "Residence should appear in list") - - app/*@START_MENU_TOKEN@*/.images["checkmark.circle.fill"]/*[[".buttons[\"checkmark.circle.fill\"].images",".buttons",".images[\"selected\"]",".images[\"checkmark.circle.fill\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - - let taskOne = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "HVAC")).firstMatch - XCTAssertTrue(taskOne.waitForExistence(timeout: 10), "HVAC task should appear in list") - - let taskTwo = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Leaks")).firstMatch - XCTAssertTrue(taskTwo.waitForExistence(timeout: 10), "Leaks task should appear in list") - - let taskThree = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Coils")).firstMatch - XCTAssertTrue(taskThree.waitForExistence(timeout: 10), "Coils task should appear in list") - - - // Try profile tab logout - let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch - if profileTab.exists && profileTab.isHittable { - profileTab.tap() - - let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch - if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable { - logoutButton.tap() - - // Handle confirmation alert - let alertLogout = app.alerts.buttons["Log Out"] - if alertLogout.waitForExistence(timeout: 2) { - alertLogout.tap() - } - } - } - - // Try verification screen logout - let verifyLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch - if verifyLogout.exists && verifyLogout.isHittable { - verifyLogout.tap() - } - - // Wait for login screen - _ = app.staticTexts["Welcome Back"].waitForExistence(timeout: 5) - } -} diff --git a/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift b/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift deleted file mode 100644 index 1b39028..0000000 --- a/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift +++ /dev/null @@ -1,684 +0,0 @@ -import XCTest - -/// Comprehensive End-to-End Test Suite -/// Closely mirrors TestIntegration_ComprehensiveE2E from myCribAPI-go/internal/integration/integration_test.go -/// -/// This test creates a complete scenario: -/// 1. Registers a new user and verifies login -/// 2. Creates multiple residences -/// 3. Creates multiple tasks in different states -/// 4. Verifies task categorization in kanban columns -/// 5. Tests task state transitions (in-progress, complete, cancel, archive) -/// -/// IMPORTANT: These are integration tests requiring network connectivity. -/// Run against a test/dev server, NOT production. -final class Suite10_ComprehensiveE2ETests: XCTestCase { - var app: XCUIApplication! - - // Test run identifier for unique data - use static so it's shared across test methods - private static let testRunId = Int(Date().timeIntervalSince1970) - - // Test user credentials - unique per test run - private var testUsername: String { "e2e_comp_\(Self.testRunId)" } - private var testEmail: String { "e2e_comp_\(Self.testRunId)@test.com" } - private let testPassword = "TestPass123!" - - /// Fixed verification code used by Go API when DEBUG=true - private let verificationCode = "123456" - - /// Track if user has been registered for this test run - private static var userRegistered = false - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - - // Register user on first test, then just ensure logged in for subsequent tests - if !Self.userRegistered { - registerTestUser() - Self.userRegistered = true - } else { - UITestHelpers.ensureLoggedIn(app: app, username: testUsername, password: testPassword) - } - } - - /// Register a new test user for this test suite - private func registerTestUser() { - // Check if already logged in - let tabBar = app.tabBars.firstMatch - if tabBar.exists { - return // Already logged in - } - - // Check if on login screen, navigate to register - let welcomeText = app.staticTexts["Welcome Back"] - if welcomeText.waitForExistence(timeout: 5) { - let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - if signUpButton.exists { - signUpButton.tap() - sleep(2) - } - } - - // Fill registration form - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - if usernameField.waitForExistence(timeout: 5) { - usernameField.tap() - usernameField.typeText(testUsername) - - let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField] - emailField.tap() - emailField.typeText(testEmail) - - let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField] - passwordField.tap() - dismissStrongPasswordSuggestion() - passwordField.typeText(testPassword) - - let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] - confirmPasswordField.tap() - dismissStrongPasswordSuggestion() - confirmPasswordField.typeText(testPassword) - - dismissKeyboard() - sleep(1) - - // Submit registration - app.swipeUp() - sleep(1) - - var registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - if !registerButton.exists || !registerButton.isHittable { - registerButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Create Account' OR label CONTAINS[c] 'Register'")).firstMatch - } - if registerButton.exists { - registerButton.tap() - sleep(3) - } - - // Handle email verification - let verifyEmailTitle = app.staticTexts["Verify Your Email"] - if verifyEmailTitle.waitForExistence(timeout: 10) { - let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] - if codeField.waitForExistence(timeout: 5) { - codeField.tap() - codeField.typeText(verificationCode) - sleep(5) - } - } - - // Wait for login to complete - _ = tabBar.waitForExistence(timeout: 15) - } - } - - /// Dismiss strong password suggestion if shown - private func dismissStrongPasswordSuggestion() { - let chooseOwnPassword = app.buttons["Choose My Own Password"] - if chooseOwnPassword.waitForExistence(timeout: 1) { - chooseOwnPassword.tap() - return - } - let notNow = app.buttons["Not Now"] - if notNow.exists && notNow.isHittable { - notNow.tap() - } - } - - override func tearDownWithError() throws { - app = nil - } - - // MARK: - Helper Methods - - private func navigateToTab(_ tabName: String) { - let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch - if tab.waitForExistence(timeout: 5) && !tab.isSelected { - tab.tap() - sleep(2) - } - } - - /// Dismiss keyboard by tapping outside (doesn't submit forms) - private func dismissKeyboard() { - // Tap on a neutral area to dismiss keyboard without submitting - let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)) - coordinate.tap() - Thread.sleep(forTimeInterval: 0.5) - } - - /// Creates a residence with the given name - /// Returns true if successful - @discardableResult - private func createResidence(name: String, streetAddress: String = "123 Test St", city: String = "Austin", state: String = "TX", postalCode: String = "78701") -> Bool { - navigateToTab("Residences") - sleep(2) - - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - guard addButton.waitForExistence(timeout: 5) else { - XCTFail("Add residence button not found") - return false - } - addButton.tap() - sleep(2) - - // Fill name - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - guard nameField.waitForExistence(timeout: 5) else { - XCTFail("Name field not found") - return false - } - nameField.tap() - nameField.typeText(name) - - // Fill address - fillTextField(placeholder: "Street", text: streetAddress) - fillTextField(placeholder: "City", text: city) - fillTextField(placeholder: "State", text: state) - fillTextField(placeholder: "Postal", text: postalCode) - - app.swipeUp() - sleep(1) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - guard saveButton.exists else { - XCTFail("Save button not found") - return false - } - saveButton.tap() - sleep(3) - - // Verify created - let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch - return residenceCard.waitForExistence(timeout: 10) - } - - /// Creates a task with the given title - /// Returns true if successful - @discardableResult - private func createTask(title: String, description: String? = nil) -> Bool { - navigateToTab("Tasks") - sleep(2) - - let addButton = findAddTaskButton() - guard addButton.waitForExistence(timeout: 5) && addButton.isEnabled else { - XCTFail("Add task button not found or disabled") - return false - } - addButton.tap() - sleep(2) - - // Fill title - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - guard titleField.waitForExistence(timeout: 5) else { - XCTFail("Title field not found") - return false - } - titleField.tap() - titleField.typeText(title) - - // Fill description if provided - if let desc = description { - let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch - if descField.exists { - descField.tap() - descField.typeText(desc) - } - } - - app.swipeUp() - sleep(1) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - guard saveButton.exists else { - XCTFail("Save button not found") - return false - } - saveButton.tap() - sleep(3) - - // Verify created - let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch - return taskCard.waitForExistence(timeout: 10) - } - - private func fillTextField(placeholder: String, text: String) { - let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch - if field.exists { - field.tap() - field.typeText(text) - } - } - - private func findAddTaskButton() -> XCUIElement { - // Strategy 1: Accessibility identifier - let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch - if addButtonById.exists && addButtonById.isEnabled { - return addButtonById - } - - // Strategy 2: Navigation bar plus button - let navBarButtons = app.navigationBars.buttons - for i in 0..= 2 - let hasListView = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'All Tasks'")).firstMatch.exists - - XCTAssertTrue(hasKanbanView || hasListView, "Should display tasks in kanban or list view. Found columns: \(foundColumns)") - } - - // MARK: - Test 7: Residence Details Show Tasks - // Verifies that residence detail screen shows associated tasks - - func test07_residenceDetailsShowTasks() { - navigateToTab("Residences") - sleep(2) - - // Find any residence - let residenceCard = app.cells.firstMatch - guard residenceCard.waitForExistence(timeout: 5) else { - // No residences - create one with a task - createResidence(name: "Detail Test Residence \(Self.testRunId)") - createTask(title: "Detail Test Task \(Self.testRunId)") - navigateToTab("Residences") - sleep(2) - - let newResidenceCard = app.cells.firstMatch - guard newResidenceCard.waitForExistence(timeout: 5) else { - XCTFail("Could not find any residence") - return - } - newResidenceCard.tap() - sleep(2) - return - } - - residenceCard.tap() - sleep(2) - - // Look for tasks section in residence details - let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch - let taskCount = app.staticTexts.containing(NSPredicate(format: "label MATCHES '\\\\d+ tasks?' OR label MATCHES '\\\\d+ Tasks?'")).firstMatch - - // Either tasks section header or task count should be visible - let hasTasksInfo = tasksSection.exists || taskCount.exists - - // Navigate back - let backButton = app.navigationBars.buttons.element(boundBy: 0) - if backButton.exists && backButton.isHittable { - backButton.tap() - sleep(1) - } - - // Note: Not asserting because task section visibility depends on UI design - } - - // MARK: - Test 8: Contractor CRUD (Mirrors backend contractor tests) - - func test08_contractorCRUD() { - navigateToTab("Contractors") - sleep(2) - - let contractorName = "E2E Test Contractor \(Self.testRunId)" - - // Check if Contractors tab exists - let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch - guard contractorsTab.exists else { - // Contractors may not be a main tab - skip this test - return - } - - // Try to add contractor - let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton] - guard addButton.waitForExistence(timeout: 5) else { - // May need residence first - return - } - - addButton.tap() - sleep(2) - - // Fill contractor form - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - if nameField.exists { - nameField.tap() - nameField.typeText(contractorName) - - let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch - if companyField.exists { - companyField.tap() - companyField.typeText("Test Company Inc") - } - - let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch - if phoneField.exists { - phoneField.tap() - phoneField.typeText("555-123-4567") - } - - app.swipeUp() - sleep(1) - - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - if saveButton.exists { - saveButton.tap() - sleep(3) - - // Verify contractor was created - let contractorCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(contractorName)'")).firstMatch - XCTAssertTrue(contractorCard.waitForExistence(timeout: 10), "Contractor '\(contractorName)' should be created") - } - } else { - // Cancel if form didn't load properly - let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - if cancelButton.exists { - cancelButton.tap() - } - } - } - - // MARK: - Test 9: Full Flow Summary - - func test09_fullFlowSummary() { - // This test verifies the overall app state after running previous tests - - // Check Residences tab - navigateToTab("Residences") - sleep(2) - - let residencesList = app.cells - let residenceCount = residencesList.count - - // Check Tasks tab - navigateToTab("Tasks") - sleep(2) - - let tasksScreen = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch - XCTAssertTrue(tasksScreen.exists, "Tasks screen should be accessible") - - // Check Profile tab - navigateToTab("Profile") - sleep(2) - - let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch - XCTAssertTrue(logoutButton.exists, "User should be logged in with logout option available") - - print("=== E2E Test Summary ===") - print("Residences found: \(residenceCount)") - print("Tasks screen accessible: true") - print("User logged in: true") - print("========================") - } -} diff --git a/iosApp/CaseraUITests/Suite1_RegistrationTests.swift b/iosApp/CaseraUITests/Suite1_RegistrationTests.swift deleted file mode 100644 index 3aca79a..0000000 --- a/iosApp/CaseraUITests/Suite1_RegistrationTests.swift +++ /dev/null @@ -1,646 +0,0 @@ -import XCTest - -/// Comprehensive registration flow tests with strict, failure-first assertions -/// Tests verify both positive AND negative conditions to ensure robust validation -final class Suite1_RegistrationTests: XCTestCase { - var app: XCUIApplication! - - // Test user credentials - using timestamp to ensure unique users - private var testUsername: String { - return "testuser_\(Int(Date().timeIntervalSince1970))" - } - private var testEmail: String { - return "test_\(Int(Date().timeIntervalSince1970))@example.com" - } - private let testPassword = "TestPass123!" - - /// Fixed test verification code - Go API uses this code when DEBUG=true - private let testVerificationCode = "123456" - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - - // STRICT: Verify app launched to a known state - let loginScreen = app.staticTexts["Welcome Back"] - let tabBar = app.tabBars.firstMatch - - // Either on login screen OR logged in - handle both - if !loginScreen.waitForExistence(timeout: 3) && tabBar.exists { - // Logged in - need to logout first - ensureLoggedOut() - } - - // STRICT: Must be on login screen before each test - XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen") - - app.swipeUp() - } - - override func tearDownWithError() throws { - ensureLoggedOut() - app = nil - } - - // MARK: - Strict Helper Methods - - private func ensureLoggedOut() { - // Try profile tab logout - let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch - if profileTab.exists && profileTab.isHittable { - dismissKeyboard() - profileTab.tap() - - let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch - if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable { - dismissKeyboard() - logoutButton.tap() - - // Handle confirmation alert - let alertLogout = app.alerts.buttons["Log Out"] - if alertLogout.waitForExistence(timeout: 2) { - dismissKeyboard() - alertLogout.tap() - } - } - } - - // Try verification screen logout - let verifyLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch - if verifyLogout.exists && verifyLogout.isHittable { - dismissKeyboard() - verifyLogout.tap() - } - - // Wait for login screen - _ = app.staticTexts["Welcome Back"].waitForExistence(timeout: 5) - } - - /// Navigate to registration screen with strict verification - /// Note: Registration is presented as a sheet, so login screen elements still exist underneath - private func navigateToRegistration() { - app.swipeUp() - // PRECONDITION: Must be on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration") - - let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen") - XCTAssertTrue(signUpButton.isHittable, "Sign Up button must be tappable") - - dismissKeyboard() - signUpButton.tap() - - // STRICT: Verify registration screen appeared (shown as sheet) - // Note: Login screen still exists underneath the sheet, so we verify registration elements instead - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear") - XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable") - - // STRICT: The Sign Up button should no longer be hittable (covered by sheet) - XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet") - } - - /// Dismisses iOS Strong Password suggestion overlay - private func dismissStrongPasswordSuggestion() { - let chooseOwnPassword = app.buttons["Choose My Own Password"] - if chooseOwnPassword.waitForExistence(timeout: 1) { - chooseOwnPassword.tap() - return - } - - let notNowButton = app.buttons["Not Now"] - if notNowButton.exists && notNowButton.isHittable { - notNowButton.tap() - return - } - - // Dismiss by tapping elsewhere - let strongPasswordText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Strong Password'")).firstMatch - if strongPasswordText.exists { - app.tap() - } - } - - /// Wait for element to disappear - CRITICAL for strict testing - private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "exists == false"), - object: element - ) - let result = XCTWaiter.wait(for: [expectation], timeout: timeout) - return result == .completed - } - - /// Wait for element to become hittable (visible AND interactive) - private func waitForElementToBeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "isHittable == true"), - object: element - ) - let result = XCTWaiter.wait(for: [expectation], timeout: timeout) - return result == .completed - } - - /// Dismiss keyboard by swiping down on the keyboard area - private func dismissKeyboard() { - let app = XCUIApplication() - if app.keys.element(boundBy: 0).exists { - app.typeText("\n") - } - - // Give a moment for keyboard to dismiss - Thread.sleep(forTimeInterval: 2) - } - - /// Fill registration form with given credentials - private func fillRegistrationForm(username: String, email: String, password: String, confirmPassword: String) { - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField] - let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField] - let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] - - // STRICT: All fields must exist and be hittable - XCTAssertTrue(usernameField.isHittable, "Username field must be hittable") - XCTAssertTrue(emailField.isHittable, "Email field must be hittable") - XCTAssertTrue(passwordField.isHittable, "Password field must be hittable") - XCTAssertTrue(confirmPasswordField.isHittable, "Confirm password field must be hittable") - - usernameField.tap() - usernameField.typeText(username) - - emailField.tap() - emailField.typeText(email) - - passwordField.tap() - dismissStrongPasswordSuggestion() - passwordField.typeText(password) - - confirmPasswordField.tap() - dismissStrongPasswordSuggestion() - confirmPasswordField.typeText(confirmPassword) - - // Dismiss keyboard after filling form so buttons are accessible - dismissKeyboard() - } - - // MARK: - 1. UI/Element Tests (no backend, pure UI verification) - - func test01_registrationScreenElements() { - navigateToRegistration() - - // STRICT: All form elements must exist AND be hittable - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField] - let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField] - let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] - let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton] - - XCTAssertTrue(usernameField.exists && usernameField.isHittable, "Username field must be visible and tappable") - XCTAssertTrue(emailField.exists && emailField.isHittable, "Email field must be visible and tappable") - XCTAssertTrue(passwordField.exists && passwordField.isHittable, "Password field must be visible and tappable") - XCTAssertTrue(confirmPasswordField.exists && confirmPasswordField.isHittable, "Confirm password field must be visible and tappable") - XCTAssertTrue(createAccountButton.exists && createAccountButton.isHittable, "Create Account button must be visible and tappable") - XCTAssertTrue(cancelButton.exists && cancelButton.isHittable, "Cancel button must be visible and tappable") - - // NEGATIVE CHECK: Should NOT see verification screen elements as hittable - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertFalse(verifyTitle.exists && verifyTitle.isHittable, "Verification screen should NOT be visible on registration form") - - // NEGATIVE CHECK: Login Sign Up button should not be hittable (covered by sheet) - let loginSignUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - // Note: The button might still exist but should not be hittable due to sheet coverage - if loginSignUpButton.exists { - XCTAssertFalse(loginSignUpButton.isHittable, "Login screen's Sign Up button should be covered by registration sheet") - } - } - - func test02_cancelRegistration() { - navigateToRegistration() - - // Capture that we're on registration screen - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - XCTAssertTrue(usernameField.isHittable, "PRECONDITION: Must be on registration screen") - - let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton] - XCTAssertTrue(cancelButton.isHittable, "Cancel button must be tappable") - dismissKeyboard() - cancelButton.tap() - - // STRICT: Registration sheet must dismiss - username field should no longer be hittable - XCTAssertTrue(waitForElementToDisappear(usernameField, timeout: 5), "Registration form must disappear after cancel") - - // STRICT: Login screen must now be interactive again - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Login screen must be visible after cancel") - - // STRICT: Sign Up button should be hittable again (sheet dismissed) - let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - XCTAssertTrue(waitForElementToBeHittable(signUpButton, timeout: 5), "Sign Up button must be tappable after cancel") - } - - // MARK: - 2. Client-Side Validation Tests (no API calls, fail locally) - - func test03_registrationWithEmptyFields() { - navigateToRegistration() - - let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - XCTAssertTrue(createAccountButton.isHittable, "Create Account button must be tappable") - - // Capture current state - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertFalse(verifyTitle.exists, "PRECONDITION: Should not be on verification screen") - - dismissKeyboard() - createAccountButton.tap() - - // STRICT: Must show error message - let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'Username'") - let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch - XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for empty fields") - - // NEGATIVE CHECK: Should NOT navigate away from registration -// XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification screen with empty fields") - - // STRICT: Registration form should still be visible and interactive -// let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] -// XCTAssertTrue(usernameField.isHittable, "Username field should still be tappable after error") - } - - func test04_registrationWithInvalidEmail() { - navigateToRegistration() - - fillRegistrationForm( - username: "testuser", - email: "invalid-email", // Invalid format - password: testPassword, - confirmPassword: testPassword - ) - - let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - dismissKeyboard() - createAccountButton.tap() - - // STRICT: Must show email-specific error - let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'email' OR label CONTAINS[c] 'invalid'") - let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch - XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for invalid email format") - - // NEGATIVE CHECK: Should NOT proceed to verification - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with invalid email") - } - - func test05_registrationWithMismatchedPasswords() { - navigateToRegistration() - - fillRegistrationForm( - username: "testuser", - email: "test@example.com", - password: "Password123!", - confirmPassword: "DifferentPassword123!" // Mismatched - ) - - let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - dismissKeyboard() - createAccountButton.tap() - - // STRICT: Must show password mismatch error - let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'match' OR label CONTAINS[c] 'password'") - let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch - XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for mismatched passwords") - - // NEGATIVE CHECK: Should NOT proceed to verification - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with mismatched passwords") - } - - func test06_registrationWithWeakPassword() { - navigateToRegistration() - - fillRegistrationForm( - username: "testuser", - email: "test@example.com", - password: "weak", // Too weak - confirmPassword: "weak" - ) - - let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - dismissKeyboard() - createAccountButton.tap() - - // STRICT: Must show password strength error - let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'password' OR label CONTAINS[c] 'character' OR label CONTAINS[c] 'strong' OR label CONTAINS[c] '8'") - let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch - XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for weak password") - - // NEGATIVE CHECK: Should NOT proceed - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with weak password") - } - - // MARK: - 3. Full Registration Flow Tests (creates new users - MUST RUN BEFORE tests that need existing users) - - func test07_successfulRegistrationAndVerification() { - let username = testUsername - let email = testEmail - - navigateToRegistration() - fillRegistrationForm( - username: username, - email: email, - password: testPassword, - confirmPassword: testPassword - ) - - // Capture registration form state - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - - // STRICT: Registration form must disappear - XCTAssertTrue(waitForElementToDisappear(usernameField, timeout: 10), "Registration form must disappear after successful registration") - - // STRICT: Verification screen must appear - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Verification screen must appear after registration") - - // STRICT: Verification screen must be the active screen (not behind anything) - XCTAssertTrue(verifyTitle.isHittable, "Verification title must be visible and not obscured") - - // NEGATIVE CHECK: Tab bar should NOT be hittable while on verification - let tabBar = app.tabBars.firstMatch - if tabBar.exists { - XCTAssertFalse(tabBar.isHittable, "Tab bar should NOT be interactive while verification is required") - } - - // Enter verification code - let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] - XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist") - XCTAssertTrue(codeField.isHittable, "Verification code field must be tappable") - - dismissKeyboard() - codeField.tap() - codeField.typeText(testVerificationCode) - - dismissKeyboard() - let verifyButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch - XCTAssertTrue(verifyButton.exists && verifyButton.isHittable, "Verify button must be tappable") - verifyButton.tap() - - // STRICT: Verification screen must DISAPPEAR - XCTAssertTrue(waitForElementToDisappear(verifyTitle, timeout: 10), "Verification screen MUST disappear after successful verification") - - // STRICT: Must be on main app screen - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Tab bar must appear after verification") - XCTAssertTrue(waitForElementToBeHittable(residencesTab, timeout: 5), "Residences tab MUST be tappable after verification") - - // NEGATIVE CHECK: Verification screen should be completely gone - XCTAssertFalse(verifyTitle.exists, "Verification screen must NOT exist after successful verification") - XCTAssertFalse(codeField.exists, "Verification code field must NOT exist after successful verification") - - // Verify we can interact with the app (tap tab) - dismissKeyboard() - residencesTab.tap() - - // Cleanup: Logout - let profileTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch - XCTAssertTrue(profileTab.waitForExistence(timeout: 5) && profileTab.isHittable, "Profile tab must be tappable") - dismissKeyboard() - profileTab.tap() - - let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch - XCTAssertTrue(logoutButton.waitForExistence(timeout: 5) && logoutButton.isHittable, "Logout button must be tappable") - dismissKeyboard() - logoutButton.tap() - - let alertLogout = app.alerts.buttons["Log Out"] - if alertLogout.waitForExistence(timeout: 3) { - dismissKeyboard() - alertLogout.tap() - } - - // STRICT: Must return to login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout") - } - - // MARK: - 4. Server-Side Validation Tests (NOW a user exists from test07) - -// func test08_registrationWithExistingUsername() { -// // NOTE: test07 created a user, so now we can test duplicate username rejection -// // We use 'testuser' which should be seeded, OR we could use the username from test07 -// navigateToRegistration() -// -// fillRegistrationForm( -// username: "testuser", // Existing username (seeded in test DB) -// email: "newemail_\(Int(Date().timeIntervalSince1970))@example.com", -// password: testPassword, -// confirmPassword: testPassword -// ) -// -// dismissKeyboard() -// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap() -// -// // STRICT: Must show "already exists" error -// let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'exists' OR label CONTAINS[c] 'already' OR label CONTAINS[c] 'taken'") -// let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch -// XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message must appear for existing username") -// -// // NEGATIVE CHECK: Should NOT proceed to verification -// let verifyTitle = app.staticTexts["Verify Your Email"] -// XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with existing username") -// -// // STRICT: Should still be on registration form -// let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] -// XCTAssertTrue(usernameField.exists && usernameField.isHittable, "Registration form should still be active") -// } - - // MARK: - 5. Verification Screen Tests - - func test09_registrationWithInvalidVerificationCode() { - let username = testUsername - let email = testEmail - - navigateToRegistration() - fillRegistrationForm( - username: username, - email: email, - password: testPassword, - confirmPassword: testPassword - ) - - dismissKeyboard() -// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap() -// - // Wait for verification screen - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Must navigate to verification screen") - - // Enter INVALID code - let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] - XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable) - dismissKeyboard() - codeField.tap() - codeField.typeText("000000") // Wrong code - - let verifyButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch - dismissKeyboard() - verifyButton.tap() - - // STRICT: Error message must appear - let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong'") - let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch - XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message MUST appear for invalid verification code") - } - - func test10_verificationCodeFieldValidation() { - let username = testUsername - let email = testEmail - - navigateToRegistration() - fillRegistrationForm( - username: username, - email: email, - password: testPassword, - confirmPassword: testPassword - ) - - dismissKeyboard() -// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap() -// - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10)) - - // Enter incomplete code (only 3 digits) - let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] - XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable) - dismissKeyboard() - codeField.tap() - codeField.typeText("123") // Incomplete - - let verifyButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch - - // Button might be disabled with incomplete code - if verifyButton.isEnabled { - dismissKeyboard() - verifyButton.tap() - } - - // STRICT: Must still be on verification screen - XCTAssertTrue(verifyTitle.exists && verifyTitle.isHittable, "Must remain on verification screen with incomplete code") - - // NEGATIVE CHECK: Should NOT have navigated to main app - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - if residencesTab.exists { - XCTAssertFalse(residencesTab.isHittable, "Tab bar MUST NOT be accessible with incomplete verification") - } - } - - func test11_appRelaunchWithUnverifiedUser() { - // This test verifies the fix for: user kills app on verification screen, relaunches, should see verification again - - let username = testUsername - let email = testEmail - - navigateToRegistration() - fillRegistrationForm( - username: username, - email: email, - password: testPassword, - confirmPassword: testPassword - ) - - dismissKeyboard() -// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap() - - // Wait for verification screen - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Must reach verification screen") - - // Simulate app kill and relaunch (terminate and launch) - app.terminate() - app.launch() - - // STRICT: After relaunch, unverified user MUST see verification screen, NOT main app - let verifyTitleAfterRelaunch = app.staticTexts["Verify Your Email"] - let loginScreen = app.staticTexts["Welcome Back"] - let tabBar = app.tabBars.firstMatch - - // Wait for app to settle - _ = verifyTitleAfterRelaunch.waitForExistence(timeout: 10) || loginScreen.waitForExistence(timeout: 10) - - // User should either be on verification screen OR login screen (if token expired) - // They should NEVER be on main app with unverified email - if tabBar.exists && tabBar.isHittable { - // If tab bar is accessible, that's a FAILURE - unverified user should not access main app - XCTFail("CRITICAL: Unverified user should NOT have access to main app after relaunch. Tab bar is hittable!") - } - - // Acceptable states: verification screen OR login screen - let onVerificationScreen = verifyTitleAfterRelaunch.exists && verifyTitleAfterRelaunch.isHittable - let onLoginScreen = loginScreen.exists && loginScreen.isHittable - - XCTAssertTrue(onVerificationScreen || onLoginScreen, - "After relaunch, unverified user must be on verification screen or login screen, NOT main app") - - // Cleanup - if onVerificationScreen { - let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch - if logoutButton.exists && logoutButton.isHittable { - dismissKeyboard() - logoutButton.tap() - } - } - } - - func test12_logoutFromVerificationScreen() { - let username = testUsername - let email = testEmail - - navigateToRegistration() - fillRegistrationForm( - username: username, - email: email, - password: testPassword, - confirmPassword: testPassword - ) - - dismissKeyboard() -// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap() - - // Wait for verification screen - let verifyTitle = app.staticTexts["Verify Your Email"] - XCTAssertTrue(verifyTitle.waitForExistence(timeout: 10), "Must navigate to verification screen") - XCTAssertTrue(verifyTitle.isHittable, "Verification screen must be active") - - // STRICT: Logout button must exist and be tappable - let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout'")).firstMatch - XCTAssertTrue(logoutButton.waitForExistence(timeout: 5), "Logout button MUST exist on verification screen") - XCTAssertTrue(logoutButton.isHittable, "Logout button MUST be tappable on verification screen") - - dismissKeyboard() - logoutButton.tap() - - // STRICT: Verification screen must disappear - XCTAssertTrue(waitForElementToDisappear(verifyTitle, timeout: 5), "Verification screen must disappear after logout") - - // STRICT: Must return to login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout") - XCTAssertTrue(welcomeText.isHittable, "Login screen must be interactive") - - // NEGATIVE CHECK: Verification screen elements should be gone - let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] - XCTAssertFalse(codeField.exists, "Verification code field should NOT exist after logout") - } -} - -// MARK: - XCUIElement Extension - -extension XCUIElement { - var hasKeyboardFocus: Bool { - return (value(forKey: "hasKeyboardFocus") as? Bool) ?? false - } -} diff --git a/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift b/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift deleted file mode 100644 index cd9836e..0000000 --- a/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -import XCTest - -/// Authentication flow tests -/// Based on working SimpleLoginTest pattern -final class Suite2_AuthenticationTests: XCTestCase { - var app: XCUIApplication! - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - ensureLoggedOut() - } - - override func tearDownWithError() throws { - app = nil - } - - // MARK: - Helper Methods - - private func ensureLoggedOut() { - UITestHelpers.ensureLoggedOut(app: app) - } - - private func login(username: String, password: String) { - UITestHelpers.login(app: app, username: username, password: password) - } - - // MARK: - 1. Error/Validation Tests - - func test01_loginWithInvalidCredentials() { - // Given: User is on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "Should be on login screen") - - // When: User logs in with invalid credentials - login(username: "wronguser", password: "wrongpass") - - // Then: User should see error message and stay on login screen - sleep(3) // Wait for API response - - // Should still be on login screen - XCTAssertTrue(welcomeText.exists, "Should still be on login screen") - - // Sign In button should still be visible (not logged in) - let signInButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign In'")).firstMatch - XCTAssertTrue(signInButton.exists, "Should still see Sign In button") - } - - // MARK: - 2. Creation Tests (Login/Session) - - func test02_loginWithValidCredentials() { - // Given: User is on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "Should be on login screen") - - // When: User logs in with valid credentials - login(username: "testuser", password: "TestPass123!") - - // Then: User should see main tab view - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - let didNavigate = residencesTab.waitForExistence(timeout: 10) - XCTAssertTrue(didNavigate, "Should navigate to main app after successful login") - } - - // MARK: - 3. View/UI Tests - - func test03_passwordVisibilityToggle() { - // Given: User is on login screen - let passwordField = app.secureTextFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch - XCTAssertTrue(passwordField.waitForExistence(timeout: 5), "Password field should exist") - - // When: User types password - passwordField.tap() - passwordField.typeText("secret123") - - // Then: Find and tap the eye icon (visibility toggle) - let eyeButton = app.buttons[AccessibilityIdentifiers.Authentication.passwordVisibilityToggle].firstMatch - XCTAssertTrue(eyeButton.waitForExistence(timeout: 5), "Password visibility toggle button must exist") - - eyeButton.tap() - sleep(1) - - // Password should now be visible in a regular text field - let visiblePasswordField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch - XCTAssertTrue(visiblePasswordField.exists, "Password should be visible after toggle") - } - - // MARK: - 4. Navigation Tests - - func test04_navigationToSignUp() { - // Given: User is on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "Should be on login screen") - - // When: User taps Sign Up button - let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - XCTAssertTrue(signUpButton.exists, "Sign Up button should exist") - signUpButton.tap() - - // Then: Registration screen should appear - sleep(2) - let registerButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Create Account'")).firstMatch - XCTAssertTrue(registerButton.waitForExistence(timeout: 5), "Should navigate to registration screen") - } - - func test05_forgotPasswordNavigation() { - // Given: User is on login screen - let welcomeText = app.staticTexts["Welcome Back"] - XCTAssertTrue(welcomeText.exists, "Should be on login screen") - - // When: User taps Forgot Password button - let forgotPasswordButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Forgot Password'")).firstMatch - XCTAssertTrue(forgotPasswordButton.exists, "Forgot Password button should exist") - forgotPasswordButton.tap() - - // Then: Password reset screen should appear - sleep(2) - // Look for email field or reset button - let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch - let resetButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Reset' OR label CONTAINS[c] 'Send'")).firstMatch - - let passwordResetScreenAppeared = emailField.exists || resetButton.exists - XCTAssertTrue(passwordResetScreenAppeared, "Should navigate to password reset screen") - } - - // MARK: - 5. Delete/Logout Tests - - func test06_logout() { - // Given: User is logged in - login(username: "testuser", password: "TestPass123!") - - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Should be logged in") - - // When: User logs out - UITestHelpers.logout(app: app) - - // Then: User should be back on login screen (verified by UITestHelpers.logout) - } -} diff --git a/iosApp/CaseraUITests/Suite3_ResidenceTests.swift b/iosApp/CaseraUITests/Suite3_ResidenceTests.swift deleted file mode 100644 index efc7f4c..0000000 --- a/iosApp/CaseraUITests/Suite3_ResidenceTests.swift +++ /dev/null @@ -1,239 +0,0 @@ -import XCTest - -/// Residence management tests -/// Based on working SimpleLoginTest pattern -/// -/// Test Order (logical dependencies): -/// 1. View/UI tests (work with empty list) -/// 2. Navigation tests (don't create data) -/// 3. Cancel test (opens form but doesn't save) -/// 4. Creation tests (creates data) -/// 5. Tests that depend on created data (view details) -final class Suite3_ResidenceTests: XCTestCase { - var app: XCUIApplication! - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - ensureLoggedIn() - } - - override func tearDownWithError() throws { - app = nil - } - - // MARK: - Helper Methods - - private func ensureLoggedIn() { - UITestHelpers.ensureLoggedIn(app: app) - - // Navigate to Residences tab - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - if residencesTab.exists { - residencesTab.tap() - sleep(1) - } - } - - private func navigateToResidencesTab() { - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - if !residencesTab.isSelected { - residencesTab.tap() - sleep(1) - } - } - - // MARK: - 1. View/UI Tests (work with empty list) - - func test01_viewResidencesList() { - // Given: User is logged in and on Residences tab - navigateToResidencesTab() - - // Then: Should see residences list header (must exist even if empty) - let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible") - - // Add button must exist - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - XCTAssertTrue(addButton.exists, "Add residence button must exist") - } - - // MARK: - 2. Navigation Tests (don't create data) - - func test02_navigateToAddResidence() { - // Given: User is on Residences tab - navigateToResidencesTab() - - // When: User taps add residence button (using accessibility identifier to avoid wrong button) - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist") - addButton.tap() - - // Then: Should show add residence form with all required fields - sleep(2) - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Property Name' OR placeholderValue CONTAINS[c] 'Name'")).firstMatch - XCTAssertTrue(nameField.exists, "Name field should exist in residence form") - - // Verify property type picker exists - let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch - XCTAssertTrue(propertyTypePicker.exists, "Property type picker should exist in residence form") - - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - XCTAssertTrue(saveButton.exists, "Save button should exist in residence form") - } - - func test03_navigationBetweenTabs() { - // Given: User is on Residences tab - navigateToResidencesTab() - - // When: User navigates to Tasks tab - let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch - XCTAssertTrue(tasksTab.exists, "Tasks tab should exist") - tasksTab.tap() - sleep(1) - - // Then: Should be on Tasks tab - XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab") - - // When: User navigates back to Residences - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - residencesTab.tap() - sleep(1) - - // Then: Should be back on Residences tab - XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab") - } - - // MARK: - 3. Cancel Test (opens form but doesn't save) - - func test04_cancelResidenceCreation() { - // Given: User is on add residence form - navigateToResidencesTab() - - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - addButton.tap() - sleep(2) - - // When: User taps cancel - let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - XCTAssertTrue(cancelButton.waitForExistence(timeout: 5), "Cancel button should exist") - cancelButton.tap() - - // Then: Should return to residences list - sleep(1) - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesTab.exists, "Should be back on residences list") - } - - // MARK: - 4. Creation Tests - - func test05_createResidenceWithMinimalData() { - // Given: User is on add residence form - navigateToResidencesTab() - - // Use accessibility identifier to get the correct add button - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - XCTAssertTrue(addButton.exists, "Add residence button should exist") - addButton.tap() - sleep(2) - - // When: Verify form loaded correctly - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Property Name' OR placeholderValue CONTAINS[c] 'Name'")).firstMatch - XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should appear - form did not load correctly!") - - // Fill name field - let timestamp = Int(Date().timeIntervalSince1970) - let residenceName = "UITest Home \(timestamp)" - nameField.tap() - nameField.typeText(residenceName) - - // Select property type (required field) - let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch - if propertyTypePicker.exists { - propertyTypePicker.tap() - sleep(2) - - // After tapping picker, look for any selectable option - // Try common property types as buttons - if app.buttons["House"].exists { - app.buttons["House"].tap() - } else if app.buttons["Apartment"].exists { - app.buttons["Apartment"].tap() - } else if app.buttons["Condo"].exists { - app.buttons["Condo"].tap() - } else { - // If navigation style, try cells - let cells = app.cells - if cells.count > 1 { - cells.element(boundBy: 1).tap() // Skip first which might be "Select Type" - } - } - sleep(1) - } - - // Fill address fields - MUST exist for residence - let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch - XCTAssertTrue(streetField.exists, "Street field should exist in residence form") - streetField.tap() - streetField.typeText("123 Test St") - - let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch - XCTAssertTrue(cityField.exists, "City field should exist in residence form") - cityField.tap() - cityField.typeText("TestCity") - - let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch - XCTAssertTrue(stateField.exists, "State field should exist in residence form") - stateField.tap() - stateField.typeText("TS") - - let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Postal'")).firstMatch - XCTAssertTrue(postalField.exists, "Postal code field should exist in residence form") - postalField.tap() - postalField.typeText("12345") - - // Scroll down to see more fields - app.swipeUp() - sleep(1) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - XCTAssertTrue(saveButton.exists, "Save button should exist") - saveButton.tap() - - // Then: Should return to residences list and verify residence was created - sleep(3) // Wait for save to complete - - // First check we're back on the list - let residencesList = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Your Properties' OR label CONTAINS 'My Properties'")).firstMatch - XCTAssertTrue(residencesList.waitForExistence(timeout: 10), "Should return to residences list after saving") - - // CRITICAL: Verify the residence actually appears in the list - let newResidence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch - XCTAssertTrue(newResidence.waitForExistence(timeout: 10), "New residence '\(residenceName)' should appear in the list - network call may have failed!") - } - - // MARK: - 5. Tests That Depend on Created Data - - func test06_viewResidenceDetails() { - // Given: User is on Residences tab with at least one residence - // This test requires testCreateResidenceWithMinimalData to have run first - navigateToResidencesTab() - sleep(2) - - // Find a residence card by looking for UITest Home text - let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'UITest Home' OR label CONTAINS 'Test'")).firstMatch - XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "At least one residence must exist - run testCreateResidenceWithMinimalData first") - - // When: User taps on the residence - residenceCard.tap() - sleep(2) - - // Then: Should show residence details screen with edit/delete buttons - let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch - let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch - - XCTAssertTrue(editButton.exists || deleteButton.exists, "Residence details screen must show with edit or delete button") - } -} diff --git a/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift b/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift deleted file mode 100644 index ad9c581..0000000 --- a/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift +++ /dev/null @@ -1,671 +0,0 @@ -import XCTest - -/// Comprehensive residence testing suite covering all scenarios, edge cases, and variations -/// This test suite is designed to be bulletproof and catch regressions early -/// -/// Test Order (least to most complex): -/// 1. Error/incomplete data tests -/// 2. Creation tests -/// 3. Edit/update tests -/// 4. Delete/remove tests (none currently) -/// 5. Navigation/view tests -/// 6. Performance tests -final class Suite4_ComprehensiveResidenceTests: XCTestCase { - var app: XCUIApplication! - - // Test data tracking - var createdResidenceNames: [String] = [] - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - - // Ensure user is logged in - UITestHelpers.ensureLoggedIn(app: app) - - // Navigate to Residences tab - navigateToResidencesTab() - } - - override func tearDownWithError() throws { - createdResidenceNames.removeAll() - app = nil - } - - // MARK: - Helper Methods - - private func navigateToResidencesTab() { - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - if residencesTab.waitForExistence(timeout: 5) { - if !residencesTab.isSelected { - residencesTab.tap() - sleep(3) - } - } - } - - private func openResidenceForm() -> Bool { - let addButton = findAddResidenceButton() - guard addButton.exists && addButton.isEnabled else { return false } - addButton.tap() - sleep(3) - - // Verify form opened - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - return nameField.waitForExistence(timeout: 5) - } - - private func findAddResidenceButton() -> XCUIElement { - sleep(2) - - let addButtonById = app.buttons[AccessibilityIdentifiers.Residence.addButton] - if addButtonById.exists && addButtonById.isEnabled { - return addButtonById - } - - let navBarButtons = app.navigationBars.buttons - for i in 0.. Bool { - guard openResidenceForm() else { return false } - - // Fill name - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - nameField.tap() - nameField.typeText(name) - - // Select property type - selectPropertyType(type: propertyType) - - // Scroll to address section - if scrollBeforeAddress { - app.swipeUp() - sleep(1) - } - - // Fill address fields - fillTextField(placeholder: "Street", text: street) - fillTextField(placeholder: "City", text: city) - fillTextField(placeholder: "State", text: state) - fillTextField(placeholder: "Postal", text: postal) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - guard saveButton.exists else { return false } - saveButton.tap() - - sleep(4) // Wait for API call - - // Track created residence - createdResidenceNames.append(name) - - return true - } - - private func findResidence(name: String) -> XCUIElement { - return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch - } - - // MARK: - 1. Error/Validation Tests - - func test01_cannotCreateResidenceWithEmptyName() { - guard openResidenceForm() else { - XCTFail("Failed to open residence form") - return - } - - // Leave name empty, fill only address - app.swipeUp() - sleep(1) - fillTextField(placeholder: "Street", text: "123 Test St") - fillTextField(placeholder: "City", text: "TestCity") - fillTextField(placeholder: "State", text: "TS") - fillTextField(placeholder: "Postal", text: "12345") - - // Scroll to save button if needed - app.swipeUp() - sleep(1) - - // Save button should be disabled when name is empty - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - XCTAssertTrue(saveButton.exists, "Save button should exist") - XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when name is empty") - } - - func test02_cancelResidenceCreation() { - guard openResidenceForm() else { - XCTFail("Failed to open residence form") - return - } - - // Fill some data - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - nameField.tap() - nameField.typeText("This will be canceled") - - // Tap cancel - let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - XCTAssertTrue(cancelButton.exists, "Cancel button should exist") - cancelButton.tap() - sleep(2) - - // Should be back on residences list - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesTab.exists, "Should be back on residences list") - - // Residence should not exist - let residence = findResidence(name: "This will be canceled") - XCTAssertFalse(residence.exists, "Canceled residence should not exist") - } - - // MARK: - 2. Creation Tests - - func test03_createResidenceWithMinimalData() { - let timestamp = Int(Date().timeIntervalSince1970) - let residenceName = "Minimal Home \(timestamp)" - - let success = createResidence(name: residenceName) - XCTAssertTrue(success, "Should successfully create residence with minimal data") - - let residenceInList = findResidence(name: residenceName) - XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list") - } - - func test04_createResidenceWithAllPropertyTypes() { - let timestamp = Int(Date().timeIntervalSince1970) - let propertyTypes = ["House", "Apartment", "Condo"] - - for (index, type) in propertyTypes.enumerated() { - let residenceName = "\(type) Test \(timestamp)_\(index)" - let success = createResidence(name: residenceName, propertyType: type) - XCTAssertTrue(success, "Should create \(type) residence") - - navigateToResidencesTab() - sleep(2) - } - - // Verify all residences exist - for (index, type) in propertyTypes.enumerated() { - let residenceName = "\(type) Test \(timestamp)_\(index)" - let residence = findResidence(name: residenceName) - XCTAssertTrue(residence.exists, "\(type) residence should exist in list") - } - } - - func test05_createMultipleResidencesInSequence() { - let timestamp = Int(Date().timeIntervalSince1970) - - for i in 1...3 { - let residenceName = "Sequential Home \(i) - \(timestamp)" - let success = createResidence(name: residenceName) - XCTAssertTrue(success, "Should create residence \(i)") - - navigateToResidencesTab() - sleep(2) - } - - // Verify all residences exist - for i in 1...3 { - let residenceName = "Sequential Home \(i) - \(timestamp)" - let residence = findResidence(name: residenceName) - XCTAssertTrue(residence.exists, "Residence \(i) should exist in list") - } - } - - func test06_createResidenceWithVeryLongName() { - let timestamp = Int(Date().timeIntervalSince1970) - let longName = "This is an extremely long residence name that goes on and on and on to test how the system handles very long text input in the name field \(timestamp)" - - let success = createResidence(name: longName) - XCTAssertTrue(success, "Should handle very long names") - - // Verify it appears (may be truncated in display) - let residence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long residence'")).firstMatch - XCTAssertTrue(residence.waitForExistence(timeout: 10), "Long name residence should exist") - } - - func test07_createResidenceWithSpecialCharacters() { - let timestamp = Int(Date().timeIntervalSince1970) - let specialName = "Special !@#$%^&*() Home \(timestamp)" - - let success = createResidence(name: specialName) - XCTAssertTrue(success, "Should handle special characters") - - let residence = findResidence(name: "Special") - XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with special chars should exist") - } - - func test08_createResidenceWithEmojis() { - let timestamp = Int(Date().timeIntervalSince1970) - let emojiName = "Beach House \(timestamp)" - - let success = createResidence(name: emojiName) - XCTAssertTrue(success, "Should handle emojis") - - let residence = findResidence(name: "Beach House") - XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with emojis should exist") - } - - func test09_createResidenceWithInternationalCharacters() { - let timestamp = Int(Date().timeIntervalSince1970) - let internationalName = "Chateau Montreal \(timestamp)" - - let success = createResidence(name: internationalName) - XCTAssertTrue(success, "Should handle international characters") - - let residence = findResidence(name: "Chateau") - XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with international chars should exist") - } - - func test10_createResidenceWithVeryLongAddress() { - let timestamp = Int(Date().timeIntervalSince1970) - let residenceName = "Long Address Home \(timestamp)" - - let success = createResidence( - name: residenceName, - street: "123456789 Very Long Street Name That Goes On And On Boulevard Apartment Complex Unit 42B", - city: "VeryLongCityNameThatTestsTheLimit", - state: "CA", - postal: "12345-6789" - ) - XCTAssertTrue(success, "Should handle very long addresses") - - let residence = findResidence(name: residenceName) - XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist") - } - - // MARK: - 3. Edit/Update Tests - - func test11_editResidenceName() { - let timestamp = Int(Date().timeIntervalSince1970) - let originalName = "Original Name \(timestamp)" - let newName = "Edited Name \(timestamp)" - - // Create residence - guard createResidence(name: originalName) else { - XCTFail("Failed to create residence") - return - } - - navigateToResidencesTab() - sleep(2) - - // Find and tap residence - let residence = findResidence(name: originalName) - XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist") - residence.tap() - sleep(2) - - // Tap edit button - let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch - if editButton.exists { - editButton.tap() - sleep(2) - - // Edit name - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - if nameField.exists { - let element = app/*@START_MENU_TOKEN@*/.textFields["ResidenceForm.NameField"]/*[[".otherElements",".textFields[\"Original Name 1764809003\"]",".textFields[\"Property Name\"]",".textFields[\"ResidenceForm.NameField\"]"],[[[-1,3],[-1,2],[-1,1],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch - element.tap() - element.tap() - app/*@START_MENU_TOKEN@*/.menuItems["Select All"]/*[[".menuItems.containing(.staticText, identifier: \"Select All\")",".collectionViews.menuItems[\"Select All\"]",".menuItems[\"Select All\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - nameField.typeText(newName) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - if saveButton.exists { - saveButton.tap() - sleep(3) - - // Track new name - createdResidenceNames.append(newName) - - // Verify new name appears - navigateToResidencesTab() - sleep(2) - let updatedResidence = findResidence(name: newName) - XCTAssertTrue(updatedResidence.exists, "Residence should show updated name") - } - } - } - } - - func test12_updateAllResidenceFields() { - let timestamp = Int(Date().timeIntervalSince1970) - let originalName = "Update All Fields \(timestamp)" - let newName = "All Fields Updated \(timestamp)" - let newStreet = "999 Updated Avenue" - let newCity = "NewCity" - let newState = "NC" - let newPostal = "99999" - - // Create residence with initial values - guard createResidence(name: originalName, street: "123 Old St", city: "OldCity", state: "OC", postal: "11111") else { - XCTFail("Failed to create residence") - return - } - - navigateToResidencesTab() - sleep(2) - - // Find and tap residence - let residence = findResidence(name: originalName) - XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist") - residence.tap() - sleep(2) - - // Tap edit button - let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch - XCTAssertTrue(editButton.exists, "Edit button should exist") - editButton.tap() - sleep(2) - - // Update name - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - XCTAssertTrue(nameField.exists, "Name field should exist") - nameField.tap() - nameField.doubleTap() - sleep(1) - if app.buttons["Select All"].exists { - app.buttons["Select All"].tap() - sleep(1) - } - nameField.typeText(newName) - - // Update property type (if available) - let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch - if propertyTypePicker.exists { - propertyTypePicker.tap() - sleep(1) - // Select Condo - let condoOption = app.buttons["Condo"] - if condoOption.exists { - condoOption.tap() - sleep(1) - } else { - // Try cells navigation - let cells = app.cells - for i in 0.. XCUIElement { - sleep(2) // Wait for screen to fully render - - // Strategy 1: Try accessibility identifier - let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch - if addButtonById.exists && addButtonById.isEnabled { - return addButtonById - } - - // Strategy 2: Look for toolbar add button (navigation bar plus button) - let navBarButtons = app.navigationBars.buttons - for i in 0.. Bool { - let addButton = findAddTaskButton() - guard addButton.exists && addButton.isEnabled else { return false } - addButton.tap() - sleep(3) - - // Verify form opened - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - return titleField.waitForExistence(timeout: 5) - } - - private func findAddTaskButton() -> XCUIElement { - sleep(2) - - let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch - if addButtonById.exists && addButtonById.isEnabled { - return addButtonById - } - - let navBarButtons = app.navigationBars.buttons - for i in 0.. Bool { - guard openTaskForm() else { return false } - - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - titleField.tap() - titleField.typeText(title) - - if let desc = description { - if scrollToFindFields { app.swipeUp(); sleep(1) } - let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch - if descField.exists { - descField.tap() - descField.typeText(desc) - } - } - - // Scroll to Save button - app.swipeUp() - sleep(1) - - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - guard saveButton.exists else { return false } - saveButton.tap() - - sleep(4) // Wait for API call - - // Track created task - createdTaskTitles.append(title) - - return true - } - - private func findTask(title: String) -> XCUIElement { - return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch - } - - private func deleteAllTestTasks() { - for title in createdTaskTitles { - let task = findTask(title: title) - if task.exists { - task.tap() - sleep(2) - - // Try to find delete button - let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Cancel'")).firstMatch - if deleteButton.exists { - deleteButton.tap() - sleep(1) - - // Confirm deletion - let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm'")).firstMatch - if confirmButton.exists { - confirmButton.tap() - sleep(2) - } - } - - // Go back to list - let backButton = app.navigationBars.buttons.firstMatch - if backButton.exists { - backButton.tap() - sleep(1) - } - } - } - } - - // MARK: - 1. Error/Validation Tests - - func test01_cannotCreateTaskWithEmptyTitle() { - guard openTaskForm() else { - XCTFail("Failed to open task form") - return - } - - // Leave title empty but fill other required fields - // Select category - let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch - if categoryPicker.exists { - app.staticTexts["Appliances"].firstMatch.tap() - app.buttons["Plumbing"].firstMatch.tap() - } - - // Select frequency - let frequencyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Frequency'")).firstMatch - if frequencyPicker.exists { - app.staticTexts["Once"].firstMatch.tap() - app.buttons["Once"].firstMatch.tap() - } - - // Select priority - let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch - if priorityPicker.exists { - app.staticTexts["High"].firstMatch.tap() - app.buttons["Low"].firstMatch.tap() - } - - // Select status - let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch - if statusPicker.exists { - app.staticTexts["Pending"].firstMatch.tap() - app.buttons["Pending"].firstMatch.tap() - } - - // Scroll to save button - app.swipeUp() - sleep(1) - - // Save button should be disabled when title is empty - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - XCTAssertTrue(saveButton.exists, "Save button should exist") - XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when title is empty") - } - - func test02_cancelTaskCreation() { - guard openTaskForm() else { - XCTFail("Failed to open task form") - return - } - - // Fill some data - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - titleField.tap() - titleField.typeText("This will be canceled") - - // Tap cancel - let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - XCTAssertTrue(cancelButton.exists, "Cancel button should exist") - cancelButton.tap() - sleep(2) - - // Should be back on tasks list - let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch - XCTAssertTrue(tasksTab.exists, "Should be back on tasks list") - - // Task should not exist - let task = findTask(title: "This will be canceled") - XCTAssertFalse(task.exists, "Canceled task should not exist") - } - - // MARK: - 2. Creation Tests - - func test03_createTaskWithMinimalData() { - let timestamp = Int(Date().timeIntervalSince1970) - let taskTitle = "Minimal Task \(timestamp)" - - let success = createTask(title: taskTitle) - XCTAssertTrue(success, "Should successfully create task with minimal data") - - let taskInList = findTask(title: taskTitle) - XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Task should appear in list") - } - - func test04_createTaskWithAllFields() { - let timestamp = Int(Date().timeIntervalSince1970) - let taskTitle = "Complete Task \(timestamp)" - let description = "This is a comprehensive test task with all fields populated including a very detailed description." - - let success = createTask(title: taskTitle, description: description) - XCTAssertTrue(success, "Should successfully create task with all fields") - - let taskInList = findTask(title: taskTitle) - XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Complete task should appear in list") - } - - func test05_createMultipleTasksInSequence() { - let timestamp = Int(Date().timeIntervalSince1970) - - for i in 1...3 { - let taskTitle = "Sequential Task \(i) - \(timestamp)" - let success = createTask(title: taskTitle) - XCTAssertTrue(success, "Should create task \(i)") - - navigateToTasksTab() - sleep(2) - } - - // Verify all tasks exist - for i in 1...3 { - let taskTitle = "Sequential Task \(i) - \(timestamp)" - let task = findTask(title: taskTitle) - XCTAssertTrue(task.exists, "Task \(i) should exist in list") - } - } - - func test06_createTaskWithVeryLongTitle() { - let timestamp = Int(Date().timeIntervalSince1970) - let longTitle = "This is an extremely long task title that goes on and on and on to test how the system handles very long text input in the title field \(timestamp)" - - let success = createTask(title: longTitle) - XCTAssertTrue(success, "Should handle very long titles") - - // Verify it appears (may be truncated in display) - let task = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long task title'")).firstMatch - XCTAssertTrue(task.waitForExistence(timeout: 10), "Long title task should exist") - } - - func test07_createTaskWithSpecialCharacters() { - let timestamp = Int(Date().timeIntervalSince1970) - let specialTitle = "Special !@#$%^&*() Task \(timestamp)" - - let success = createTask(title: specialTitle) - XCTAssertTrue(success, "Should handle special characters") - - let task = findTask(title: "Special") - XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with special chars should exist") - } - - func test08_createTaskWithEmojis() { - let timestamp = Int(Date().timeIntervalSince1970) - let emojiTitle = "Fix Plumbing Task \(timestamp)" - - let success = createTask(title: emojiTitle) - XCTAssertTrue(success, "Should handle emojis") - - let task = findTask(title: "Fix Plumbing") - XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with emojis should exist") - } - - // MARK: - 3. Edit/Update Tests - - func test09_editTaskTitle() { - let timestamp = Int(Date().timeIntervalSince1970) - let originalTitle = "Original Title \(timestamp)" - let newTitle = "Edited Title \(timestamp)" - - // Create task - guard createTask(title: originalTitle) else { - XCTFail("Failed to create task") - return - } - - navigateToTasksTab() - sleep(2) - - // Find and tap task - let task = findTask(title: originalTitle) - XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist") - task.tap() - sleep(2) - - // Tap edit button - let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch - if editButton.exists { - editButton.tap() - sleep(2) - - // Edit title - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - if titleField.exists { - titleField.tap() - // Clear existing text - titleField.doubleTap() - sleep(1) - app.buttons["Select All"].tap() - sleep(1) - titleField.typeText(newTitle) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - if saveButton.exists { - saveButton.tap() - sleep(3) - - // Track new title - createdTaskTitles.append(newTitle) - - // Verify new title appears - navigateToTasksTab() - sleep(2) - let updatedTask = findTask(title: newTitle) - XCTAssertTrue(updatedTask.exists, "Task should show updated title") - } - } - } - } - - func test10_updateAllTaskFields() { - let timestamp = Int(Date().timeIntervalSince1970) - let originalTitle = "Update All Fields \(timestamp)" - let newTitle = "All Fields Updated \(timestamp)" - let newDescription = "This task has been fully updated with all new values including description, category, priority, and status." - - // Create task with initial values - guard createTask(title: originalTitle, description: "Original description") else { - XCTFail("Failed to create task") - return - } - - navigateToTasksTab() - sleep(2) - - // Find and tap task - let task = findTask(title: originalTitle) - XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist") - task.tap() - sleep(2) - - // Tap edit button - let editButton = app.staticTexts.matching(identifier: "Actions").element(boundBy: 0).firstMatch - XCTAssertTrue(editButton.exists, "Edit button should exist") - editButton.tap() - app.buttons["pencil"].firstMatch.tap() - sleep(2) - - // Update title - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - XCTAssertTrue(titleField.exists, "Title field should exist") - titleField.tap() - sleep(1) - titleField.tap() - sleep(1) - app.menuItems["Select All"].tap() - sleep(1) - titleField.typeText(newTitle) - - // Scroll to description - app.swipeUp() - sleep(1) - - // Update description - let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch - if descField.exists { - descField.tap() - sleep(1) - // Clear existing text - descField.doubleTap() - sleep(1) - if app.buttons["Select All"].exists { - app.buttons["Select All"].tap() - sleep(1) - } - descField.typeText(newDescription) - } - - // Update category (if picker exists) - let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch - if categoryPicker.exists { - categoryPicker.tap() - sleep(1) - // Select a different category - let electricalOption = app.buttons["Electrical"] - if electricalOption.exists { - electricalOption.tap() - sleep(1) - } - } - - // Scroll to more fields - app.swipeUp() - sleep(1) - - // Update priority (if picker exists) - let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch - if priorityPicker.exists { - priorityPicker.tap() - sleep(1) - // Select high priority - let highOption = app.buttons["High"] - if highOption.exists { - highOption.tap() - sleep(1) - } - } - - // Update status (if picker exists) - let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch - if statusPicker.exists { - statusPicker.tap() - sleep(1) - // Select in progress status - let inProgressOption = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'InProgress'")).firstMatch - if inProgressOption.exists { - inProgressOption.tap() - sleep(1) - } - } - - // Scroll to save button - app.swipeUp() - sleep(1) - - // Save - let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - XCTAssertTrue(saveButton.exists, "Save button should exist") - saveButton.tap() - sleep(4) - - // Track new title - createdTaskTitles.append(newTitle) - - // Verify updated task appears in list with new title - navigateToTasksTab() - sleep(2) - let updatedTask = findTask(title: newTitle) - XCTAssertTrue(updatedTask.exists, "Task should show updated title in list") - - // Tap on task to verify details were updated - updatedTask.tap() - sleep(2) - - // Verify updated priority (High) appears - let highPriorityBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'High'")).firstMatch - XCTAssertTrue(highPriorityBadge.exists || true, "Updated priority should be visible (if priority is shown in detail)") - } - - // MARK: - 4. Navigation/View Tests - - func test11_navigateFromTasksToOtherTabs() { - // From Tasks tab - navigateToTasksTab() - - // Navigate to Residences - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch - XCTAssertTrue(residencesTab.exists, "Residences tab should exist") - residencesTab.tap() - sleep(1) - XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab") - - // Navigate back to Tasks - let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch - tasksTab.tap() - sleep(1) - XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab") - - // Navigate to Contractors - let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch - XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist") - contractorsTab.tap() - sleep(1) - XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab") - - // Back to Tasks - tasksTab.tap() - sleep(1) - XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab again") - } - - func test12_refreshTasksList() { - navigateToTasksTab() - sleep(2) - - // Pull to refresh (if implemented) or use refresh button - let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch - if refreshButton.exists { - refreshButton.tap() - sleep(3) - } - - // Verify we're still on tasks tab - let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch - XCTAssertTrue(tasksTab.isSelected, "Should still be on Tasks tab after refresh") - } - - // MARK: - 5. Persistence Tests - - func test13_taskPersistsAfterBackgroundingApp() { - let timestamp = Int(Date().timeIntervalSince1970) - let taskTitle = "Persistence Test \(timestamp)" - - // Create task - guard createTask(title: taskTitle) else { - XCTFail("Failed to create task") - return - } - - navigateToTasksTab() - sleep(2) - - // Verify task exists - var task = findTask(title: taskTitle) - XCTAssertTrue(task.exists, "Task should exist before backgrounding") - - // Background and reactivate app - XCUIDevice.shared.press(.home) - sleep(2) - app.activate() - sleep(3) - - // Navigate back to tasks - navigateToTasksTab() - sleep(2) - - // Verify task still exists - task = findTask(title: taskTitle) - XCTAssertTrue(task.exists, "Task should persist after backgrounding app") - } - - // MARK: - 6. Performance Tests - - func test14_taskListPerformance() { - measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) { - navigateToTasksTab() - sleep(2) - } - } - - func test15_taskCreationPerformance() { - let timestamp = Int(Date().timeIntervalSince1970) - - measure(metrics: [XCTClockMetric()]) { - let taskTitle = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))" - _ = createTask(title: taskTitle) - } - } -} diff --git a/iosApp/CaseraUITests/Suite7_ContractorTests.swift b/iosApp/CaseraUITests/Suite7_ContractorTests.swift deleted file mode 100644 index 84fbe40..0000000 --- a/iosApp/CaseraUITests/Suite7_ContractorTests.swift +++ /dev/null @@ -1,718 +0,0 @@ -import XCTest - -/// Comprehensive contractor testing suite covering all scenarios, edge cases, and variations -/// This test suite is designed to be bulletproof and catch regressions early -final class Suite7_ContractorTests: XCTestCase { - var app: XCUIApplication! - - // Test data tracking - var createdContractorNames: [String] = [] - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - - // Ensure user is logged in - UITestHelpers.ensureLoggedIn(app: app) - - // Navigate to Contractors tab - navigateToContractorsTab() - } - - override func tearDownWithError() throws { - createdContractorNames.removeAll() - app = nil - } - - // MARK: - Helper Methods - - private func navigateToContractorsTab() { - let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch - if contractorsTab.waitForExistence(timeout: 5) { - if !contractorsTab.isSelected { - contractorsTab.tap() - sleep(3) - } - } - } - - private func openContractorForm() -> Bool { - let addButton = findAddContractorButton() - guard addButton.exists && addButton.isEnabled else { return false } - addButton.tap() - sleep(3) - - // Verify form opened - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - return nameField.waitForExistence(timeout: 5) - } - - private func findAddContractorButton() -> XCUIElement { - sleep(2) - - // Look for add button by various methods - let navBarButtons = app.navigationBars.buttons - for i in 0.. Bool { - guard openContractorForm() else { return false } - - // Fill name - let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch - nameField.tap() - nameField.typeText(name) - - // Fill phone (required field) - fillTextField(placeholder: "Phone", text: phone) - - // Fill optional fields - if let email = email { - fillTextField(placeholder: "Email", text: email) - } - - if let company = company { - fillTextField(placeholder: "Company", text: company) - } - - // Select specialty if provided - if let specialty = specialty { - selectSpecialty(specialty: specialty) - } - - // Scroll to save button if needed - if scrollBeforeSave { - app.swipeUp() - sleep(1) - } - - // Add button (for creating new contractors) - let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch - guard addButton.exists else { return false } - addButton.tap() - - sleep(4) // Wait for API call - - // Track created contractor - createdContractorNames.append(name) - - return true - } - - private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement { - let element = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch - - // If element is visible, return it immediately - if element.exists && element.isHittable { - return element - } - - // If scrolling is not needed, return the element as-is - guard scrollIfNeeded else { - return element - } - - // Get the scroll view - let scrollView = app.scrollViews.firstMatch - guard scrollView.exists else { - return element - } - - // First, scroll to the top of the list - scrollView.swipeDown(velocity: .fast) - usleep(30_000) // 0.03 second delay - - // Now scroll down from top, checking after each swipe - var lastVisibleRow = "" - for _ in 0.. Bool { - let addButton = findAddButton() - guard addButton.exists && addButton.isEnabled else { return false } - addButton.tap() - sleep(3) - - // Verify form opened - let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch - return titleField.waitForExistence(timeout: 5) - } - - private func findAddButton() -> XCUIElement { - sleep(2) - - // Look for add button by various methods - let navBarButtons = app.navigationBars.buttons - for i in 0.. Bool { - let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")).firstMatch - guard submitButton.exists && submitButton.isEnabled else { return false } - submitButton.tap() - sleep(3) - return true - } - - private func cancelForm() { - let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - if cancelButton.exists { - cancelButton.tap() - sleep(2) - } - } - - private func switchToWarrantiesTab() { - app/*@START_MENU_TOKEN@*/.buttons["checkmark.shield"]/*[[".segmentedControls",".buttons[\"Warranties\"]",".buttons[\"checkmark.shield\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - } - - private func switchToDocumentsTab() { - app/*@START_MENU_TOKEN@*/.buttons["doc.text"]/*[[".segmentedControls",".buttons[\"Documents\"]",".buttons[\"doc.text\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() - } - - private func searchFor(text: String) { - let searchField = app.searchFields.firstMatch - if searchField.exists { - searchField.tap() - searchField.typeText(text) - sleep(2) - } - } - - private func clearSearch() { - let searchField = app.searchFields.firstMatch - if searchField.exists { - let clearButton = searchField.buttons["Clear text"] - if clearButton.exists { - clearButton.tap() - sleep(1) - } - } - } - - private func applyFilter(filterName: String) { - // Open filter menu - let filterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'line.3.horizontal.decrease'")).firstMatch - if filterButton.exists { - filterButton.tap() - sleep(1) - - // Select filter option - let filterOption = app.buttons[filterName] - if filterOption.exists { - filterOption.tap() - sleep(2) - } - } - } - - private func toggleActiveFilter() { - let activeFilterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'checkmark.circle'")).firstMatch - if activeFilterButton.exists { - activeFilterButton.tap() - sleep(2) - } - } - - // MARK: - Test Cases - - // MARK: Navigation Tests - - func test01_NavigateToDocumentsScreen() { - navigateToDocumentsTab() - - // Verify we're on documents screen - let navigationTitle = app.navigationBars["Documents & Warranties"] - XCTAssertTrue(navigationTitle.waitForExistence(timeout: 5), "Should navigate to Documents & Warranties screen") - - // Verify tabs are visible - let warrantiesTab = app.buttons["Warranties"] - let documentsTab = app.buttons["Documents"] - XCTAssertTrue(warrantiesTab.exists || documentsTab.exists, "Should see tab switcher") - } - - func test02_SwitchBetweenWarrantiesAndDocuments() { - navigateToDocumentsTab() - - // Start on warranties tab - switchToWarrantiesTab() - sleep(1) - - // Switch to documents tab - switchToDocumentsTab() - sleep(1) - - // Switch back to warranties - switchToWarrantiesTab() - sleep(1) - - // Should not crash and tabs should still exist - let warrantiesTab = app.buttons["Warranties"] - XCTAssertTrue(warrantiesTab.exists, "Tabs should remain functional after switching") - } - - // MARK: Document Creation Tests - - func test03_CreateDocumentWithAllFields() { - navigateToDocumentsTab() - switchToDocumentsTab() - - XCTAssertTrue(openDocumentForm(), "Should open document form") - - let testTitle = "Test Permit \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - - // Fill all fields - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectDocumentType(type: "Insurance") - fillTextEditor(text: "Test permit description with detailed information") - fillTextField(placeholder: "Tags", text: "construction,permit") - fillTextField(placeholder: "Item Name", text: "Kitchen Renovation") - fillTextField(placeholder: "Location", text: "Main Kitchen") - - XCTAssertTrue(submitForm(), "Should submit form successfully") - - // Verify document appears in list - sleep(2) - let documentCard = app.staticTexts[testTitle] - XCTAssertTrue(documentCard.exists, "Created document should appear in list") - } - - func test04_CreateDocumentWithMinimalFields() { - navigateToDocumentsTab() - switchToDocumentsTab() - - XCTAssertTrue(openDocumentForm(), "Should open document form") - - let testTitle = "Min Doc \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - - // Fill only required fields - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectDocumentType(type: "Insurance") - - XCTAssertTrue(submitForm(), "Should submit form with minimal fields") - - // Verify document appears - sleep(2) - let documentCard = app.staticTexts[testTitle] - XCTAssertTrue(documentCard.exists, "Document with minimal fields should appear") - } - - func test05_CreateDocumentWithEmptyTitle_ShouldFail() { - navigateToDocumentsTab() - switchToDocumentsTab() - - XCTAssertTrue(openDocumentForm(), "Should open document form") - - // Try to submit without title - selectProperty() // REQUIRED - Select property first - selectDocumentType(type: "Insurance") - - let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add'")).firstMatch - - // Submit button should be disabled or show error - if submitButton.exists && submitButton.isEnabled { - submitButton.tap() - sleep(2) - - // Should show error message - let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'title'")).firstMatch - XCTAssertTrue(errorMessage.exists, "Should show validation error for missing title") - } - - cancelForm() - } - - // MARK: Warranty Creation Tests - - func test06_CreateWarrantyWithAllFields() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - XCTAssertTrue(openDocumentForm(), "Should open warranty form") - - let testTitle = "Test Warranty \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - - // Fill all warranty fields (including required fields) - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectCategory(category: "Appliances") - fillTextField(placeholder: "Item Name", text: "Dishwasher") // REQUIRED - fillTextField(placeholder: "Provider", text: "Bosch") // REQUIRED - fillTextField(placeholder: "Model", text: "SHPM65Z55N") - fillTextField(placeholder: "Serial", text: "SN123456789") - fillTextField(placeholder: "Provider Contact", text: "1-800-BOSCH-00") - fillTextEditor(text: "Full warranty coverage for 2 years") - - // Select dates - selectDate(dateType: "Start Date", daysFromNow: -30) - selectDate(dateType: "End Date", daysFromNow: 700) // ~2 years - - XCTAssertTrue(submitForm(), "Should submit warranty successfully") - - // Verify warranty appears - sleep(2) - let warrantyCard = app.staticTexts[testTitle] - XCTAssertTrue(warrantyCard.exists, "Created warranty should appear in list") - } - - func test07_CreateWarrantyWithFutureDates() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - XCTAssertTrue(openDocumentForm(), "Should open warranty form") - - let testTitle = "Future Warranty \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectCategory(category: "HVAC") - fillTextField(placeholder: "Item Name", text: "Air Conditioner") // REQUIRED - fillTextField(placeholder: "Provider", text: "Carrier HVAC") // REQUIRED - - // Set start date in future - selectDate(dateType: "Start Date", daysFromNow: 30) - selectDate(dateType: "End Date", daysFromNow: 400) - - XCTAssertTrue(submitForm(), "Should create warranty with future dates") - - sleep(2) - let warrantyCard = app.staticTexts[testTitle] - XCTAssertTrue(warrantyCard.exists, "Warranty with future dates should be created") - } - - func test08_CreateExpiredWarranty() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - XCTAssertTrue(openDocumentForm(), "Should open warranty form") - - let testTitle = "Expired Warranty \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectCategory(category: "Plumbing") - fillTextField(placeholder: "Item Name", text: "Water Heater") // REQUIRED - fillTextField(placeholder: "Provider", text: "AO Smith") // REQUIRED - - // Set dates in the past - selectDate(dateType: "Start Date", daysFromNow: -400) - selectDate(dateType: "End Date", daysFromNow: -30) - - XCTAssertTrue(submitForm(), "Should create expired warranty") - - sleep(2) - // Expired warranty might not show with active filter on - // Toggle active filter off to see it - toggleActiveFilter() - sleep(1) - - let warrantyCard = app.staticTexts[testTitle] - XCTAssertTrue(warrantyCard.exists, "Expired warranty should be created and visible when filter is off") - } - - // MARK: Search and Filter Tests - - func test09_SearchDocumentsByTitle() { - navigateToDocumentsTab() - switchToDocumentsTab() - - // Create a test document first - XCTAssertTrue(openDocumentForm(), "Should open form") - let searchableTitle = "Searchable Doc \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(searchableTitle) - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: searchableTitle) - selectDocumentType(type: "Insurance") - XCTAssertTrue(submitForm(), "Should create document") - sleep(2) - - // Search for it - searchFor(text: String(searchableTitle.prefix(15))) - - // Should find the document - let foundDocument = app.staticTexts[searchableTitle] - XCTAssertTrue(foundDocument.exists, "Should find document by search") - - clearSearch() - } - - func test10_FilterWarrantiesByCategory() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Apply category filter - applyFilter(filterName: "Appliances") - - sleep(2) - - // Should show filter chip or indication - let filterChip = app.staticTexts["Appliances"] - XCTAssertTrue(filterChip.exists || app.buttons["Appliances"].exists, "Should show active category filter") - - // Clear filter - applyFilter(filterName: "All Categories") - } - - func test11_FilterDocumentsByType() { - navigateToDocumentsTab() - switchToDocumentsTab() - - // Apply type filter - applyFilter(filterName: "Permit") - - sleep(2) - - // Should show filter indication - let filterChip = app.staticTexts["Permit"] - XCTAssertTrue(filterChip.exists || app.buttons["Permit"].exists, "Should show active type filter") - - // Clear filter - applyFilter(filterName: "All Types") - } - - func test12_ToggleActiveWarrantiesFilter() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Toggle active filter off - toggleActiveFilter() - sleep(1) - - // Toggle it back on - toggleActiveFilter() - sleep(1) - - // Should not crash - let warrantiesTab = app.buttons["Warranties"] - XCTAssertTrue(warrantiesTab.exists, "Active filter toggle should work without crashing") - } - - // MARK: Document Detail Tests - - func test13_ViewDocumentDetail() { - navigateToDocumentsTab() - switchToDocumentsTab() - - // Create a document - XCTAssertTrue(openDocumentForm(), "Should open form") - let testTitle = "Detail Test Doc \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectDocumentType(type: "Insurance") - fillTextEditor(text: "This is a test receipt with details") - XCTAssertTrue(submitForm(), "Should create document") - sleep(2) - - // Tap on the document card - let documentCard = app.staticTexts[testTitle] - XCTAssertTrue(documentCard.exists, "Document should exist in list") - documentCard.tap() - sleep(2) - - // Should show detail screen - let detailTitle = app.staticTexts[testTitle] - XCTAssertTrue(detailTitle.exists, "Should show document detail screen") - - // Go back - let backButton = app.navigationBars.buttons.firstMatch - backButton.tap() - sleep(1) - } - - func test14_ViewWarrantyDetailWithDates() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Create a warranty - XCTAssertTrue(openDocumentForm(), "Should open form") - let testTitle = "Warranty Detail Test \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectCategory(category: "Appliances") - fillTextField(placeholder: "Item Name", text: "Test Appliance") // REQUIRED - fillTextField(placeholder: "Provider", text: "Test Company") // REQUIRED - selectDate(dateType: "Start Date", daysFromNow: -30) - selectDate(dateType: "End Date", daysFromNow: 335) - XCTAssertTrue(submitForm(), "Should create warranty") - sleep(2) - - // Tap on warranty - let warrantyCard = app.staticTexts[testTitle] - XCTAssertTrue(warrantyCard.exists, "Warranty should exist") - warrantyCard.tap() - sleep(2) - - // Should show warranty details with dates - let detailScreen = app.staticTexts[testTitle] - XCTAssertTrue(detailScreen.exists, "Should show warranty detail") - - // Look for date information - let dateLabels = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] '20' OR label CONTAINS[c] 'Start' OR label CONTAINS[c] 'End'")) - XCTAssertTrue(dateLabels.count > 0, "Should display date information") - - // Go back - app.navigationBars.buttons.firstMatch.tap() - sleep(1) - } - - // MARK: Edit Tests - - func test15_EditDocumentTitle() { - navigateToDocumentsTab() - switchToDocumentsTab() - - // Create document - XCTAssertTrue(openDocumentForm(), "Should open form") - let originalTitle = "Edit Test \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(originalTitle) - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: originalTitle) - selectDocumentType(type: "Insurance") - XCTAssertTrue(submitForm(), "Should create document") - sleep(2) - - // Open detail - let documentCard = app.staticTexts[originalTitle] - XCTAssertTrue(documentCard.exists, "Document should exist") - documentCard.tap() - sleep(2) - - // Tap edit button - let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch - if editButton.exists { - editButton.tap() - sleep(2) - - // Change title - let titleField = app.textFields.containing(NSPredicate(format: "value == '\(originalTitle)'")).firstMatch - if titleField.exists { - titleField.tap() - titleField.clearText() - let newTitle = "Edited \(originalTitle)" - titleField.typeText(newTitle) - createdDocumentTitles.append(newTitle) - - XCTAssertTrue(submitForm(), "Should save edited document") - sleep(2) - - // Verify new title appears - let updatedTitle = app.staticTexts[newTitle] - XCTAssertTrue(updatedTitle.exists, "Updated title should appear") - } - } - - // Go back to list - app.navigationBars.buttons.element(boundBy: 0).tap() - sleep(1) - } - - func test16_EditWarrantyDates() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Create warranty - XCTAssertTrue(openDocumentForm(), "Should open form") - let testTitle = "Edit Dates Warranty \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(testTitle) - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: testTitle) - selectCategory(category: "Electronics") - fillTextField(placeholder: "Item Name", text: "TV") // REQUIRED - fillTextField(placeholder: "Provider", text: "Samsung") // REQUIRED - selectDate(dateType: "Start Date", daysFromNow: -60) - selectDate(dateType: "End Date", daysFromNow: 305) - XCTAssertTrue(submitForm(), "Should create warranty") - sleep(2) - - // Open and edit - let warrantyCard = app.staticTexts[testTitle] - XCTAssertTrue(warrantyCard.exists, "Warranty should exist") - warrantyCard.tap() - sleep(2) - - let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch - if editButton.exists { - editButton.tap() - sleep(2) - - // Change end date to extend warranty - selectDate(dateType: "End Date", daysFromNow: 730) // 2 years - - XCTAssertTrue(submitForm(), "Should save edited warranty dates") - sleep(2) - } - - app.navigationBars.buttons.element(boundBy: 0).tap() - sleep(1) - } - - // MARK: Delete Tests - - func test17_DeleteDocument() { - navigateToDocumentsTab() - switchToDocumentsTab() - - // Create document to delete - XCTAssertTrue(openDocumentForm(), "Should open form") - let deleteTitle = "To Delete \(UUID().uuidString.prefix(8))" - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: deleteTitle) - selectDocumentType(type: "Insurance") - XCTAssertTrue(submitForm(), "Should create document") - sleep(2) - - // Open detail - let documentCard = app.staticTexts[deleteTitle] - XCTAssertTrue(documentCard.exists, "Document should exist") - documentCard.tap() - sleep(2) - - // Find and tap delete button - let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch - if deleteButton.exists { - deleteButton.tap() - sleep(1) - - // Confirm deletion - let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")).firstMatch - if confirmButton.exists { - confirmButton.tap() - sleep(2) - } - - // Should navigate back to list - sleep(2) - - // Verify document no longer exists - let deletedCard = app.staticTexts[deleteTitle] - XCTAssertFalse(deletedCard.exists, "Deleted document should not appear in list") - } - } - - func test18_DeleteWarranty() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Create warranty to delete - XCTAssertTrue(openDocumentForm(), "Should open form") - let deleteTitle = "Warranty to Delete \(UUID().uuidString.prefix(8))" - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: deleteTitle) - selectCategory(category: "Other") - fillTextField(placeholder: "Item Name", text: "Test Item") // REQUIRED - fillTextField(placeholder: "Provider", text: "Test Provider") // REQUIRED - XCTAssertTrue(submitForm(), "Should create warranty") - sleep(2) - - // Open and delete - let warrantyCard = app.staticTexts[deleteTitle] - XCTAssertTrue(warrantyCard.exists, "Warranty should exist") - warrantyCard.tap() - sleep(2) - - let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch - if deleteButton.exists { - deleteButton.tap() - sleep(1) - - // Confirm - let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch - if confirmButton.exists { - confirmButton.tap() - sleep(2) - } - - // Verify deleted - sleep(2) - let deletedCard = app.staticTexts[deleteTitle] - XCTAssertFalse(deletedCard.exists, "Deleted warranty should not appear") - } - } - - // MARK: Edge Cases and Error Handling - - func test19_CancelDocumentCreation() { - navigateToDocumentsTab() - switchToDocumentsTab() - - XCTAssertTrue(openDocumentForm(), "Should open form") - - // Fill some fields - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: "Cancelled Document") - selectDocumentType(type: "Insurance") - - // Cancel instead of save - cancelForm() - - // Should not appear in list - sleep(2) - let cancelledDoc = app.staticTexts["Cancelled Document"] - XCTAssertFalse(cancelledDoc.exists, "Cancelled document should not be created") - } - - func test20_HandleEmptyDocumentsList() { - navigateToDocumentsTab() - switchToDocumentsTab() - - // Apply very specific filter to get empty list - searchFor(text: "NONEXISTENT_DOCUMENT_12345") - - sleep(2) - - // Should show empty state - let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No documents' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch - - // Either empty state exists or no items are shown - let hasNoItems = app.cells.count == 0 - XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty documents list gracefully") - - clearSearch() - } - - func test21_HandleEmptyWarrantiesList() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Search for non-existent warranty - searchFor(text: "NONEXISTENT_WARRANTY_99999") - - sleep(2) - - let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No warranties' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch - let hasNoItems = app.cells.count == 0 - XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty warranties list gracefully") - - clearSearch() - } - - func test22_CreateDocumentWithLongTitle() { - navigateToDocumentsTab() - switchToDocumentsTab() - - XCTAssertTrue(openDocumentForm(), "Should open form") - - let longTitle = "This is a very long document title that exceeds normal length expectations to test how the UI handles lengthy text input " + UUID().uuidString - createdDocumentTitles.append(longTitle) - - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: longTitle) - selectDocumentType(type: "Insurance") - - XCTAssertTrue(submitForm(), "Should handle long title") - - sleep(2) - // Just verify it was created (partial match) - let partialTitle = String(longTitle.prefix(30)) - let documentExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch.exists - XCTAssertTrue(documentExists, "Document with long title should be created") - } - - func test23_CreateWarrantyWithSpecialCharacters() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - XCTAssertTrue(openDocumentForm(), "Should open form") - - let specialTitle = "Warranty w/ Special #Chars: @ & $ % \(UUID().uuidString.prefix(8))" - createdDocumentTitles.append(specialTitle) - - selectProperty() // REQUIRED - Select property first - fillTextField(placeholder: "Title", text: specialTitle) - selectCategory(category: "Other") - fillTextField(placeholder: "Item Name", text: "Test @#$ Item") // REQUIRED - fillTextField(placeholder: "Provider", text: "Special & Co.") // REQUIRED - - XCTAssertTrue(submitForm(), "Should handle special characters") - - sleep(2) - let partialTitle = String(specialTitle.prefix(20)) - let warrantyExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch.exists - XCTAssertTrue(warrantyExists, "Warranty with special characters should be created") - } - - func test24_RapidTabSwitching() { - navigateToDocumentsTab() - - // Rapidly switch between tabs - for _ in 0..<5 { - switchToWarrantiesTab() - usleep(500000) // 0.5 seconds - switchToDocumentsTab() - usleep(500000) // 0.5 seconds - } - - // Should remain stable - let warrantiesTab = app.buttons["Warranties"] - let documentsTab = app.buttons["Documents"] - XCTAssertTrue(warrantiesTab.exists && documentsTab.exists, "Should handle rapid tab switching without crashing") - } - - func test25_MultipleFiltersCombined() { - navigateToDocumentsTab() - switchToWarrantiesTab() - - // Apply multiple filters - toggleActiveFilter() // Turn off active filter - sleep(1) - applyFilter(filterName: "Appliances") - sleep(1) - searchFor(text: "Test") - - sleep(2) - - // Should apply all filters without crashing - let searchField = app.searchFields.firstMatch - XCTAssertTrue(searchField.exists, "Should handle multiple filters simultaneously") - - // Clean up - clearSearch() - sleep(1) - applyFilter(filterName: "All Categories") - sleep(1) - toggleActiveFilter() // Turn active filter back on - } -} - -// MARK: - XCUIElement Extension for Clearing Text - -extension XCUIElement { - func clearText() { - guard let stringValue = self.value as? String else { - return - } - - self.tap() - - let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) - self.typeText(deleteString) - } -} diff --git a/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift b/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift deleted file mode 100644 index b0b7d50..0000000 --- a/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift +++ /dev/null @@ -1,526 +0,0 @@ -import XCTest - -/// Comprehensive End-to-End Integration Tests -/// Mirrors the backend integration tests in myCribAPI-go/internal/integration/integration_test.go -/// -/// This test suite covers: -/// 1. Full authentication flow (register, login, logout) -/// 2. Residence CRUD operations -/// 3. Task lifecycle (create, update, mark-in-progress, complete, archive, cancel) -/// 4. Residence sharing between users -/// 5. Cross-user access control -/// -/// IMPORTANT: These tests create real data and require network connectivity. -/// Run with a test server or dev environment (not production). -final class Suite9_IntegrationE2ETests: XCTestCase { - var app: XCUIApplication! - - // Test user credentials - unique per test run - private let timestamp = Int(Date().timeIntervalSince1970) - - private var userAUsername: String { "e2e_usera_\(timestamp)" } - private var userAEmail: String { "e2e_usera_\(timestamp)@test.com" } - private var userAPassword: String { "TestPass123!" } - - private var userBUsername: String { "e2e_userb_\(timestamp)" } - private var userBEmail: String { "e2e_userb_\(timestamp)@test.com" } - private var userBPassword: String { "TestPass456!" } - - /// Fixed verification code used by Go API when DEBUG=true - private let verificationCode = "123456" - - override func setUpWithError() throws { - continueAfterFailure = false - app = XCUIApplication() - app.launch() - } - - override func tearDownWithError() throws { - app = nil - } - - // MARK: - Helper Methods - - private func ensureLoggedOut() { - UITestHelpers.ensureLoggedOut(app: app) - } - - private func login(username: String, password: String) { - UITestHelpers.login(app: app, username: username, password: password) - } - - /// Navigate to a specific tab - private func navigateToTab(_ tabName: String) { - let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch - if tab.waitForExistence(timeout: 5) && !tab.isSelected { - tab.tap() - sleep(2) - } - } - - /// Dismiss keyboard by tapping outside (doesn't submit forms) - private func dismissKeyboard() { - let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)) - coordinate.tap() - Thread.sleep(forTimeInterval: 0.5) - } - - /// Dismiss strong password suggestion if shown - private func dismissStrongPasswordSuggestion() { - let chooseOwnPassword = app.buttons["Choose My Own Password"] - if chooseOwnPassword.waitForExistence(timeout: 1) { - chooseOwnPassword.tap() - return - } - let notNow = app.buttons["Not Now"] - if notNow.exists && notNow.isHittable { - notNow.tap() - } - } - - // MARK: - Test 1: Complete Authentication Flow - // Mirrors TestIntegration_AuthenticationFlow - - func test01_authenticationFlow() { - // Phase 1: Start on login screen - let welcomeText = app.staticTexts["Welcome Back"] - if !welcomeText.waitForExistence(timeout: 5) { - ensureLoggedOut() - } - XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should start on login screen") - - // Phase 2: Navigate to registration - let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch - XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button should exist") - signUpButton.tap() - sleep(2) - - // Phase 3: Fill registration form using proper accessibility identifiers - let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] - XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist") - usernameField.tap() - usernameField.typeText(userAUsername) - - let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField] - XCTAssertTrue(emailField.waitForExistence(timeout: 3), "Email field should exist") - emailField.tap() - emailField.typeText(userAEmail) - - // Password field - check both SecureField and TextField - var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField] - if !passwordField.exists { - passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField] - } - XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist") - passwordField.tap() - dismissStrongPasswordSuggestion() - passwordField.typeText(userAPassword) - - // Confirm password field - var confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] - if !confirmPasswordField.exists { - confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] - } - XCTAssertTrue(confirmPasswordField.waitForExistence(timeout: 3), "Confirm password field should exist") - confirmPasswordField.tap() - dismissStrongPasswordSuggestion() - confirmPasswordField.typeText(userAPassword) - - dismissKeyboard() - sleep(1) - - // Phase 4: Submit registration - app.swipeUp() - sleep(1) - - let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] - XCTAssertTrue(registerButton.waitForExistence(timeout: 5), "Register button should exist") - registerButton.tap() - sleep(3) - - // Phase 5: Handle email verification - let verifyEmailTitle = app.staticTexts["Verify Your Email"] - XCTAssertTrue(verifyEmailTitle.waitForExistence(timeout: 10), "Verification screen must appear after registration") - - sleep(3) - - // Enter verification code - auto-submits when 6 digits entered - let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] - XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist") - codeField.tap() - codeField.typeText(verificationCode) - sleep(5) - - // Phase 6: Verify logged in - let tabBar = app.tabBars.firstMatch - XCTAssertTrue(tabBar.waitForExistence(timeout: 15), "Should be logged in after registration") - - // Phase 7: Logout - UITestHelpers.logout(app: app) - - // Phase 8: Login with created credentials - XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen after logout") - login(username: userAUsername, password: userAPassword) - - // Phase 9: Verify logged in - XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Should be logged in after login") - - // Phase 10: Final logout - UITestHelpers.logout(app: app) - XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be logged out") - } - - // MARK: - Test 2: Residence CRUD Flow - // Mirrors TestIntegration_ResidenceFlow - - func test02_residenceCRUDFlow() { - // Ensure logged in as test user - UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) - navigateToTab("Residences") - sleep(2) - - let residenceName = "E2E Test Home \(timestamp)" - - // Phase 1: Create residence - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist") - addButton.tap() - sleep(2) - - // Fill form - just tap and type, don't dismiss keyboard between fields - let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField] - XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should exist") - nameField.tap() - sleep(1) - nameField.typeText(residenceName) - - // Use return key to move to next field or dismiss, then scroll - app.keyboards.buttons["return"].tap() - sleep(1) - - // Scroll to show more fields - app.swipeUp() - sleep(1) - - // Fill street field - let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField] - if streetField.waitForExistence(timeout: 3) && streetField.isHittable { - streetField.tap() - sleep(1) - streetField.typeText("123 E2E Test St") - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Fill city field - let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField] - if cityField.waitForExistence(timeout: 3) && cityField.isHittable { - cityField.tap() - sleep(1) - cityField.typeText("Austin") - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Fill state field - let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField] - if stateField.waitForExistence(timeout: 3) && stateField.isHittable { - stateField.tap() - sleep(1) - stateField.typeText("TX") - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Fill postal code field - let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField] - if postalField.waitForExistence(timeout: 3) && postalField.isHittable { - postalField.tap() - sleep(1) - postalField.typeText("78701") - } - - // Dismiss keyboard and scroll to save button - dismissKeyboard() - sleep(1) - app.swipeUp() - sleep(1) - - // Save the residence - let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton] - if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable { - saveButton.tap() - } else { - // Try finding by label as fallback - let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - XCTAssertTrue(saveByLabel.waitForExistence(timeout: 5), "Save button should exist") - saveByLabel.tap() - } - sleep(3) - - // Phase 2: Verify residence was created - navigateToTab("Residences") - sleep(2) - let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch - XCTAssertTrue(residenceCard.waitForExistence(timeout: 10), "Residence '\(residenceName)' should appear in list") - } - - // MARK: - Test 3: Task Lifecycle Flow - // Mirrors TestIntegration_TaskFlow - - func test03_taskLifecycleFlow() { - // Ensure logged in - UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) - - // Ensure residence exists first - create one if empty - navigateToTab("Residences") - sleep(2) - - let residenceCards = app.cells - if residenceCards.count == 0 { - // No residences, create one first - createMinimalResidence(name: "Task Test Home \(timestamp)") - sleep(2) - } - - // Navigate to Tasks - navigateToTab("Tasks") - sleep(3) - - let taskTitle = "E2E Task Lifecycle \(timestamp)" - - // Phase 1: Create task - use firstMatch to avoid multiple element issue - let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch - guard addButton.waitForExistence(timeout: 5) else { - XCTFail("Add task button should exist") - return - } - - // Check if button is enabled - guard addButton.isEnabled else { - XCTFail("Add task button should be enabled (requires at least one residence)") - return - } - - addButton.tap() - sleep(2) - - // Fill task form - let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch - XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should exist") - titleField.tap() - sleep(1) - titleField.typeText(taskTitle) - - dismissKeyboard() - sleep(1) - app.swipeUp() - sleep(1) - - // Save the task - let saveTaskButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch - if saveTaskButton.waitForExistence(timeout: 5) && saveTaskButton.isHittable { - saveTaskButton.tap() - } else { - let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'Create'")).firstMatch - XCTAssertTrue(saveByLabel.exists, "Save/Create button should exist") - saveByLabel.tap() - } - sleep(3) - - // Phase 2: Verify task was created - navigateToTab("Tasks") - sleep(2) - let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch - XCTAssertTrue(taskCard.waitForExistence(timeout: 10), "Task '\(taskTitle)' should appear in task list") - } - - // MARK: - Test 4: Kanban Column Distribution - // Mirrors TestIntegration_TasksByResidenceKanban - - func test04_kanbanColumnDistribution() { - UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) - navigateToTab("Tasks") - sleep(3) - - // Verify tasks screen is showing - let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch - let kanbanExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Overdue' OR label CONTAINS[c] 'Upcoming' OR label CONTAINS[c] 'In Progress'")).firstMatch.exists - - XCTAssertTrue(kanbanExists || tasksTitle.exists, "Tasks screen should be visible") - } - - // MARK: - Test 5: Cross-User Access Control - // Mirrors TestIntegration_CrossUserAccessDenied - - func test05_crossUserAccessControl() { - UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) - - // Verify user can access their residences tab - navigateToTab("Residences") - sleep(2) - - let residencesVisible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch.isSelected - XCTAssertTrue(residencesVisible, "User should be able to access Residences tab") - - // Verify user can access their tasks tab - navigateToTab("Tasks") - sleep(2) - - let tasksAccessible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch.isSelected - XCTAssertTrue(tasksAccessible, "User should be able to access Tasks tab") - } - - // MARK: - Test 6: Lookup Data Endpoints - // Mirrors TestIntegration_LookupEndpoints - - func test06_lookupDataAvailable() { - UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) - - // Navigate to add residence to check residence types are loaded - navigateToTab("Residences") - sleep(2) - - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - if addButton.waitForExistence(timeout: 5) { - addButton.tap() - sleep(2) - - // Check property type picker exists (indicates lookups loaded) - let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type' OR label CONTAINS[c] 'Type'")).firstMatch - let pickerExists = propertyTypePicker.exists - - // Cancel form - let cancelButton = app.buttons[AccessibilityIdentifiers.Residence.formCancelButton] - if cancelButton.exists { - cancelButton.tap() - } else { - let cancelByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch - if cancelByLabel.exists { - cancelByLabel.tap() - } - } - - XCTAssertTrue(pickerExists, "Property type picker should exist (lookups loaded)") - } - } - - // MARK: - Test 7: Residence Sharing Flow - // Mirrors TestIntegration_ResidenceSharingFlow - - func test07_residenceSharingUIElements() { - UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) - navigateToTab("Residences") - sleep(2) - - // Find any residence to check sharing UI - let residenceCard = app.cells.firstMatch - if residenceCard.waitForExistence(timeout: 5) { - residenceCard.tap() - sleep(2) - - // Look for share button in residence details - let shareButton = app.buttons[AccessibilityIdentifiers.Residence.shareButton] - let manageUsersButton = app.buttons[AccessibilityIdentifiers.Residence.manageUsersButton] - - // Note: Share functionality may not be visible depending on user permissions - // This test just verifies we can navigate to residence details - - // Navigate back - let backButton = app.navigationBars.buttons.element(boundBy: 0) - if backButton.exists && backButton.isHittable { - backButton.tap() - sleep(1) - } - } - } - - // MARK: - Helper: Create Minimal Residence - - private func createMinimalResidence(name: String) { - let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] - guard addButton.waitForExistence(timeout: 5) else { return } - - addButton.tap() - sleep(2) - - // Fill name field - let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField] - if nameField.waitForExistence(timeout: 5) { - nameField.tap() - sleep(1) - nameField.typeText(name) - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Scroll to show address fields - app.swipeUp() - sleep(1) - - // Fill street field - let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField] - if streetField.waitForExistence(timeout: 3) && streetField.isHittable { - streetField.tap() - sleep(1) - streetField.typeText("123 Test St") - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Fill city field - let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField] - if cityField.waitForExistence(timeout: 3) && cityField.isHittable { - cityField.tap() - sleep(1) - cityField.typeText("Austin") - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Fill state field - let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField] - if stateField.waitForExistence(timeout: 3) && stateField.isHittable { - stateField.tap() - sleep(1) - stateField.typeText("TX") - app.keyboards.buttons["return"].tap() - sleep(1) - } - - // Fill postal code field - let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField] - if postalField.waitForExistence(timeout: 3) && postalField.isHittable { - postalField.tap() - sleep(1) - postalField.typeText("78701") - } - - dismissKeyboard() - sleep(1) - app.swipeUp() - sleep(1) - - // Save - let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton] - if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable { - saveButton.tap() - } else { - let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch - if saveByLabel.exists { - saveByLabel.tap() - } - } - sleep(3) - } - - // MARK: - Helper: Find Add Task Button - - private func findAddTaskButton() -> XCUIElement { - let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton] - if addButton.exists { - return addButton - } - return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'New Task'")).firstMatch - } -} diff --git a/iosApp/CaseraUITests/UITestHelpers.swift b/iosApp/CaseraUITests/UITestHelpers.swift index c5bd3e9..98ff65b 100644 --- a/iosApp/CaseraUITests/UITestHelpers.swift +++ b/iosApp/CaseraUITests/UITestHelpers.swift @@ -1,49 +1,43 @@ import XCTest -/// Reusable helper functions for UI tests +/// Reusable helper functions for UI tests. +/// All waits use explicit conditions β€” zero sleep() calls. struct UITestHelpers { // MARK: - Authentication Helpers - /// Logs out the user if they are currently logged in - /// - Parameter app: The XCUIApplication instance + /// Logs out the user if they are currently logged in. static func logout(app: XCUIApplication) { - sleep(2) - // Check if already logged out (login screen visible) let welcomeText = app.staticTexts["Welcome Back"] - if welcomeText.exists { - // Already logged out + if welcomeText.waitForExistence(timeout: 3) { return } // Check if we have a tab bar (logged in state) let tabBar = app.tabBars.firstMatch - guard tabBar.exists else { return } + guard tabBar.waitForExistence(timeout: 3) else { return } // Navigate to Residences tab first - let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + let residencesTab = app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab] if residencesTab.exists { residencesTab.tap() - sleep(1) } // Tap settings button let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton] - if settingsButton.waitForExistence(timeout: 3) && settingsButton.isHittable { + if settingsButton.waitForExistence(timeout: 5) { settingsButton.tap() - sleep(1) } // Find and tap logout button let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton] - if logoutButton.waitForExistence(timeout: 3) { + if logoutButton.waitForExistence(timeout: 5) { logoutButton.tap() - sleep(1) - // Confirm logout in alert if present - specifically target the alert's button + // Confirm logout in alert if present let alert = app.alerts.firstMatch - if alert.waitForExistence(timeout: 2) { + if alert.waitForExistence(timeout: 3) { let confirmLogout = alert.buttons["Log Out"] if confirmLogout.exists { confirmLogout.tap() @@ -51,25 +45,21 @@ struct UITestHelpers { } } - sleep(2) - // Verify we're back on login screen - XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Failed to log out - Welcome Back screen should appear after logout") + XCTAssertTrue( + welcomeText.waitForExistence(timeout: 10), + "Failed to log out - Welcome Back screen should appear after logout" + ) } - /// Logs in a user with the provided credentials - /// - Parameters: - /// - app: The XCUIApplication instance - /// - username: The username/email to use for login - /// - password: The password to use for login + /// Logs in a user with the provided credentials. static func login(app: XCUIApplication, username: String, password: String) { - // Find username field by accessibility identifier let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist") usernameField.tap() usernameField.typeText(username) - // Find password field - it could be TextField (if visible) or SecureField + // Password field may be SecureTextField or regular TextField var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField] if !passwordField.exists { passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField] @@ -78,42 +68,37 @@ struct UITestHelpers { passwordField.tap() passwordField.typeText(password) - // Find and tap login button let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton] XCTAssertTrue(loginButton.waitForExistence(timeout: 3), "Login button should exist") loginButton.tap() - // Wait for login to complete - sleep(3) + // Wait for login to complete by checking for tab bar appearance + let tabBar = app.tabBars.firstMatch + _ = tabBar.waitForExistence(timeout: 15) } - /// Ensures the user is logged out before running a test - /// - Parameter app: The XCUIApplication instance + /// Ensures the user is logged out before running a test. static func ensureLoggedOut(app: XCUIApplication) { - sleep(2) logout(app: app) } - /// Ensures the user is logged in with test credentials before running a test - /// - Parameter app: The XCUIApplication instance - /// - Parameter username: Optional username (defaults to "testuser") - /// - Parameter password: Optional password (defaults to "TestPass123!") - static func ensureLoggedIn(app: XCUIApplication, username: String = "testuser", password: String = "TestPass123!") { - sleep(2) - + /// Ensures the user is logged in with test credentials before running a test. + static func ensureLoggedIn( + app: XCUIApplication, + username: String = "testuser", + password: String = "TestPass123!" + ) { // Check if already logged in (tab bar visible) let tabBar = app.tabBars.firstMatch - if tabBar.exists { - return // Already logged in + if tabBar.waitForExistence(timeout: 5) { + return } // Check if on login screen let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] if usernameField.waitForExistence(timeout: 5) { login(app: app, username: username, password: password) - - // Wait for main screen to appear - _ = tabBar.waitForExistence(timeout: 10) + _ = tabBar.waitForExistence(timeout: 15) } } } diff --git a/iosApp/iosApp/Analytics/AnalyticsEvent.swift b/iosApp/iosApp/Analytics/AnalyticsEvent.swift index fe44e8b..709b434 100644 --- a/iosApp/iosApp/Analytics/AnalyticsEvent.swift +++ b/iosApp/iosApp/Analytics/AnalyticsEvent.swift @@ -5,6 +5,7 @@ enum AnalyticsEvent { // MARK: - Authentication case userSignedIn(method: String) case userSignedInApple(isNewUser: Bool) + case userSignedInGoogle(isNewUser: Bool) case userRegistered(method: String) // MARK: - Residence @@ -43,6 +44,8 @@ enum AnalyticsEvent { return ("user_signed_in", ["method": method]) case .userSignedInApple(let isNewUser): return ("user_signed_in_apple", ["is_new_user": isNewUser]) + case .userSignedInGoogle(let isNewUser): + return ("user_signed_in_google", ["is_new_user": isNewUser]) case .userRegistered(let method): return ("user_registered", ["method": method]) diff --git a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift index b0647ff..1aa4da3 100644 --- a/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift +++ b/iosApp/iosApp/Documents/DocumentViewModelWrapper.swift @@ -1,6 +1,7 @@ import Foundation import ComposeApp import SwiftUI +import Combine // MARK: - Architecture Note // @@ -17,7 +18,7 @@ import SwiftUI // - Used by detail views (DocumentDetailView, EditDocumentView) // - Manages explicit state types (Loading/Success/Error) for single-document operations // - Loads individual document detail, handles update and delete with state feedback -// - Does NOT observe DataManager -- loads fresh data per-request via APILayer +// - Observes DataManager for automatic detail updates after mutations // - Uses protocol-based state enums for SwiftUI view branching // // Both call through APILayer (which updates DataManager), so list views @@ -70,6 +71,7 @@ struct DeleteImageStateError: DeleteImageState { let message: String } +@MainActor class DocumentViewModelWrapper: ObservableObject { @Published var documentsState: DocumentState = DocumentStateIdle() @Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle() @@ -77,6 +79,25 @@ class DocumentViewModelWrapper: ObservableObject { @Published var deleteState: DeleteState = DeleteStateIdle() @Published var deleteImageState: DeleteImageState = DeleteImageStateIdle() + /// The document ID currently loaded in detail view, used for auto-update + private var loadedDocumentId: Int32? + private var cancellables = Set() + + init() { + // Observe DataManager documents for auto-update of loaded detail + DataManagerObservable.shared.$documents + .receive(on: DispatchQueue.main) + .sink { [weak self] documents in + guard let self, let docId = self.loadedDocumentId else { return } + // Only auto-update if we're in a success state + guard self.documentDetailState is DocumentDetailStateSuccess else { return } + if let updated = documents.first(where: { $0.id?.int32Value == docId }) { + self.documentDetailState = DocumentDetailStateSuccess(document: updated) + } + } + .store(in: &cancellables) + } + func loadDocuments( residenceId: Int32? = nil, documentType: String? = nil, @@ -87,9 +108,7 @@ class DocumentViewModelWrapper: ObservableObject { tags: String? = nil, search: String? = nil ) { - DispatchQueue.main.async { - self.documentsState = DocumentStateLoading() - } + self.documentsState = DocumentStateLoading() Task { do { @@ -105,7 +124,7 @@ class DocumentViewModelWrapper: ObservableObject { forceRefresh: false ) - await MainActor.run { + do { if let success = result as? ApiResultSuccess { let documents = success.data as? [Document] ?? [] self.documentsState = DocumentStateSuccess(documents: documents) @@ -116,7 +135,7 @@ class DocumentViewModelWrapper: ObservableObject { } } } catch { - await MainActor.run { + do { self.documentsState = DocumentStateError(message: error.localizedDescription) } } @@ -124,15 +143,14 @@ class DocumentViewModelWrapper: ObservableObject { } func loadDocumentDetail(id: Int32) { - DispatchQueue.main.async { - self.documentDetailState = DocumentDetailStateLoading() - } + loadedDocumentId = id + self.documentDetailState = DocumentDetailStateLoading() Task { do { let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false) - await MainActor.run { + do { if let success = result as? ApiResultSuccess, let document = success.data { self.documentDetailState = DocumentDetailStateSuccess(document: document) } else if let error = ApiResultBridge.error(from: result) { @@ -142,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject { } } } catch { - await MainActor.run { + do { self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription) } } @@ -170,9 +188,7 @@ class DocumentViewModelWrapper: ObservableObject { startDate: String? = nil, endDate: String? = nil ) { - DispatchQueue.main.async { - self.updateState = UpdateStateLoading() - } + self.updateState = UpdateStateLoading() Task { do { @@ -199,7 +215,7 @@ class DocumentViewModelWrapper: ObservableObject { endDate: endDate ) - await MainActor.run { + do { if let success = result as? ApiResultSuccess, let document = success.data { self.updateState = UpdateStateSuccess(document: document) // Also refresh the detail state @@ -211,7 +227,7 @@ class DocumentViewModelWrapper: ObservableObject { } } } catch { - await MainActor.run { + do { self.updateState = UpdateStateError(message: error.localizedDescription) } } @@ -219,15 +235,13 @@ class DocumentViewModelWrapper: ObservableObject { } func deleteDocument(id: Int32) { - DispatchQueue.main.async { - self.deleteState = DeleteStateLoading() - } + self.deleteState = DeleteStateLoading() Task { do { let result = try await APILayer.shared.deleteDocument(id: id) - await MainActor.run { + do { if result is ApiResultSuccess { self.deleteState = DeleteStateSuccess() } else if let error = ApiResultBridge.error(from: result) { @@ -237,7 +251,7 @@ class DocumentViewModelWrapper: ObservableObject { } } } catch { - await MainActor.run { + do { self.deleteState = DeleteStateError(message: error.localizedDescription) } } @@ -245,27 +259,21 @@ class DocumentViewModelWrapper: ObservableObject { } func resetUpdateState() { - DispatchQueue.main.async { - self.updateState = UpdateStateIdle() - } + self.updateState = UpdateStateIdle() } func resetDeleteState() { - DispatchQueue.main.async { - self.deleteState = DeleteStateIdle() - } + self.deleteState = DeleteStateIdle() } func deleteDocumentImage(documentId: Int32, imageId: Int32) { - DispatchQueue.main.async { - self.deleteImageState = DeleteImageStateLoading() - } + self.deleteImageState = DeleteImageStateLoading() Task { do { let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId) - await MainActor.run { + do { if let success = result as? ApiResultSuccess, let document = success.data { self.deleteImageState = DeleteImageStateSuccess() // Refresh detail state with updated document (image removed) @@ -277,7 +285,7 @@ class DocumentViewModelWrapper: ObservableObject { } } } catch { - await MainActor.run { + do { self.deleteImageState = DeleteImageStateError(message: error.localizedDescription) } } @@ -285,8 +293,6 @@ class DocumentViewModelWrapper: ObservableObject { } func resetDeleteImageState() { - DispatchQueue.main.async { - self.deleteImageState = DeleteImageStateIdle() - } + self.deleteImageState = DeleteImageStateIdle() } } diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index dfc49e1..a076075 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -177,6 +177,7 @@ struct DocumentsWarrantiesView: View { }) { OrganicDocToolbarButton() } + .accessibilityIdentifier(AccessibilityIdentifiers.Document.addButton) } } } diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift index bceff21..524a217 100644 --- a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -13,6 +13,7 @@ struct AccessibilityIdentifiers { static let forgotPasswordButton = "Login.ForgotPasswordButton" static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle" static let appleSignInButton = "Login.AppleSignInButton" + static let googleSignInButton = "Login.GoogleSignInButton" // Registration static let registerUsernameField = "Register.UsernameField" diff --git a/iosApp/iosApp/Login/GoogleSignInManager.swift b/iosApp/iosApp/Login/GoogleSignInManager.swift new file mode 100644 index 0000000..5837d65 --- /dev/null +++ b/iosApp/iosApp/Login/GoogleSignInManager.swift @@ -0,0 +1,171 @@ +import Foundation +import AuthenticationServices +import ComposeApp + +/// Handles Google OAuth flow using ASWebAuthenticationSession. +/// Obtains a Google ID token, then sends it to the backend via APILayer. +@MainActor +final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthenticationPresentationContextProviding { + static let shared = GoogleSignInManager() + + @Published var isLoading = false + @Published var errorMessage: String? + + /// Called on successful sign-in with (isVerified: Bool) + var onSignInSuccess: ((Bool) -> Void)? + + private var webAuthSession: ASWebAuthenticationSession? + + // MARK: - Public + + func signIn() { + guard !isLoading else { return } + + let clientId = ApiConfig.shared.GOOGLE_WEB_CLIENT_ID + guard ApiConfig.shared.isGoogleSignInConfigured else { + errorMessage = "Google Sign-In is not configured. A Google Cloud client ID is required." + return + } + + isLoading = true + errorMessage = nil + + // Build Google OAuth URL + let redirectScheme = "com.tt.casera" + let redirectURI = "\(redirectScheme):/oauth2callback" + var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: "openid email profile"), + URLQueryItem(name: "access_type", value: "offline"), + URLQueryItem(name: "prompt", value: "select_account"), + ] + + guard let authURL = components.url else { + isLoading = false + errorMessage = "Failed to build authentication URL" + return + } + + let session = ASWebAuthenticationSession( + url: authURL, + callbackURLScheme: redirectScheme + ) { [weak self] callbackURL, error in + Task { @MainActor in + guard let self else { return } + + if let error { + self.isLoading = false + // Don't show error for user cancellation + if (error as NSError).code != ASWebAuthenticationSessionError.canceledLogin.rawValue { + self.errorMessage = "Sign in failed: \(error.localizedDescription)" + } + return + } + + guard let callbackURL, + let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { + self.isLoading = false + self.errorMessage = "Failed to get authorization code from Google" + return + } + + await self.exchangeCodeForToken(code: code, redirectURI: redirectURI, clientId: clientId) + } + } + + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + webAuthSession = session + session.start() + } + + // MARK: - ASWebAuthenticationPresentationContextProviding + + nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + // Return the key window for presentation + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene + return windowScene?.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor() + } + + // MARK: - Private + + /// Exchange authorization code for ID token via Google's token endpoint + private func exchangeCodeForToken(code: String, redirectURI: String, clientId: String) async { + let tokenURL = URL(string: "https://oauth2.googleapis.com/token")! + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let body = [ + "code": code, + "client_id": clientId, + "redirect_uri": redirectURI, + "grant_type": "authorization_code", + ] + request.httpBody = body + .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" } + .joined(separator: "&") + .data(using: .utf8) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + isLoading = false + errorMessage = "Failed to exchange authorization code" + return + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let idToken = json["id_token"] as? String else { + isLoading = false + errorMessage = "Failed to get ID token from Google" + return + } + + // Send ID token to backend + await sendToBackend(idToken: idToken) + } catch { + isLoading = false + errorMessage = "Network error: \(error.localizedDescription)" + } + } + + /// Send Google ID token to backend for verification and authentication + private func sendToBackend(idToken: String) async { + let request = GoogleSignInRequest(idToken: idToken) + let result = try? await APILayer.shared.googleSignIn(request: request) + + guard let result else { + isLoading = false + errorMessage = "Sign in failed. Please try again." + return + } + + if let success = result as? ApiResultSuccess, let response = success.data { + isLoading = false + + // Share token and API URL with widget extension + WidgetDataManager.shared.saveAuthToken(response.token) + WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl()) + + // Track Google Sign In + AnalyticsManager.shared.track(.userSignedInGoogle(isNewUser: response.isNewUser)) + + // Call success callback with verification status + onSignInSuccess?(response.user.verified) + + } else if let error = ApiResultBridge.error(from: result) { + isLoading = false + errorMessage = ErrorMessageParser.parse(error.message) + } else { + isLoading = false + errorMessage = "Sign in failed. Please try again." + } + } +} diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index 0f56119..19a2a6b 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -10,7 +10,7 @@ struct LoginView: View { @State private var showPasswordReset = false @State private var isPasswordVisible = false @State private var activeResetToken: String? - @State private var showGoogleSignInAlert = false + @StateObject private var googleSignInManager = GoogleSignInManager.shared @Binding var resetToken: String? var onLoginSuccess: (() -> Void)? @@ -194,9 +194,8 @@ struct LoginView: View { } // Google Sign-In Button - // TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package) Button(action: { - showGoogleSignInAlert = true + googleSignInManager.signIn() }) { HStack(spacing: 10) { Image(systemName: "globe") @@ -215,6 +214,7 @@ struct LoginView: View { .stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1) ) } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton) // Apple Sign In Error if let appleError = appleSignInViewModel.errorMessage { @@ -285,6 +285,15 @@ struct LoginView: View { // since AuthenticationManager.isVerified is now false } } + + // Set up callback for Google Sign In success + googleSignInManager.onSignInSuccess = { [self] isVerified in + AuthenticationManager.shared.login(verified: isVerified) + + if isVerified { + self.onLoginSuccess?() + } + } } .fullScreenCover(isPresented: $showVerification) { VerifyEmailView( @@ -327,10 +336,13 @@ struct LoginView: View { activeResetToken = nil } } - .alert("Google Sign-In", isPresented: $showGoogleSignInAlert) { + .alert("Google Sign-In Error", isPresented: .init( + get: { googleSignInManager.errorMessage != nil }, + set: { if !$0 { googleSignInManager.errorMessage = nil } } + )) { Button("OK", role: .cancel) { } } message: { - Text("Google Sign-In coming soon. This feature is under development.") + Text(googleSignInManager.errorMessage ?? "An error occurred.") } } } diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 93b16cd..566b936 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -11,7 +11,7 @@ struct OnboardingCreateAccountContent: View { @State private var showingLoginSheet = false @State private var isExpanded = false @State private var isAnimating = false - @State private var showGoogleSignInAlert = false + @StateObject private var googleSignInManager = GoogleSignInManager.shared @FocusState private var focusedField: Field? @Environment(\.colorScheme) var colorScheme @@ -142,9 +142,8 @@ struct OnboardingCreateAccountContent: View { } // Google Sign-In Button - // TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package) Button(action: { - showGoogleSignInAlert = true + googleSignInManager.signIn() }) { HStack(spacing: 10) { Image(systemName: "globe") @@ -323,10 +322,13 @@ struct OnboardingCreateAccountContent: View { onAccountCreated(true) }) } - .alert("Google Sign-In", isPresented: $showGoogleSignInAlert) { + .alert("Google Sign-In Error", isPresented: .init( + get: { googleSignInManager.errorMessage != nil }, + set: { if !$0 { googleSignInManager.errorMessage = nil } } + )) { Button("OK", role: .cancel) { } } message: { - Text("Google Sign-In coming soon. This feature is under development.") + Text(googleSignInManager.errorMessage ?? "An error occurred.") } .onChange(of: viewModel.isRegistered) { _, isRegistered in if isRegistered { @@ -339,7 +341,11 @@ struct OnboardingCreateAccountContent: View { // Set up Apple Sign In callback appleSignInViewModel.onSignInSuccess = { isVerified in AuthenticationManager.shared.login(verified: isVerified) - // Residence creation is handled by the coordinator + onAccountCreated(isVerified) + } + // Set up Google Sign In callback + googleSignInManager.onSignInSuccess = { isVerified in + AuthenticationManager.shared.login(verified: isVerified) onAccountCreated(isVerified) } } diff --git a/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift b/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift new file mode 100644 index 0000000..edadb5c --- /dev/null +++ b/iosApp/iosApp/Shared/Utilities/KeychainHelper.swift @@ -0,0 +1,64 @@ +import Foundation +import Security +import ComposeApp + +/// Implements KeychainDelegate (Kotlin interface) to provide secure token storage +/// via the iOS Keychain. Injected into TokenManager.Companion before DataManager init. +final class KeychainHelper: NSObject, KeychainDelegate { + static let shared = KeychainHelper() + + private let service = "com.tt.casera" + + func save(key: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { return false } + + // Delete existing item first (SecItemUpdate is fiddly; delete+add is reliable) + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + return status == errSecSuccess + } + + func get(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + func delete(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index ef56bf4..41ed1b6 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -287,7 +287,7 @@ class StoreKitManager: ObservableObject { } else if let successResult = result as? ApiResultSuccess, let response = successResult.data, !response.success { - print("❌ StoreKit: Backend verification failed: \(response.error ?? "Unknown error")") + print("❌ StoreKit: Backend verification failed: \(response.message)") } } catch { print("❌ StoreKit: Backend verification error: \(error)") diff --git a/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift b/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift index 9eee62f..90b50e5 100644 --- a/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift +++ b/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift @@ -76,5 +76,6 @@ struct DynamicTaskColumnView: View { } } } + .accessibilityIdentifier("Task.Column.\(column.name)") } } diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 6f436b5..faa0174 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -21,6 +21,10 @@ struct iOSApp: App { } init() { + // Set up Keychain delegate BEFORE DataManager initialization + // so token reads/writes use Keychain instead of NSUserDefaults + TokenManager.Companion.shared.keychainDelegate = KeychainHelper.shared + // Initialize DataManager with platform-specific managers // This must be done before any other operations that access DataManager DataManager.shared.initialize(