Complete re-validation remediation: KMP architecture, iOS platform, XCUITest rewrite
Phases 1-6 of fixes.md — closes all 13 issues from codex_issues_2.md re-validation: KMP Architecture: - Fix subscription purchase/restore response contract (VerificationResponse aligned) - Add feature benefits auth token + APILayer init flow - Remove ResidenceFormScreen direct API bypass (use APILayer) - Wire paywall purchase/restore to real SubscriptionApi calls iOS Platform: - Add iOS Keychain token storage via Swift KeychainHelper - Implement Google Sign-In via ASWebAuthenticationSession (GoogleSignInManager) - DocumentViewModelWrapper observes DataManager for auto-updates - Add missing accessibility identifiers (document, task columns, Google Sign-In) XCUITest Rewrite: - Rewrite test infrastructure: zero sleep() calls, accessibility ID lookups - Create AuthCriticalPathTests and NavigationCriticalPathTests - Delete 14 legacy brittle test files (Suite0-10, templates) - Fix CaseraTests module import (@testable import Casera) All platforms build clean. TEST BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -44,7 +44,6 @@ import com.example.casera.ui.screens.MainScreen
|
|||||||
import com.example.casera.ui.screens.ManageUsersScreen
|
import com.example.casera.ui.screens.ManageUsersScreen
|
||||||
import com.example.casera.ui.screens.NotificationPreferencesScreen
|
import com.example.casera.ui.screens.NotificationPreferencesScreen
|
||||||
import com.example.casera.ui.screens.ProfileScreen
|
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.MyCribTheme
|
||||||
import com.example.casera.ui.theme.ThemeManager
|
import com.example.casera.ui.theme.ThemeManager
|
||||||
import com.example.casera.navigation.*
|
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.data.DataManager
|
||||||
import com.example.casera.network.APILayer
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.platform.ContractorImportHandler
|
import com.example.casera.platform.ContractorImportHandler
|
||||||
|
import com.example.casera.platform.PlatformUpgradeScreen
|
||||||
import com.example.casera.platform.ResidenceImportHandler
|
import com.example.casera.platform.ResidenceImportHandler
|
||||||
|
|
||||||
import casera.composeapp.generated.resources.Res
|
import casera.composeapp.generated.resources.Res
|
||||||
@@ -613,16 +613,12 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable<UpgradeRoute> {
|
composable<UpgradeRoute> {
|
||||||
UpgradeScreen(
|
PlatformUpgradeScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
onPurchase = { planId ->
|
onSubscriptionChanged = {
|
||||||
// Handle purchase - integrate with billing system
|
// Subscription state updated via DataManager
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onRestorePurchases = {
|
|
||||||
// Handle restore - integrate with billing system
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,9 +63,33 @@ data class PurchaseVerificationRequest(
|
|||||||
@SerialName("product_id") val productId: String
|
@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
|
@Serializable
|
||||||
data class VerificationResponse(
|
data class VerificationResponse(
|
||||||
val success: Boolean,
|
val message: String = "",
|
||||||
val tier: String? = null,
|
val subscription: VerificationSubscriptionInfo? = null
|
||||||
val error: String? = 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ object APILayer {
|
|||||||
println("❌ Failed to fetch subscription status: ${subscriptionStatusResult.message}")
|
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
|
// Load contractors if cache is empty or stale
|
||||||
if (!DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
|
if (!DataManager.isCacheValid(DataManager.contractorsCacheTime)) {
|
||||||
println("🔄 Fetching contractors...")
|
println("🔄 Fetching contractors...")
|
||||||
@@ -1391,6 +1401,18 @@ object APILayer {
|
|||||||
return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId)
|
return subscriptionApi.verifyIOSReceipt(token, receiptData, transactionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch feature benefits from backend (requires auth).
|
||||||
|
*/
|
||||||
|
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
|
||||||
|
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 ====================
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ class SubscriptionApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getFeatureBenefits(): ApiResult<List<FeatureBenefit>> {
|
suspend fun getFeatureBenefits(token: String): ApiResult<List<FeatureBenefit>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/subscription/features/")
|
val response = client.get("$baseUrl/subscription/features/") {
|
||||||
|
header("Authorization", "Token $token")
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
ApiResult.Success(response.body())
|
ApiResult.Success(response.body())
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -15,8 +15,8 @@ import com.example.casera.navigation.*
|
|||||||
import com.example.casera.repository.LookupsRepository
|
import com.example.casera.repository.LookupsRepository
|
||||||
import com.example.casera.models.Residence
|
import com.example.casera.models.Residence
|
||||||
import com.example.casera.models.TaskDetail
|
import com.example.casera.models.TaskDetail
|
||||||
|
import com.example.casera.platform.PlatformUpgradeScreen
|
||||||
import com.example.casera.storage.TokenStorage
|
import com.example.casera.storage.TokenStorage
|
||||||
import com.example.casera.ui.subscription.UpgradeScreen
|
|
||||||
import com.example.casera.ui.theme.*
|
import com.example.casera.ui.theme.*
|
||||||
import casera.composeapp.generated.resources.*
|
import casera.composeapp.generated.resources.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -315,16 +315,12 @@ fun MainScreen(
|
|||||||
|
|
||||||
composable<UpgradeRoute> {
|
composable<UpgradeRoute> {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
UpgradeScreen(
|
PlatformUpgradeScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
onPurchase = { planId ->
|
onSubscriptionChanged = {
|
||||||
// Handle purchase - integrate with billing system
|
// Subscription state updated via DataManager
|
||||||
navController.popBackStack()
|
|
||||||
},
|
|
||||||
onRestorePurchases = {
|
|
||||||
// Handle restore - integrate with billing system
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import com.example.casera.models.ResidenceCreateRequest
|
|||||||
import com.example.casera.models.ResidenceType
|
import com.example.casera.models.ResidenceType
|
||||||
import com.example.casera.models.ResidenceUser
|
import com.example.casera.models.ResidenceUser
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.ResidenceApi
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.storage.TokenStorage
|
|
||||||
import com.example.casera.analytics.PostHogAnalytics
|
import com.example.casera.analytics.PostHogAnalytics
|
||||||
import com.example.casera.analytics.AnalyticsEvents
|
import com.example.casera.analytics.AnalyticsEvents
|
||||||
import com.example.casera.ui.theme.*
|
import com.example.casera.ui.theme.*
|
||||||
@@ -78,16 +77,13 @@ fun ResidenceFormScreen(
|
|||||||
var userToRemove by remember { mutableStateOf<ResidenceUser?>(null) }
|
var userToRemove by remember { mutableStateOf<ResidenceUser?>(null) }
|
||||||
var showRemoveUserConfirmation by remember { mutableStateOf(false) }
|
var showRemoveUserConfirmation by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val residenceApi = remember { ResidenceApi() }
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Load users when in edit mode and user is owner
|
// Load users when in edit mode and user is owner
|
||||||
LaunchedEffect(isEditMode, isCurrentUserOwner, existingResidence?.id) {
|
LaunchedEffect(isEditMode, isCurrentUserOwner, existingResidence?.id) {
|
||||||
if (isEditMode && isCurrentUserOwner && existingResidence != null) {
|
if (isEditMode && isCurrentUserOwner && existingResidence != null) {
|
||||||
isLoadingUsers = true
|
isLoadingUsers = true
|
||||||
val token = TokenStorage.getToken()
|
when (val result = APILayer.getResidenceUsers(existingResidence.id)) {
|
||||||
if (token != null) {
|
|
||||||
when (val result = residenceApi.getResidenceUsers(token, existingResidence.id)) {
|
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
// Filter out the owner from the list
|
// Filter out the owner from the list
|
||||||
users = result.data.filter { it.id != existingResidence.ownerId }
|
users = result.data.filter { it.id != existingResidence.ownerId }
|
||||||
@@ -97,7 +93,6 @@ fun ResidenceFormScreen(
|
|||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
isLoadingUsers = false
|
isLoadingUsers = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,9 +455,8 @@ fun ResidenceFormScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
userToRemove?.let { user ->
|
userToRemove?.let { user ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val token = TokenStorage.getToken()
|
if (existingResidence != null) {
|
||||||
if (token != null && existingResidence != null) {
|
when (APILayer.removeUser(existingResidence.id, user.id)) {
|
||||||
when (residenceApi.removeUser(token, existingResidence.id, user.id)) {
|
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
users = users.filter { it.id != user.id }
|
users = users.filter { it.id != user.id }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,33 +3,71 @@ package com.example.casera.storage
|
|||||||
import platform.Foundation.NSUserDefaults
|
import platform.Foundation.NSUserDefaults
|
||||||
import kotlin.concurrent.Volatile
|
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.
|
* iOS implementation of TokenManager.
|
||||||
*
|
*
|
||||||
* SECURITY NOTE: Currently uses NSUserDefaults for token storage.
|
* Uses iOS Keychain via [KeychainDelegate] for secure token storage.
|
||||||
* For production hardening, migrate to iOS Keychain via a Swift helper
|
* Falls back to NSUserDefaults if delegate is not set (should not happen
|
||||||
* exposed to KMP through an expect/actual boundary or SKIE bridge.
|
* in production — delegate is set in iOSApp.init before DataManager init).
|
||||||
* NSUserDefaults is not encrypted and should not store long-lived auth tokens
|
|
||||||
* in apps handling sensitive data.
|
|
||||||
*
|
*
|
||||||
* Migration plan:
|
* On first read, migrates any existing NSUserDefaults token to Keychain.
|
||||||
* 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"
|
|
||||||
*/
|
*/
|
||||||
actual class TokenManager {
|
actual class TokenManager {
|
||||||
private val prefs = NSUserDefaults.standardUserDefaults
|
private val prefs = NSUserDefaults.standardUserDefaults
|
||||||
|
|
||||||
actual fun saveToken(token: String) {
|
actual fun saveToken(token: String) {
|
||||||
|
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.setObject(token, forKey = TOKEN_KEY)
|
||||||
prefs.synchronize()
|
prefs.synchronize()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actual fun getToken(): String? {
|
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)
|
return prefs.stringForKey(TOKEN_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun clearToken() {
|
actual fun clearToken() {
|
||||||
|
keychainDelegate?.delete(TOKEN_KEY)
|
||||||
prefs.removeObjectForKey(TOKEN_KEY)
|
prefs.removeObjectForKey(TOKEN_KEY)
|
||||||
prefs.synchronize()
|
prefs.synchronize()
|
||||||
}
|
}
|
||||||
@@ -37,6 +75,12 @@ actual class TokenManager {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TOKEN_KEY = "auth_token"
|
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
|
@Volatile
|
||||||
private var instance: TokenManager? = null
|
private var instance: TokenManager? = null
|
||||||
|
|
||||||
|
|||||||
@@ -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 = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@
|
|||||||
// Unit tests for WidgetDataManager.TaskMetrics and task categorization logic.
|
// Unit tests for WidgetDataManager.TaskMetrics and task categorization logic.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import iosApp
|
@testable import Casera
|
||||||
|
|
||||||
// MARK: - Column Name Constants Tests
|
// MARK: - Column Name Constants Tests
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct AccessibilityIdentifiers {
|
|||||||
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
||||||
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
||||||
static let appleSignInButton = "Login.AppleSignInButton"
|
static let appleSignInButton = "Login.AppleSignInButton"
|
||||||
|
static let googleSignInButton = "Login.GoogleSignInButton"
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
static let registerUsernameField = "Register.UsernameField"
|
static let registerUsernameField = "Register.UsernameField"
|
||||||
@@ -35,6 +36,7 @@ struct AccessibilityIdentifiers {
|
|||||||
static let contractorsTab = "TabBar.Contractors"
|
static let contractorsTab = "TabBar.Contractors"
|
||||||
static let documentsTab = "TabBar.Documents"
|
static let documentsTab = "TabBar.Documents"
|
||||||
static let profileTab = "TabBar.Profile"
|
static let profileTab = "TabBar.Profile"
|
||||||
|
static let settingsButton = "Navigation.SettingsButton"
|
||||||
static let backButton = "Navigation.BackButton"
|
static let backButton = "Navigation.BackButton"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
111
iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift
Normal file
111
iosApp/CaseraUITests/CriticalPath/AuthCriticalPathTests.swift
Normal file
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import XCTest
|
|||||||
/// Tests that the app launches successfully, the auth screen renders correctly,
|
/// Tests that the app launches successfully, the auth screen renders correctly,
|
||||||
/// and core navigation is functional. These are the minimum-viability tests
|
/// and core navigation is functional. These are the minimum-viability tests
|
||||||
/// that must pass before any PR can merge.
|
/// that must pass before any PR can merge.
|
||||||
|
///
|
||||||
|
/// Zero sleep() calls — all waits are condition-based.
|
||||||
final class SmokeTests: XCTestCase {
|
final class SmokeTests: XCTestCase {
|
||||||
var app: XCUIApplication!
|
var app: XCUIApplication!
|
||||||
|
|
||||||
@@ -70,7 +72,6 @@ final class SmokeTests: XCTestCase {
|
|||||||
func testMainTabsExistAfterLogin() {
|
func testMainTabsExistAfterLogin() {
|
||||||
let login = LoginScreen(app: app)
|
let login = LoginScreen(app: app)
|
||||||
if login.emailField.waitForExistence(timeout: 15) {
|
if login.emailField.waitForExistence(timeout: 15) {
|
||||||
// Need to login first
|
|
||||||
let user = TestFixtures.TestUser.existing
|
let user = TestFixtures.TestUser.existing
|
||||||
login.login(email: user.email, password: user.password)
|
login.login(email: user.email, password: user.password)
|
||||||
}
|
}
|
||||||
@@ -81,11 +82,11 @@ final class SmokeTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App has 4 tabs: Residences, Tasks, Contractors, Documents
|
||||||
XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist")
|
XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist")
|
||||||
XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist")
|
XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist")
|
||||||
XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist")
|
XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist")
|
||||||
XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist")
|
XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist")
|
||||||
XCTAssertTrue(main.profileTab.exists, "Profile tab should exist")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTabNavigation() {
|
func testTabNavigation() {
|
||||||
@@ -111,9 +112,6 @@ final class SmokeTests: XCTestCase {
|
|||||||
main.goToDocuments()
|
main.goToDocuments()
|
||||||
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
|
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
|
||||||
|
|
||||||
main.goToProfile()
|
|
||||||
XCTAssertTrue(main.profileTab.isSelected, "Profile tab should be selected")
|
|
||||||
|
|
||||||
main.goToResidences()
|
main.goToResidences()
|
||||||
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
|
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,6 +45,30 @@ class BaseScreen {
|
|||||||
return element
|
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
|
// MARK: - State Assertions
|
||||||
|
|
||||||
/// Asserts that an element with the given accessibility identifier exists.
|
/// Asserts that an element with the given accessibility identifier exists.
|
||||||
|
|||||||
@@ -2,30 +2,32 @@ import XCTest
|
|||||||
|
|
||||||
/// Page object for the main tab view that appears after login.
|
/// Page object for the main tab view that appears after login.
|
||||||
///
|
///
|
||||||
/// Provides navigation to each tab (Residences, Tasks, Contractors, Documents, Profile)
|
/// The app has 4 tabs: Residences, Tasks, Contractors, Documents.
|
||||||
/// and a logout flow. Uses predicate-based element lookup to match the existing test patterns.
|
/// Profile is accessed via the settings button on the Residences screen.
|
||||||
|
/// Uses accessibility identifiers for reliable element lookup.
|
||||||
class MainTabScreen: BaseScreen {
|
class MainTabScreen: BaseScreen {
|
||||||
|
|
||||||
// MARK: - Tab Elements
|
// MARK: - Tab Elements
|
||||||
|
|
||||||
var residencesTab: XCUIElement {
|
var residencesTab: XCUIElement {
|
||||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
|
||||||
}
|
}
|
||||||
|
|
||||||
var tasksTab: XCUIElement {
|
var tasksTab: XCUIElement {
|
||||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.tasksTab]
|
||||||
}
|
}
|
||||||
|
|
||||||
var contractorsTab: XCUIElement {
|
var contractorsTab: XCUIElement {
|
||||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.contractorsTab]
|
||||||
}
|
}
|
||||||
|
|
||||||
var documentsTab: XCUIElement {
|
var documentsTab: XCUIElement {
|
||||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch
|
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.documentsTab]
|
||||||
}
|
}
|
||||||
|
|
||||||
var profileTab: XCUIElement {
|
/// Settings button on the Residences tab (leads to profile/settings).
|
||||||
app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch
|
var settingsButton: XCUIElement {
|
||||||
|
app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||||
}
|
}
|
||||||
|
|
||||||
override var isDisplayed: Bool {
|
override var isDisplayed: Bool {
|
||||||
@@ -58,18 +60,20 @@ class MainTabScreen: BaseScreen {
|
|||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to settings/profile via the settings button on Residences tab.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func goToProfile() -> Self {
|
func goToSettings() -> Self {
|
||||||
waitForElement(profileTab).tap()
|
goToResidences()
|
||||||
|
waitForElement(settingsButton).tap()
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Logout
|
// 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.
|
/// Handles the confirmation alert automatically.
|
||||||
func logout() {
|
func logout() {
|
||||||
goToProfile()
|
goToSettings()
|
||||||
|
|
||||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||||
if logoutButton.waitForExistence(timeout: 5) {
|
if logoutButton.waitForExistence(timeout: 5) {
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ CaseraUITests/
|
|||||||
├── PageObjects/ # Screen abstractions (Page Object pattern)
|
├── PageObjects/ # Screen abstractions (Page Object pattern)
|
||||||
│ ├── BaseScreen.swift # Common wait/assert utilities
|
│ ├── BaseScreen.swift # Common wait/assert utilities
|
||||||
│ ├── LoginScreen.swift # Login screen elements and actions
|
│ ├── LoginScreen.swift # Login screen elements and actions
|
||||||
│ ├── RegisterScreen.swift
|
│ ├── RegisterScreen.swift # Registration screen
|
||||||
│ └── MainTabScreen.swift
|
│ └── MainTabScreen.swift # Main tab navigation + settings + logout
|
||||||
├── TestConfiguration/ # Launch config, environment setup
|
├── TestConfiguration/ # Launch config, environment setup
|
||||||
│ └── TestLaunchConfig.swift
|
│ └── TestLaunchConfig.swift
|
||||||
├── Fixtures/ # Test data builders
|
├── Fixtures/ # Test data builders
|
||||||
│ └── TestFixtures.swift
|
│ └── TestFixtures.swift
|
||||||
├── CriticalPath/ # Must-pass tests for CI gating
|
├── CriticalPath/ # Must-pass tests for CI gating
|
||||||
│ └── SmokeTests.swift # Fast smoke suite (<2 min)
|
│ ├── SmokeTests.swift # Fast smoke suite (<2 min)
|
||||||
├── Suite0-10_*.swift # Existing comprehensive test suites
|
│ ├── AuthCriticalPathTests.swift # Auth flow validation
|
||||||
├── UITestHelpers.swift # Legacy shared helpers
|
│ └── NavigationCriticalPathTests.swift # Tab + navigation validation
|
||||||
├── AccessibilityIdentifiers.swift # UI element IDs
|
├── UITestHelpers.swift # Shared login/logout/navigation helpers
|
||||||
|
├── AccessibilityIdentifiers.swift # UI element IDs (synced with app-side copy)
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -25,10 +26,9 @@ CaseraUITests/
|
|||||||
|
|
||||||
| Suite | Purpose | CI Gate | Target Time |
|
| Suite | Purpose | CI Gate | Target Time |
|
||||||
|-------|---------|---------|-------------|
|
|-------|---------|---------|-------------|
|
||||||
| SmokeTests | App launches, auth, navigation | Every PR | <2 min |
|
| SmokeTests | App launches, basic auth, tab existence | Every PR | <2 min |
|
||||||
| Suite0-2 | Onboarding, registration, auth | Nightly | <5 min |
|
| AuthCriticalPathTests | Login, logout, registration entry, forgot password | Every PR | <3 min |
|
||||||
| Suite3-8 | Feature CRUD (residence, task, etc) | Nightly | <15 min |
|
| NavigationCriticalPathTests | Tab navigation, settings, add buttons | Every PR | <3 min |
|
||||||
| Suite9-10 | E2E integration | Weekly | <30 min |
|
|
||||||
|
|
||||||
## Patterns
|
## 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.
|
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
|
### 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
|
### 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.
|
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.
|
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
|
### 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
|
## CI Configuration
|
||||||
|
|
||||||
### Smoke Suite (every PR)
|
### Critical Path (every PR)
|
||||||
```bash
|
```bash
|
||||||
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
||||||
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \
|
-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)
|
### Full Regression (nightly)
|
||||||
@@ -66,15 +68,16 @@ xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
|||||||
## Flake Reduction
|
## Flake Reduction
|
||||||
|
|
||||||
- Target: <2% flake rate on critical-path suite
|
- 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
|
- Test data uses unique identifiers to prevent cross-test interference
|
||||||
- UI animations disabled via launch arguments
|
- 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
|
## Adding New Tests
|
||||||
|
|
||||||
1. If the screen does not have a page object yet, create one in `PageObjects/` that extends `BaseScreen`.
|
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.
|
2. Define accessibility identifiers in `AccessibilityIdentifiers.swift` for any new UI elements.
|
||||||
3. Add test data builders to `TestFixtures.swift` if needed.
|
3. Sync the app-side copy of `AccessibilityIdentifiers.swift` with matching identifiers.
|
||||||
4. Write the test in the appropriate suite file, or create a new suite if the feature is new.
|
4. Add test data builders to `TestFixtures.swift` if needed.
|
||||||
5. For critical-path tests (must pass on every PR), add to `CriticalPath/SmokeTests.swift`.
|
5. Write the test in `CriticalPath/` for must-pass CI tests.
|
||||||
|
6. Verify zero `sleep()` calls before merging.
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..<navBarButtons.count {
|
|
||||||
let button = navBarButtons.element(boundBy: i)
|
|
||||||
if (button.label == "plus" || button.label.contains("Add")) && button.isEnabled {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Empty state button
|
|
||||||
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
|
|
||||||
if emptyStateButton.exists && emptyStateButton.isEnabled {
|
|
||||||
return emptyStateButton
|
|
||||||
}
|
|
||||||
|
|
||||||
return addButtonById
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test 1: Create Multiple Residences
|
|
||||||
// Phase 2 of TestIntegration_ComprehensiveE2E
|
|
||||||
|
|
||||||
func test01_createMultipleResidences() {
|
|
||||||
let residenceNames = [
|
|
||||||
"E2E Main House \(Self.testRunId)",
|
|
||||||
"E2E Beach House \(Self.testRunId)",
|
|
||||||
"E2E Mountain Cabin \(Self.testRunId)"
|
|
||||||
]
|
|
||||||
|
|
||||||
for (index, name) in residenceNames.enumerated() {
|
|
||||||
let streetAddress = "\(100 * (index + 1)) Test St"
|
|
||||||
let success = createResidence(name: name, streetAddress: streetAddress)
|
|
||||||
XCTAssertTrue(success, "Should create residence: \(name)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all residences exist
|
|
||||||
navigateToTab("Residences")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
for name in residenceNames {
|
|
||||||
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
|
|
||||||
XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "Residence '\(name)' should exist in list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test 2: Create Tasks with Various States
|
|
||||||
// Phase 3 of TestIntegration_ComprehensiveE2E
|
|
||||||
|
|
||||||
func test02_createTasksWithVariousStates() {
|
|
||||||
// Ensure at least one residence exists
|
|
||||||
navigateToTab("Residences")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
let emptyState = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
|
||||||
if emptyState.exists {
|
|
||||||
createResidence(name: "Task Test Residence \(Self.testRunId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tasks with different purposes
|
|
||||||
let tasks = [
|
|
||||||
("E2E Active Task \(Self.testRunId)", "Task that remains active"),
|
|
||||||
("E2E Progress Task \(Self.testRunId)", "Task to mark in-progress"),
|
|
||||||
("E2E Complete Task \(Self.testRunId)", "Task to complete"),
|
|
||||||
("E2E Cancel Task \(Self.testRunId)", "Task to cancel")
|
|
||||||
]
|
|
||||||
|
|
||||||
for (title, description) in tasks {
|
|
||||||
let success = createTask(title: title, description: description)
|
|
||||||
XCTAssertTrue(success, "Should create task: \(title)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all tasks exist
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
for (title, _) in tasks {
|
|
||||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
|
|
||||||
XCTAssertTrue(taskCard.waitForExistence(timeout: 5), "Task '\(title)' should exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test 3: Task State Transitions
|
|
||||||
// Mirrors task operations from TestIntegration_TaskFlow
|
|
||||||
|
|
||||||
func test03_taskStateTransitions() {
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Find a task to transition (create one if needed)
|
|
||||||
let testTaskTitle = "E2E State Test \(Self.testRunId)"
|
|
||||||
|
|
||||||
var taskExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists
|
|
||||||
if !taskExists {
|
|
||||||
// Check if any residence exists first
|
|
||||||
navigateToTab("Residences")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
|
||||||
if emptyResidences.exists {
|
|
||||||
createResidence(name: "State Test Residence \(Self.testRunId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
createTask(title: testTaskTitle, description: "Testing state transitions")
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and tap the task
|
|
||||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
|
||||||
if taskCard.waitForExistence(timeout: 5) {
|
|
||||||
taskCard.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Try to mark in progress
|
|
||||||
let inProgressButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'Start'")).firstMatch
|
|
||||||
if inProgressButton.exists && inProgressButton.isEnabled {
|
|
||||||
inProgressButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to complete
|
|
||||||
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark Complete'")).firstMatch
|
|
||||||
if completeButton.exists && completeButton.isEnabled {
|
|
||||||
completeButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Handle completion form if shown
|
|
||||||
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Submit' OR label CONTAINS[c] 'Save'")).firstMatch
|
|
||||||
if submitButton.waitForExistence(timeout: 2) {
|
|
||||||
submitButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate back
|
|
||||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
|
||||||
if backButton.exists && backButton.isHittable {
|
|
||||||
backButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test 4: Task Cancel Operation
|
|
||||||
|
|
||||||
func test04_taskCancelOperation() {
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
let testTaskTitle = "E2E Cancel Test \(Self.testRunId)"
|
|
||||||
|
|
||||||
// Create task if doesn't exist
|
|
||||||
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
|
|
||||||
navigateToTab("Residences")
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
|
||||||
if emptyResidences.exists {
|
|
||||||
createResidence(name: "Cancel Test Residence \(Self.testRunId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
createTask(title: testTaskTitle, description: "Task to be cancelled")
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and tap task
|
|
||||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
|
||||||
if taskCard.waitForExistence(timeout: 5) {
|
|
||||||
taskCard.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Look for cancel button
|
|
||||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel Task' OR label CONTAINS[c] 'Cancel'")).firstMatch
|
|
||||||
if cancelButton.exists && cancelButton.isEnabled {
|
|
||||||
cancelButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Confirm cancellation if alert shown
|
|
||||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
|
||||||
if confirmButton.exists {
|
|
||||||
confirmButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate back
|
|
||||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
|
||||||
if backButton.exists && backButton.isHittable {
|
|
||||||
backButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test 5: Task Archive Operation
|
|
||||||
|
|
||||||
func test05_taskArchiveOperation() {
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
let testTaskTitle = "E2E Archive Test \(Self.testRunId)"
|
|
||||||
|
|
||||||
// Create task if doesn't exist
|
|
||||||
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
|
|
||||||
navigateToTab("Residences")
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
|
||||||
if emptyResidences.exists {
|
|
||||||
createResidence(name: "Archive Test Residence \(Self.testRunId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
createTask(title: testTaskTitle, description: "Task to be archived")
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find and tap task
|
|
||||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
|
||||||
if taskCard.waitForExistence(timeout: 5) {
|
|
||||||
taskCard.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Look for archive button
|
|
||||||
let archiveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive'")).firstMatch
|
|
||||||
if archiveButton.exists && archiveButton.isEnabled {
|
|
||||||
archiveButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Confirm archive if alert shown
|
|
||||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
|
||||||
if confirmButton.exists {
|
|
||||||
confirmButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate back
|
|
||||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
|
||||||
if backButton.exists && backButton.isHittable {
|
|
||||||
backButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test 6: Verify Kanban Column Structure
|
|
||||||
// Phase 6 of TestIntegration_ComprehensiveE2E
|
|
||||||
|
|
||||||
func test06_verifyKanbanStructure() {
|
|
||||||
navigateToTab("Tasks")
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Expected kanban column names (may vary by implementation)
|
|
||||||
let expectedColumns = [
|
|
||||||
"Overdue",
|
|
||||||
"In Progress",
|
|
||||||
"Due Soon",
|
|
||||||
"Upcoming",
|
|
||||||
"Completed",
|
|
||||||
"Cancelled"
|
|
||||||
]
|
|
||||||
|
|
||||||
var foundColumns: [String] = []
|
|
||||||
|
|
||||||
for column in expectedColumns {
|
|
||||||
let columnHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(column)'")).firstMatch
|
|
||||||
if columnHeader.exists {
|
|
||||||
foundColumns.append(column)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have at least some kanban columns OR be in list view
|
|
||||||
let hasKanbanView = foundColumns.count >= 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("========================")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..<navBarButtons.count {
|
|
||||||
let button = navBarButtons.element(boundBy: i)
|
|
||||||
if button.label == "plus" || button.label.contains("Add") {
|
|
||||||
if button.isEnabled {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return addButtonById
|
|
||||||
}
|
|
||||||
|
|
||||||
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 selectPropertyType(type: String) {
|
|
||||||
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
|
|
||||||
if propertyTypePicker.exists {
|
|
||||||
propertyTypePicker.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Try to find and tap the type option
|
|
||||||
let typeButton = app.buttons[type]
|
|
||||||
if typeButton.exists {
|
|
||||||
typeButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
} else {
|
|
||||||
// Try cells if it's a navigation style picker
|
|
||||||
let cells = app.cells
|
|
||||||
for i in 0..<cells.count {
|
|
||||||
let cell = cells.element(boundBy: i)
|
|
||||||
if cell.staticTexts[type].exists {
|
|
||||||
cell.tap()
|
|
||||||
sleep(1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createResidence(
|
|
||||||
name: String,
|
|
||||||
propertyType: String = "House",
|
|
||||||
street: String = "123 Test St",
|
|
||||||
city: String = "TestCity",
|
|
||||||
state: String = "TS",
|
|
||||||
postal: String = "12345",
|
|
||||||
scrollBeforeAddress: Bool = true
|
|
||||||
) -> 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..<cells.count {
|
|
||||||
let cell = cells.element(boundBy: i)
|
|
||||||
if cell.staticTexts["Condo"].exists {
|
|
||||||
cell.tap()
|
|
||||||
sleep(1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll to address fields
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Update street
|
|
||||||
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
|
||||||
if streetField.exists {
|
|
||||||
streetField.tap()
|
|
||||||
streetField.doubleTap()
|
|
||||||
sleep(1)
|
|
||||||
if app.buttons["Select All"].exists {
|
|
||||||
app.buttons["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
streetField.typeText(newStreet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update city
|
|
||||||
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
|
|
||||||
if cityField.exists {
|
|
||||||
cityField.tap()
|
|
||||||
cityField.doubleTap()
|
|
||||||
sleep(1)
|
|
||||||
if app.buttons["Select All"].exists {
|
|
||||||
app.buttons["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
cityField.typeText(newCity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
|
|
||||||
if stateField.exists {
|
|
||||||
stateField.tap()
|
|
||||||
stateField.doubleTap()
|
|
||||||
sleep(1)
|
|
||||||
if app.buttons["Select All"].exists {
|
|
||||||
app.buttons["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
stateField.typeText(newState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update postal code
|
|
||||||
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
|
|
||||||
if postalField.exists {
|
|
||||||
postalField.tap()
|
|
||||||
postalField.doubleTap()
|
|
||||||
sleep(1)
|
|
||||||
if app.buttons["Select All"].exists {
|
|
||||||
app.buttons["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
postalField.typeText(newPostal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 name
|
|
||||||
createdResidenceNames.append(newName)
|
|
||||||
|
|
||||||
// Verify updated residence appears in list with new name
|
|
||||||
navigateToResidencesTab()
|
|
||||||
sleep(2)
|
|
||||||
let updatedResidence = findResidence(name: newName)
|
|
||||||
XCTAssertTrue(updatedResidence.exists, "Residence should show updated name in list")
|
|
||||||
|
|
||||||
// Tap on residence to verify details were updated
|
|
||||||
updatedResidence.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify updated address appears in detail view
|
|
||||||
let streetText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newStreet)'")).firstMatch
|
|
||||||
XCTAssertTrue(streetText.exists || true, "Updated street should be visible in detail view")
|
|
||||||
|
|
||||||
let cityText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newCity)'")).firstMatch
|
|
||||||
XCTAssertTrue(cityText.exists || true, "Updated city should be visible in detail view")
|
|
||||||
|
|
||||||
let postalText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newPostal)'")).firstMatch
|
|
||||||
XCTAssertTrue(postalText.exists || true, "Updated postal code should be visible in detail view")
|
|
||||||
|
|
||||||
// Verify property type was updated to Condo
|
|
||||||
let condoBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Condo'")).firstMatch
|
|
||||||
XCTAssertTrue(condoBadge.exists || true, "Updated property type should be visible (if shown in detail)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 4. View/Navigation Tests
|
|
||||||
|
|
||||||
func test13_viewResidenceDetails() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let residenceName = "Detail View Test \(timestamp)"
|
|
||||||
|
|
||||||
// Create residence
|
|
||||||
guard createResidence(name: residenceName) else {
|
|
||||||
XCTFail("Failed to create residence")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToResidencesTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Tap on residence
|
|
||||||
let residence = findResidence(name: residenceName)
|
|
||||||
XCTAssertTrue(residence.exists, "Residence should exist")
|
|
||||||
residence.tap()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Verify detail view appears with edit button or tasks section
|
|
||||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
|
||||||
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
|
|
||||||
|
|
||||||
XCTAssertTrue(editButton.exists || tasksSection.exists, "Detail view should show with edit button or tasks section")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test14_navigateFromResidencesToOtherTabs() {
|
|
||||||
// From Residences tab
|
|
||||||
navigateToResidencesTab()
|
|
||||||
|
|
||||||
// Navigate to Tasks
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
|
|
||||||
tasksTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
|
|
||||||
|
|
||||||
// Navigate back to Residences
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
residencesTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences 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 Residences
|
|
||||||
residencesTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab again")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test15_refreshResidencesList() {
|
|
||||||
navigateToResidencesTab()
|
|
||||||
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 residences tab
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
XCTAssertTrue(residencesTab.isSelected, "Should still be on Residences tab after refresh")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 5. Persistence Tests
|
|
||||||
|
|
||||||
func test16_residencePersistsAfterBackgroundingApp() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let residenceName = "Persistence Test \(timestamp)"
|
|
||||||
|
|
||||||
// Create residence
|
|
||||||
guard createResidence(name: residenceName) else {
|
|
||||||
XCTFail("Failed to create residence")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToResidencesTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify residence exists
|
|
||||||
var residence = findResidence(name: residenceName)
|
|
||||||
XCTAssertTrue(residence.exists, "Residence should exist before backgrounding")
|
|
||||||
|
|
||||||
// Background and reactivate app
|
|
||||||
XCUIDevice.shared.press(.home)
|
|
||||||
sleep(2)
|
|
||||||
app.activate()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Navigate back to residences
|
|
||||||
navigateToResidencesTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify residence still exists
|
|
||||||
residence = findResidence(name: residenceName)
|
|
||||||
XCTAssertTrue(residence.exists, "Residence should persist after backgrounding app")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 6. Performance Tests
|
|
||||||
|
|
||||||
func test17_residenceListPerformance() {
|
|
||||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
|
||||||
navigateToResidencesTab()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test18_residenceCreationPerformance() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
|
|
||||||
measure(metrics: [XCTClockMetric()]) {
|
|
||||||
let residenceName = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
|
|
||||||
_ = createResidence(name: residenceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,376 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Task management tests
|
|
||||||
/// Uses UITestHelpers for consistent login/logout behavior
|
|
||||||
/// IMPORTANT: Tasks require at least one residence to exist
|
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
final class Suite5_TaskTests: XCTestCase {
|
|
||||||
var app: XCUIApplication!
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Ensure user is logged in
|
|
||||||
UITestHelpers.ensureLoggedIn(app: app)
|
|
||||||
|
|
||||||
// CRITICAL: Ensure at least one residence exists
|
|
||||||
// Tasks are disabled if no residences exist
|
|
||||||
ensureResidenceExists()
|
|
||||||
|
|
||||||
// Now navigate to Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
app = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
/// Ensures at least one residence exists (required for tasks to work)
|
|
||||||
private func ensureResidenceExists() {
|
|
||||||
// Navigate to Residences tab
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
if residencesTab.waitForExistence(timeout: 5) {
|
|
||||||
residencesTab.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Check if we have any residences
|
|
||||||
// Look for the add button - if we see "Add a property" text or empty state, create one
|
|
||||||
let emptyStateText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties' OR label CONTAINS[c] 'No residences'")).firstMatch
|
|
||||||
|
|
||||||
if emptyStateText.exists {
|
|
||||||
// No residences exist, create a quick one
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
|
||||||
if addButton.waitForExistence(timeout: 5) {
|
|
||||||
addButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Fill minimal required fields
|
|
||||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
|
||||||
if nameField.waitForExistence(timeout: 5) {
|
|
||||||
nameField.tap()
|
|
||||||
nameField.typeText("Test Home for Tasks")
|
|
||||||
|
|
||||||
// Scroll to address fields
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Fill required address fields
|
|
||||||
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
|
||||||
if streetField.exists {
|
|
||||||
streetField.tap()
|
|
||||||
streetField.typeText("123 Test St")
|
|
||||||
}
|
|
||||||
|
|
||||||
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
|
|
||||||
if cityField.exists {
|
|
||||||
cityField.tap()
|
|
||||||
cityField.typeText("TestCity")
|
|
||||||
}
|
|
||||||
|
|
||||||
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
|
|
||||||
if stateField.exists {
|
|
||||||
stateField.tap()
|
|
||||||
stateField.typeText("TS")
|
|
||||||
}
|
|
||||||
|
|
||||||
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
|
|
||||||
if postalField.exists {
|
|
||||||
postalField.tap()
|
|
||||||
postalField.typeText("12345")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save
|
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
|
||||||
if saveButton.exists {
|
|
||||||
saveButton.tap()
|
|
||||||
sleep(3) // Wait for save to complete
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func navigateToTasksTab() {
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
if tasksTab.waitForExistence(timeout: 5) {
|
|
||||||
if !tasksTab.isSelected {
|
|
||||||
tasksTab.tap()
|
|
||||||
sleep(3) // Give it time to load
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds the Add Task button using multiple strategies
|
|
||||||
/// The button exists in two places:
|
|
||||||
/// 1. Toolbar (always visible when residences exist)
|
|
||||||
/// 2. Empty state (visible when no tasks exist)
|
|
||||||
private func findAddTaskButton() -> 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..<navBarButtons.count {
|
|
||||||
let button = navBarButtons.element(boundBy: i)
|
|
||||||
if button.label == "plus" || button.label.contains("Add") {
|
|
||||||
if button.isEnabled {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 3: Try finding "Add Task" button in empty state by text
|
|
||||||
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
|
|
||||||
if emptyStateButton.exists && emptyStateButton.isEnabled {
|
|
||||||
return emptyStateButton
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 4: Look for any enabled button with a plus icon
|
|
||||||
let allButtons = app.buttons
|
|
||||||
for i in 0..<min(allButtons.count, 20) { // Check first 20 buttons
|
|
||||||
let button = allButtons.element(boundBy: i)
|
|
||||||
if button.isEnabled && (button.label.contains("plus") || button.label.contains("Add")) {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the identifier one as fallback (will fail assertion if doesn't exist)
|
|
||||||
return addButtonById
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 1. Error/Validation Tests
|
|
||||||
|
|
||||||
func test01_cancelTaskCreation() {
|
|
||||||
// Given: User is on add task form
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
let addButton = findAddTaskButton()
|
|
||||||
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add task button should exist and be enabled")
|
|
||||||
addButton.tap()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Verify form opened
|
|
||||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
|
||||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task form should open")
|
|
||||||
|
|
||||||
// When: User taps cancel
|
|
||||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
|
||||||
XCTAssertTrue(cancelButton.exists, "Cancel button should exist in task form")
|
|
||||||
cancelButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Then: Should return to tasks list
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list after cancel")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 2. View/List Tests
|
|
||||||
|
|
||||||
func test02_tasksTabExists() {
|
|
||||||
// Given: User is logged in
|
|
||||||
// When: User looks for Tasks tab
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
|
|
||||||
// Then: Tasks tab should exist
|
|
||||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist in main tab bar")
|
|
||||||
XCTAssertTrue(tasksTab.isSelected, "Tasks tab should be selected after navigation")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test03_viewTasksList() {
|
|
||||||
// Given: User is on Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Then: Tasks screen should be visible
|
|
||||||
// Verify we're on the right screen by checking for the navigation title
|
|
||||||
let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'All Tasks' OR label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
XCTAssertTrue(tasksTitle.waitForExistence(timeout: 5), "Tasks screen title should be visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test04_addTaskButtonExists() {
|
|
||||||
// Given: User is on Tasks tab with at least one residence
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Then: Add task button should exist and be enabled
|
|
||||||
let addButton = findAddTaskButton()
|
|
||||||
XCTAssertTrue(addButton.exists, "Add task button should exist on Tasks screen")
|
|
||||||
XCTAssertTrue(addButton.isEnabled, "Add task button should be enabled when residence exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test05_navigateToAddTask() {
|
|
||||||
// Given: User is on Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// When: User taps add task button
|
|
||||||
let addButton = findAddTaskButton()
|
|
||||||
XCTAssertTrue(addButton.exists, "Add task button should exist")
|
|
||||||
XCTAssertTrue(addButton.isEnabled, "Add task button should be enabled")
|
|
||||||
|
|
||||||
addButton.tap()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Then: Should show add task form with required fields
|
|
||||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title' OR placeholderValue CONTAINS[c] 'Task'")).firstMatch
|
|
||||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should appear in add form")
|
|
||||||
|
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
|
||||||
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 3. Creation Tests
|
|
||||||
|
|
||||||
func test06_createBasicTask() {
|
|
||||||
// Given: User is on Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// When: User taps add task button
|
|
||||||
let addButton = findAddTaskButton()
|
|
||||||
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add task button should exist and be enabled")
|
|
||||||
addButton.tap()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Verify task form loaded
|
|
||||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
|
||||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should appear")
|
|
||||||
|
|
||||||
// Fill in task title with unique timestamp
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let taskTitle = "UITest Task \(timestamp)"
|
|
||||||
titleField.tap()
|
|
||||||
titleField.typeText(taskTitle)
|
|
||||||
|
|
||||||
// Scroll down to find and fill description
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
|
|
||||||
if descField.exists {
|
|
||||||
descField.tap()
|
|
||||||
descField.typeText("Test task")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll to find Save button
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// When: User taps 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 tasks list
|
|
||||||
sleep(5) // Wait for API call to complete
|
|
||||||
|
|
||||||
// Verify we're back on tasks list by checking tab exists
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list after saving")
|
|
||||||
|
|
||||||
// Verify task appears in the list (may be in kanban columns)
|
|
||||||
let newTask = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch
|
|
||||||
XCTAssertTrue(newTask.waitForExistence(timeout: 10), "New task '\(taskTitle)' should appear in the list")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 4. View Details Tests
|
|
||||||
|
|
||||||
func test07_viewTaskDetails() {
|
|
||||||
// Given: User is on Tasks tab and at least one task exists
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Look for any task in the list
|
|
||||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'UITest Task' OR label CONTAINS 'Test'")).firstMatch
|
|
||||||
|
|
||||||
if !taskCard.waitForExistence(timeout: 5) {
|
|
||||||
// No task found - skip this test
|
|
||||||
print("No tasks found - run testCreateBasicTask first")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// When: User taps on a task
|
|
||||||
taskCard.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Then: Should show task details screen
|
|
||||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
|
||||||
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark'")).firstMatch
|
|
||||||
let backButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Back' OR label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
|
|
||||||
let detailScreenVisible = editButton.exists || completeButton.exists || backButton.exists
|
|
||||||
XCTAssertTrue(detailScreenVisible, "Task details screen should show with action buttons")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 5. Navigation Tests
|
|
||||||
|
|
||||||
func test08_navigateToContractors() {
|
|
||||||
// Given: User is on Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// When: User taps Contractors tab
|
|
||||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
||||||
XCTAssertTrue(contractorsTab.waitForExistence(timeout: 5), "Contractors tab should exist")
|
|
||||||
contractorsTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Then: Should be on Contractors tab
|
|
||||||
XCTAssertTrue(contractorsTab.isSelected, "Contractors tab should be selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test09_navigateToDocuments() {
|
|
||||||
// Given: User is on Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// When: User taps Documents tab
|
|
||||||
let documentsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch
|
|
||||||
XCTAssertTrue(documentsTab.waitForExistence(timeout: 5), "Documents tab should exist")
|
|
||||||
documentsTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Then: Should be on Documents tab
|
|
||||||
XCTAssertTrue(documentsTab.isSelected, "Documents tab should be selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test10_navigateBetweenTabs() {
|
|
||||||
// Given: User is on Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// When: User navigates to Residences tab
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
|
||||||
residencesTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Then: Should be on Residences tab
|
|
||||||
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
|
|
||||||
|
|
||||||
// When: User navigates back to Tasks
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
tasksTab.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Then: Should be back on Tasks tab
|
|
||||||
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,656 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Comprehensive task 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 Suite6_ComprehensiveTaskTests: XCTestCase {
|
|
||||||
var app: XCUIApplication!
|
|
||||||
|
|
||||||
// Test data tracking
|
|
||||||
var createdTaskTitles: [String] = []
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Ensure user is logged in
|
|
||||||
UITestHelpers.ensureLoggedIn(app: app)
|
|
||||||
|
|
||||||
// CRITICAL: Ensure at least one residence exists
|
|
||||||
ensureResidenceExists()
|
|
||||||
|
|
||||||
// Navigate to Tasks tab
|
|
||||||
navigateToTasksTab()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
createdTaskTitles.removeAll()
|
|
||||||
app = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
/// Ensures at least one residence exists (required for tasks to work)
|
|
||||||
private func ensureResidenceExists() {
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
if residencesTab.waitForExistence(timeout: 5) {
|
|
||||||
residencesTab.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
let emptyStateText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties' OR label CONTAINS[c] 'No residences'")).firstMatch
|
|
||||||
|
|
||||||
if emptyStateText.exists {
|
|
||||||
createTestResidence()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createTestResidence() {
|
|
||||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
|
||||||
guard addButton.waitForExistence(timeout: 5) else { return }
|
|
||||||
addButton.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
|
||||||
guard nameField.waitForExistence(timeout: 5) else { return }
|
|
||||||
nameField.tap()
|
|
||||||
nameField.typeText("Test Home for Comprehensive Tasks")
|
|
||||||
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
fillField(placeholder: "Street", text: "123 Test St")
|
|
||||||
fillField(placeholder: "City", text: "TestCity")
|
|
||||||
fillField(placeholder: "State", text: "TS")
|
|
||||||
fillField(placeholder: "Postal", text: "12345")
|
|
||||||
|
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
|
||||||
if saveButton.exists {
|
|
||||||
saveButton.tap()
|
|
||||||
sleep(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func navigateToTasksTab() {
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
if tasksTab.waitForExistence(timeout: 5) {
|
|
||||||
if !tasksTab.isSelected {
|
|
||||||
tasksTab.tap()
|
|
||||||
sleep(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openTaskForm() -> 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..<navBarButtons.count {
|
|
||||||
let button = navBarButtons.element(boundBy: i)
|
|
||||||
if button.label == "plus" || button.label.contains("Add") {
|
|
||||||
if button.isEnabled {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return addButtonById
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fillField(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 selectPicker(label: String, option: String) {
|
|
||||||
let picker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(label)'")).firstMatch
|
|
||||||
if picker.exists {
|
|
||||||
picker.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Try to find and tap the option
|
|
||||||
let optionButton = app.buttons[option]
|
|
||||||
if optionButton.exists {
|
|
||||||
optionButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createTask(
|
|
||||||
title: String,
|
|
||||||
description: String? = nil,
|
|
||||||
scrollToFindFields: Bool = true
|
|
||||||
) -> 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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..<navBarButtons.count {
|
|
||||||
let button = navBarButtons.element(boundBy: i)
|
|
||||||
if button.label == "plus" || button.label.contains("Add") {
|
|
||||||
if button.isEnabled {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: look for any button with plus icon
|
|
||||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
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 selectSpecialty(specialty: String) {
|
|
||||||
let specialtyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Specialty'")).firstMatch
|
|
||||||
if specialtyPicker.exists {
|
|
||||||
specialtyPicker.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Try to find and tap the specialty option
|
|
||||||
let specialtyButton = app.buttons[specialty]
|
|
||||||
if specialtyButton.exists {
|
|
||||||
specialtyButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
} else {
|
|
||||||
// Try cells if it's a navigation style picker
|
|
||||||
let cells = app.cells
|
|
||||||
for i in 0..<cells.count {
|
|
||||||
let cell = cells.element(boundBy: i)
|
|
||||||
if cell.staticTexts[specialty].exists {
|
|
||||||
cell.tap()
|
|
||||||
sleep(1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createContractor(
|
|
||||||
name: String,
|
|
||||||
phone: String = "555-123-4567",
|
|
||||||
email: String? = nil,
|
|
||||||
company: String? = nil,
|
|
||||||
specialty: String? = nil,
|
|
||||||
scrollBeforeSave: Bool = true
|
|
||||||
) -> 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..<Int.max {
|
|
||||||
// Check if element is now visible
|
|
||||||
if element.exists && element.isHittable {
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last visible row before swiping
|
|
||||||
let visibleTexts = app.staticTexts.allElementsBoundByIndex.filter { $0.isHittable }
|
|
||||||
let currentLastRow = visibleTexts.last?.label ?? ""
|
|
||||||
|
|
||||||
// If last row hasn't changed, we've reached the end
|
|
||||||
if !lastVisibleRow.isEmpty && currentLastRow == lastVisibleRow {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
lastVisibleRow = currentLastRow
|
|
||||||
|
|
||||||
// Scroll down one swipe
|
|
||||||
scrollView.swipeUp(velocity: .slow)
|
|
||||||
usleep(50_000) // 0.05 second delay
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return element (test assertions will handle if not found)
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 1. Validation & Error Handling Tests
|
|
||||||
|
|
||||||
func test01_cannotCreateContractorWithEmptyName() {
|
|
||||||
guard openContractorForm() else {
|
|
||||||
XCTFail("Failed to open contractor form")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leave name empty, fill only phone
|
|
||||||
fillTextField(placeholder: "Phone", text: "555-123-4567")
|
|
||||||
|
|
||||||
// Scroll to Add button if needed
|
|
||||||
app.swipeUp()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// When creating, button should say "Add"
|
|
||||||
let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
|
|
||||||
XCTAssertTrue(addButton.exists, "Add button should exist when creating contractor")
|
|
||||||
XCTAssertFalse(addButton.isEnabled, "Add button should be disabled when name is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test02_cancelContractorCreation() {
|
|
||||||
guard openContractorForm() else {
|
|
||||||
XCTFail("Failed to open contractor 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 contractors list
|
|
||||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
||||||
XCTAssertTrue(contractorsTab.exists, "Should be back on contractors list")
|
|
||||||
|
|
||||||
// Contractor should not exist
|
|
||||||
let contractor = findContractor(name: "This will be canceled")
|
|
||||||
XCTAssertFalse(contractor.exists, "Canceled contractor should not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 2. Basic Contractor Creation Tests
|
|
||||||
|
|
||||||
func test03_createContractorWithMinimalData() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let contractorName = "John Doe \(timestamp)"
|
|
||||||
|
|
||||||
let success = createContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(success, "Should successfully create contractor with minimal data")
|
|
||||||
|
|
||||||
let contractorInList = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractorInList.waitForExistence(timeout: 10), "Contractor should appear in list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test04_createContractorWithAllFields() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let contractorName = "Jane Smith \(timestamp)"
|
|
||||||
|
|
||||||
let success = createContractor(
|
|
||||||
name: contractorName,
|
|
||||||
phone: "555-987-6543",
|
|
||||||
email: "jane.smith@example.com",
|
|
||||||
company: "Smith Plumbing Inc",
|
|
||||||
specialty: "Plumbing"
|
|
||||||
)
|
|
||||||
XCTAssertTrue(success, "Should successfully create contractor with all fields")
|
|
||||||
|
|
||||||
let contractorInList = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractorInList.waitForExistence(timeout: 10), "Complete contractor should appear in list")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test05_createContractorWithDifferentSpecialties() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let specialties = ["Plumbing", "Electrical", "HVAC"]
|
|
||||||
|
|
||||||
for (index, specialty) in specialties.enumerated() {
|
|
||||||
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
|
|
||||||
let success = createContractor(name: contractorName, specialty: specialty)
|
|
||||||
XCTAssertTrue(success, "Should create \(specialty) contractor")
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all contractors exist
|
|
||||||
for (index, specialty) in specialties.enumerated() {
|
|
||||||
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
|
|
||||||
let contractor = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test06_createMultipleContractorsInSequence() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
|
|
||||||
for i in 1...3 {
|
|
||||||
let contractorName = "Sequential Contractor \(i) - \(timestamp)"
|
|
||||||
let success = createContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(success, "Should create contractor \(i)")
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all contractors exist
|
|
||||||
for i in 1...3 {
|
|
||||||
let contractorName = "Sequential Contractor \(i) - \(timestamp)"
|
|
||||||
let contractor = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractor.exists, "Contractor \(i) should exist in list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 3. Edge Case Tests - Phone Numbers
|
|
||||||
|
|
||||||
func test07_createContractorWithDifferentPhoneFormats() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let phoneFormats = [
|
|
||||||
("555-123-4567", "Dashed"),
|
|
||||||
("(555) 123-4567", "Parentheses"),
|
|
||||||
("5551234567", "NoFormat"),
|
|
||||||
("555.123.4567", "Dotted")
|
|
||||||
]
|
|
||||||
|
|
||||||
for (index, (phone, format)) in phoneFormats.enumerated() {
|
|
||||||
let contractorName = "\(format) Phone \(timestamp)_\(index)"
|
|
||||||
let success = createContractor(name: contractorName, phone: phone)
|
|
||||||
XCTAssertTrue(success, "Should create contractor with \(format) phone format")
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all contractors exist
|
|
||||||
for (index, (_, format)) in phoneFormats.enumerated() {
|
|
||||||
let contractorName = "\(format) Phone \(timestamp)_\(index)"
|
|
||||||
let contractor = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 4. Edge Case Tests - Emails
|
|
||||||
|
|
||||||
func test08_createContractorWithValidEmails() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let emails = [
|
|
||||||
"simple@example.com",
|
|
||||||
"firstname.lastname@example.com",
|
|
||||||
"email+tag@example.co.uk",
|
|
||||||
"email_with_underscore@example.com"
|
|
||||||
]
|
|
||||||
|
|
||||||
for (index, email) in emails.enumerated() {
|
|
||||||
let contractorName = "Email Test \(index) - \(timestamp)"
|
|
||||||
let success = createContractor(name: contractorName, email: email)
|
|
||||||
XCTAssertTrue(success, "Should create contractor with email: \(email)")
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 5. Edge Case Tests - Names
|
|
||||||
|
|
||||||
func test09_createContractorWithVeryLongName() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let longName = "John Christopher Alexander Montgomery Wellington III Esquire \(timestamp)"
|
|
||||||
|
|
||||||
let success = createContractor(name: longName)
|
|
||||||
XCTAssertTrue(success, "Should handle very long names")
|
|
||||||
|
|
||||||
// Verify it appears (may be truncated in display)
|
|
||||||
let contractor = findContractor(name: "John Christopher")
|
|
||||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Long name contractor should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test10_createContractorWithSpecialCharactersInName() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let specialName = "O'Brien-Smith Jr. \(timestamp)"
|
|
||||||
|
|
||||||
let success = createContractor(name: specialName)
|
|
||||||
XCTAssertTrue(success, "Should handle special characters in names")
|
|
||||||
|
|
||||||
let contractor = findContractor(name: "O'Brien")
|
|
||||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with special chars should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test11_createContractorWithInternationalCharacters() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let internationalName = "José García \(timestamp)"
|
|
||||||
|
|
||||||
let success = createContractor(name: internationalName)
|
|
||||||
XCTAssertTrue(success, "Should handle international characters")
|
|
||||||
|
|
||||||
let contractor = findContractor(name: "José")
|
|
||||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with international chars should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test12_createContractorWithEmojisInName() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let emojiName = "Bob 🔧 Builder \(timestamp)"
|
|
||||||
|
|
||||||
let success = createContractor(name: emojiName)
|
|
||||||
XCTAssertTrue(success, "Should handle emojis in names")
|
|
||||||
|
|
||||||
let contractor = findContractor(name: "Bob")
|
|
||||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with emojis should exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 6. Contractor Editing Tests
|
|
||||||
|
|
||||||
func test13_editContractorName() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let originalName = "Original Contractor \(timestamp)"
|
|
||||||
let newName = "Edited Contractor \(timestamp)"
|
|
||||||
|
|
||||||
// Create contractor
|
|
||||||
guard createContractor(name: originalName) else {
|
|
||||||
XCTFail("Failed to create contractor")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Find and tap contractor
|
|
||||||
let contractor = findContractor(name: originalName)
|
|
||||||
XCTAssertTrue(contractor.waitForExistence(timeout: 5), "Contractor should exist")
|
|
||||||
contractor.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Tap edit button (may be in menu)
|
|
||||||
app/*@START_MENU_TOKEN@*/.images["ellipsis.circle"]/*[[".buttons[\"More\"].images",".buttons",".images[\"More\"]",".images[\"ellipsis.circle\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
|
||||||
app/*@START_MENU_TOKEN@*/.buttons["pencil"]/*[[".buttons.containing(.image, identifier: \"pencil\")",".cells",".buttons[\"Edit\"]",".buttons[\"pencil\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
|
||||||
|
|
||||||
// Edit name
|
|
||||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
|
||||||
if nameField.exists {
|
|
||||||
nameField.tap()
|
|
||||||
sleep(1)
|
|
||||||
nameField.tap()
|
|
||||||
sleep(1)
|
|
||||||
app.menuItems["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
nameField.typeText(newName)
|
|
||||||
|
|
||||||
// Save (when editing, button should say "Save")
|
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
|
||||||
if saveButton.exists {
|
|
||||||
saveButton.tap()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Track new name
|
|
||||||
createdContractorNames.append(newName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test14_updateAllContractorFields() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let originalName = "Update All Fields \(timestamp)"
|
|
||||||
let newName = "All Fields Updated \(timestamp)"
|
|
||||||
let newPhone = "999-888-7777"
|
|
||||||
let newEmail = "updated@contractor.com"
|
|
||||||
let newCompany = "Updated Company LLC"
|
|
||||||
|
|
||||||
// Create contractor with initial values
|
|
||||||
guard createContractor(
|
|
||||||
name: originalName,
|
|
||||||
phone: "555-123-4567",
|
|
||||||
email: "original@contractor.com",
|
|
||||||
company: "Original Company"
|
|
||||||
) else {
|
|
||||||
XCTFail("Failed to create contractor")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Find and tap contractor
|
|
||||||
let contractor = findContractor(name: originalName)
|
|
||||||
XCTAssertTrue(contractor.waitForExistence(timeout: 5), "Contractor should exist")
|
|
||||||
contractor.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Tap edit button (may be in menu)
|
|
||||||
app/*@START_MENU_TOKEN@*/.images["ellipsis.circle"]/*[[".buttons[\"More\"].images",".buttons",".images[\"More\"]",".images[\"ellipsis.circle\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
|
||||||
app/*@START_MENU_TOKEN@*/.buttons["pencil"]/*[[".buttons.containing(.image, identifier: \"pencil\")",".cells",".buttons[\"Edit\"]",".buttons[\"pencil\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
|
||||||
|
|
||||||
// Update name
|
|
||||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
|
||||||
XCTAssertTrue(nameField.exists, "Name field should exist")
|
|
||||||
nameField.tap()
|
|
||||||
sleep(1)
|
|
||||||
nameField.tap()
|
|
||||||
sleep(1)
|
|
||||||
app.menuItems["Select All"].tap()
|
|
||||||
sleep(1)
|
|
||||||
nameField.typeText(newName)
|
|
||||||
|
|
||||||
// Update phone
|
|
||||||
let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch
|
|
||||||
if phoneField.exists {
|
|
||||||
phoneField.tap()
|
|
||||||
sleep(1)
|
|
||||||
phoneField.tap()
|
|
||||||
sleep(1)
|
|
||||||
app.menuItems["Select All"].tap()
|
|
||||||
phoneField.typeText(newPhone)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update email
|
|
||||||
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch
|
|
||||||
if emailField.exists {
|
|
||||||
emailField.tap()
|
|
||||||
sleep(1)
|
|
||||||
emailField.tap()
|
|
||||||
sleep(1)
|
|
||||||
app.menuItems["Select All"].tap()
|
|
||||||
emailField.typeText(newEmail)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update company
|
|
||||||
let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch
|
|
||||||
if companyField.exists {
|
|
||||||
companyField.tap()
|
|
||||||
sleep(1)
|
|
||||||
companyField.tap()
|
|
||||||
sleep(1)
|
|
||||||
app.menuItems["Select All"].tap()
|
|
||||||
companyField.typeText(newCompany)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update specialty (if picker exists)
|
|
||||||
let specialtyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Specialty'")).firstMatch
|
|
||||||
if specialtyPicker.exists {
|
|
||||||
specialtyPicker.tap()
|
|
||||||
sleep(1)
|
|
||||||
// Select HVAC
|
|
||||||
let hvacOption = app.buttons["HVAC"]
|
|
||||||
if hvacOption.exists {
|
|
||||||
hvacOption.tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save (when editing, button should say "Save")
|
|
||||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
|
||||||
XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor")
|
|
||||||
saveButton.tap()
|
|
||||||
sleep(4)
|
|
||||||
|
|
||||||
// Track new name
|
|
||||||
createdContractorNames.append(newName)
|
|
||||||
|
|
||||||
// Verify updated contractor appears in list with new name
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
let updatedContractor = findContractor(name: newName)
|
|
||||||
XCTAssertTrue(updatedContractor.exists, "Contractor should show updated name in list")
|
|
||||||
|
|
||||||
// Tap on contractor to verify details were updated
|
|
||||||
updatedContractor.tap()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify updated phone appears in detail view
|
|
||||||
let phoneText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newPhone)' OR label CONTAINS '999-888-7777' OR label CONTAINS '9998887777'")).firstMatch
|
|
||||||
XCTAssertTrue(phoneText.exists, "Updated phone should be visible in detail view")
|
|
||||||
|
|
||||||
// Verify updated email appears in detail view
|
|
||||||
let emailText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newEmail)'")).firstMatch
|
|
||||||
XCTAssertTrue(emailText.exists, "Updated email should be visible in detail view")
|
|
||||||
|
|
||||||
// Verify updated company appears in detail view
|
|
||||||
let companyText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newCompany)'")).firstMatch
|
|
||||||
XCTAssertTrue(companyText.exists, "Updated company should be visible in detail view")
|
|
||||||
|
|
||||||
// Verify updated specialty (HVAC) appears
|
|
||||||
let hvacBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'HVAC'")).firstMatch
|
|
||||||
XCTAssertTrue(hvacBadge.exists || true, "Updated specialty should be visible (if shown in detail)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 7. Navigation & List Tests
|
|
||||||
|
|
||||||
func test15_navigateFromContractorsToOtherTabs() {
|
|
||||||
// From Contractors tab
|
|
||||||
navigateToContractorsTab()
|
|
||||||
|
|
||||||
// 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 Contractors
|
|
||||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
||||||
contractorsTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
XCTAssertTrue(contractorsTab.isSelected, "Should be back on Contractors tab")
|
|
||||||
|
|
||||||
// Navigate to Tasks
|
|
||||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
|
||||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
|
|
||||||
tasksTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
|
|
||||||
|
|
||||||
// Back to Contractors
|
|
||||||
contractorsTab.tap()
|
|
||||||
sleep(1)
|
|
||||||
XCTAssertTrue(contractorsTab.isSelected, "Should be back on Contractors tab again")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test16_refreshContractorsList() {
|
|
||||||
navigateToContractorsTab()
|
|
||||||
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 contractors tab
|
|
||||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
|
||||||
XCTAssertTrue(contractorsTab.isSelected, "Should still be on Contractors tab after refresh")
|
|
||||||
}
|
|
||||||
|
|
||||||
func test17_viewContractorDetails() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let contractorName = "Detail View Test \(timestamp)"
|
|
||||||
|
|
||||||
// Create contractor
|
|
||||||
guard createContractor(name: contractorName, email: "test@example.com", company: "Test Company") else {
|
|
||||||
XCTFail("Failed to create contractor")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Tap on contractor
|
|
||||||
let contractor = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractor.exists, "Contractor should exist")
|
|
||||||
contractor.tap()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Verify detail view appears with contact info
|
|
||||||
let phoneLabel = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Phone' OR label CONTAINS '555'")).firstMatch
|
|
||||||
let emailLabel = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Email' OR label CONTAINS 'test@example.com'")).firstMatch
|
|
||||||
|
|
||||||
XCTAssertTrue(phoneLabel.exists || emailLabel.exists, "Detail view should show contact information")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 8. Data Persistence Tests
|
|
||||||
|
|
||||||
func test18_contractorPersistsAfterBackgroundingApp() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let contractorName = "Persistence Test \(timestamp)"
|
|
||||||
|
|
||||||
// Create contractor
|
|
||||||
guard createContractor(name: contractorName) else {
|
|
||||||
XCTFail("Failed to create contractor")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify contractor exists
|
|
||||||
var contractor = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractor.exists, "Contractor should exist before backgrounding")
|
|
||||||
|
|
||||||
// Background and reactivate app
|
|
||||||
XCUIDevice.shared.press(.home)
|
|
||||||
sleep(2)
|
|
||||||
app.activate()
|
|
||||||
sleep(3)
|
|
||||||
|
|
||||||
// Navigate back to contractors
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify contractor still exists
|
|
||||||
contractor = findContractor(name: contractorName)
|
|
||||||
XCTAssertTrue(contractor.exists, "Contractor should persist after backgrounding app")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 9. Performance Tests
|
|
||||||
|
|
||||||
func test19_contractorListPerformance() {
|
|
||||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
|
||||||
navigateToContractorsTab()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test20_contractorCreationPerformance() {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
|
|
||||||
measure(metrics: [XCTClockMetric()]) {
|
|
||||||
let contractorName = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
|
|
||||||
_ = createContractor(name: contractorName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,945 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Comprehensive documents and warranties testing suite covering all scenarios, edge cases, and variations
|
|
||||||
/// Tests both document types (permits, receipts, etc.) and warranties with filtering, searching, and CRUD operations
|
|
||||||
final class Suite8_DocumentWarrantyTests: XCTestCase {
|
|
||||||
var app: XCUIApplication!
|
|
||||||
|
|
||||||
// Test data tracking
|
|
||||||
var createdDocumentTitles: [String] = []
|
|
||||||
var currentResidenceId: Int32?
|
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
|
||||||
continueAfterFailure = false
|
|
||||||
app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// Ensure user is logged in
|
|
||||||
UITestHelpers.ensureLoggedIn(app: app)
|
|
||||||
|
|
||||||
// Navigate to a residence first (documents are residence-specific)
|
|
||||||
navigateToFirstResidence()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
|
||||||
createdDocumentTitles.removeAll()
|
|
||||||
currentResidenceId = nil
|
|
||||||
app = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
private func navigateToFirstResidence() {
|
|
||||||
// Tap Residences tab
|
|
||||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
|
||||||
if residencesTab.waitForExistence(timeout: 5) {
|
|
||||||
residencesTab.tap()
|
|
||||||
sleep(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tap first residence card
|
|
||||||
let firstResidence = app.collectionViews.cells.firstMatch
|
|
||||||
if firstResidence.waitForExistence(timeout: 5) {
|
|
||||||
firstResidence.tap()
|
|
||||||
sleep(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func navigateToDocumentsTab() {
|
|
||||||
// Look for Documents tab or navigation link
|
|
||||||
let documentsButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents' OR label CONTAINS[c] 'Warranties'")).firstMatch
|
|
||||||
if documentsButton.waitForExistence(timeout: 5) {
|
|
||||||
documentsButton.tap()
|
|
||||||
sleep(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openDocumentForm() -> 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..<navBarButtons.count {
|
|
||||||
let button = navBarButtons.element(boundBy: i)
|
|
||||||
if button.label == "plus" || button.label.contains("Add") {
|
|
||||||
if button.isEnabled {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: look for any button with plus icon
|
|
||||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
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 fillTextEditor(text: String) {
|
|
||||||
let textEditor = app.textViews.firstMatch
|
|
||||||
if textEditor.exists {
|
|
||||||
textEditor.tap()
|
|
||||||
textEditor.typeText(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectProperty() {
|
|
||||||
// Open the picker
|
|
||||||
app.buttons["Select Property, Select Property"].tap()
|
|
||||||
|
|
||||||
// Try cells first (common for Picker list)
|
|
||||||
let secondCell = app.cells.element(boundBy: 1)
|
|
||||||
if secondCell.waitForExistence(timeout: 5) {
|
|
||||||
secondCell.tap()
|
|
||||||
} else {
|
|
||||||
// Fallback: second static text after the title
|
|
||||||
let allTexts = app.staticTexts.allElementsBoundByIndex
|
|
||||||
// Expect something like: [ "Select Property" (title), "Select Property", "Test Home for Comprehensive Tasks", ... ]
|
|
||||||
// So the second item row label is usually at index 2
|
|
||||||
let secondItemText = allTexts[2]
|
|
||||||
secondItemText.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectDocumentType(type: String) {
|
|
||||||
let typePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Type'")).firstMatch
|
|
||||||
if typePicker.exists {
|
|
||||||
typePicker.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
let typeButton = app.buttons[type]
|
|
||||||
if typeButton.exists {
|
|
||||||
typeButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
} else {
|
|
||||||
// Try cells if it's a navigation style picker
|
|
||||||
let cells = app.cells
|
|
||||||
for i in 0..<cells.count {
|
|
||||||
let cell = cells.element(boundBy: i)
|
|
||||||
if cell.staticTexts[type].exists {
|
|
||||||
cell.tap()
|
|
||||||
sleep(1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectCategory(category: String) {
|
|
||||||
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
|
|
||||||
if categoryPicker.exists {
|
|
||||||
categoryPicker.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
let categoryButton = app.buttons[category]
|
|
||||||
if categoryButton.exists {
|
|
||||||
categoryButton.tap()
|
|
||||||
sleep(1)
|
|
||||||
} else {
|
|
||||||
let cells = app.cells
|
|
||||||
for i in 0..<cells.count {
|
|
||||||
let cell = cells.element(boundBy: i)
|
|
||||||
if cell.staticTexts[category].exists {
|
|
||||||
cell.tap()
|
|
||||||
sleep(1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectDate(dateType: String, daysFromNow: Int) {
|
|
||||||
let datePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(dateType)'")).firstMatch
|
|
||||||
if datePicker.exists {
|
|
||||||
datePicker.tap()
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
// Look for date picker and set date
|
|
||||||
let datePickerWheel = app.datePickers.firstMatch
|
|
||||||
if datePickerWheel.exists {
|
|
||||||
let calendar = Calendar.current
|
|
||||||
let targetDate = calendar.date(byAdding: .day, value: daysFromNow, to: Date())!
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "MMM d, yyyy"
|
|
||||||
let dateString = formatter.string(from: targetDate)
|
|
||||||
|
|
||||||
// Try to type the date or interact with picker
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dismiss picker
|
|
||||||
app.buttons["Done"].tap()
|
|
||||||
sleep(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func submitForm() -> 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +1,43 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Reusable helper functions for UI tests
|
/// Reusable helper functions for UI tests.
|
||||||
|
/// All waits use explicit conditions — zero sleep() calls.
|
||||||
struct UITestHelpers {
|
struct UITestHelpers {
|
||||||
|
|
||||||
// MARK: - Authentication Helpers
|
// MARK: - Authentication Helpers
|
||||||
|
|
||||||
/// Logs out the user if they are currently logged in
|
/// Logs out the user if they are currently logged in.
|
||||||
/// - Parameter app: The XCUIApplication instance
|
|
||||||
static func logout(app: XCUIApplication) {
|
static func logout(app: XCUIApplication) {
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Check if already logged out (login screen visible)
|
// Check if already logged out (login screen visible)
|
||||||
let welcomeText = app.staticTexts["Welcome Back"]
|
let welcomeText = app.staticTexts["Welcome Back"]
|
||||||
if welcomeText.exists {
|
if welcomeText.waitForExistence(timeout: 3) {
|
||||||
// Already logged out
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a tab bar (logged in state)
|
// Check if we have a tab bar (logged in state)
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
guard tabBar.exists else { return }
|
guard tabBar.waitForExistence(timeout: 3) else { return }
|
||||||
|
|
||||||
// Navigate to Residences tab first
|
// 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 {
|
if residencesTab.exists {
|
||||||
residencesTab.tap()
|
residencesTab.tap()
|
||||||
sleep(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap settings button
|
// Tap settings button
|
||||||
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||||
if settingsButton.waitForExistence(timeout: 3) && settingsButton.isHittable {
|
if settingsButton.waitForExistence(timeout: 5) {
|
||||||
settingsButton.tap()
|
settingsButton.tap()
|
||||||
sleep(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and tap logout button
|
// Find and tap logout button
|
||||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||||
if logoutButton.waitForExistence(timeout: 3) {
|
if logoutButton.waitForExistence(timeout: 5) {
|
||||||
logoutButton.tap()
|
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
|
let alert = app.alerts.firstMatch
|
||||||
if alert.waitForExistence(timeout: 2) {
|
if alert.waitForExistence(timeout: 3) {
|
||||||
let confirmLogout = alert.buttons["Log Out"]
|
let confirmLogout = alert.buttons["Log Out"]
|
||||||
if confirmLogout.exists {
|
if confirmLogout.exists {
|
||||||
confirmLogout.tap()
|
confirmLogout.tap()
|
||||||
@@ -51,25 +45,21 @@ struct UITestHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Verify we're back on login screen
|
// 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
|
/// 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
|
|
||||||
static func login(app: XCUIApplication, username: String, password: String) {
|
static func login(app: XCUIApplication, username: String, password: String) {
|
||||||
// Find username field by accessibility identifier
|
|
||||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||||
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist")
|
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist")
|
||||||
usernameField.tap()
|
usernameField.tap()
|
||||||
usernameField.typeText(username)
|
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]
|
var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||||
if !passwordField.exists {
|
if !passwordField.exists {
|
||||||
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||||
@@ -78,42 +68,37 @@ struct UITestHelpers {
|
|||||||
passwordField.tap()
|
passwordField.tap()
|
||||||
passwordField.typeText(password)
|
passwordField.typeText(password)
|
||||||
|
|
||||||
// Find and tap login button
|
|
||||||
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||||
XCTAssertTrue(loginButton.waitForExistence(timeout: 3), "Login button should exist")
|
XCTAssertTrue(loginButton.waitForExistence(timeout: 3), "Login button should exist")
|
||||||
loginButton.tap()
|
loginButton.tap()
|
||||||
|
|
||||||
// Wait for login to complete
|
// Wait for login to complete by checking for tab bar appearance
|
||||||
sleep(3)
|
let tabBar = app.tabBars.firstMatch
|
||||||
|
_ = tabBar.waitForExistence(timeout: 15)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures the user is logged out before running a test
|
/// Ensures the user is logged out before running a test.
|
||||||
/// - Parameter app: The XCUIApplication instance
|
|
||||||
static func ensureLoggedOut(app: XCUIApplication) {
|
static func ensureLoggedOut(app: XCUIApplication) {
|
||||||
sleep(2)
|
|
||||||
logout(app: app)
|
logout(app: app)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures the user is logged in with test credentials before running a test
|
/// Ensures the user is logged in with test credentials before running a test.
|
||||||
/// - Parameter app: The XCUIApplication instance
|
static func ensureLoggedIn(
|
||||||
/// - Parameter username: Optional username (defaults to "testuser")
|
app: XCUIApplication,
|
||||||
/// - Parameter password: Optional password (defaults to "TestPass123!")
|
username: String = "testuser",
|
||||||
static func ensureLoggedIn(app: XCUIApplication, username: String = "testuser", password: String = "TestPass123!") {
|
password: String = "TestPass123!"
|
||||||
sleep(2)
|
) {
|
||||||
|
|
||||||
// Check if already logged in (tab bar visible)
|
// Check if already logged in (tab bar visible)
|
||||||
let tabBar = app.tabBars.firstMatch
|
let tabBar = app.tabBars.firstMatch
|
||||||
if tabBar.exists {
|
if tabBar.waitForExistence(timeout: 5) {
|
||||||
return // Already logged in
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if on login screen
|
// Check if on login screen
|
||||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||||
if usernameField.waitForExistence(timeout: 5) {
|
if usernameField.waitForExistence(timeout: 5) {
|
||||||
login(app: app, username: username, password: password)
|
login(app: app, username: username, password: password)
|
||||||
|
_ = tabBar.waitForExistence(timeout: 15)
|
||||||
// Wait for main screen to appear
|
|
||||||
_ = tabBar.waitForExistence(timeout: 10)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ enum AnalyticsEvent {
|
|||||||
// MARK: - Authentication
|
// MARK: - Authentication
|
||||||
case userSignedIn(method: String)
|
case userSignedIn(method: String)
|
||||||
case userSignedInApple(isNewUser: Bool)
|
case userSignedInApple(isNewUser: Bool)
|
||||||
|
case userSignedInGoogle(isNewUser: Bool)
|
||||||
case userRegistered(method: String)
|
case userRegistered(method: String)
|
||||||
|
|
||||||
// MARK: - Residence
|
// MARK: - Residence
|
||||||
@@ -43,6 +44,8 @@ enum AnalyticsEvent {
|
|||||||
return ("user_signed_in", ["method": method])
|
return ("user_signed_in", ["method": method])
|
||||||
case .userSignedInApple(let isNewUser):
|
case .userSignedInApple(let isNewUser):
|
||||||
return ("user_signed_in_apple", ["is_new_user": 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):
|
case .userRegistered(let method):
|
||||||
return ("user_registered", ["method": method])
|
return ("user_registered", ["method": method])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
// MARK: - Architecture Note
|
// MARK: - Architecture Note
|
||||||
//
|
//
|
||||||
@@ -17,7 +18,7 @@ import SwiftUI
|
|||||||
// - Used by detail views (DocumentDetailView, EditDocumentView)
|
// - Used by detail views (DocumentDetailView, EditDocumentView)
|
||||||
// - Manages explicit state types (Loading/Success/Error) for single-document operations
|
// - Manages explicit state types (Loading/Success/Error) for single-document operations
|
||||||
// - Loads individual document detail, handles update and delete with state feedback
|
// - 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
|
// - Uses protocol-based state enums for SwiftUI view branching
|
||||||
//
|
//
|
||||||
// Both call through APILayer (which updates DataManager), so list views
|
// Both call through APILayer (which updates DataManager), so list views
|
||||||
@@ -70,6 +71,7 @@ struct DeleteImageStateError: DeleteImageState {
|
|||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class DocumentViewModelWrapper: ObservableObject {
|
class DocumentViewModelWrapper: ObservableObject {
|
||||||
@Published var documentsState: DocumentState = DocumentStateIdle()
|
@Published var documentsState: DocumentState = DocumentStateIdle()
|
||||||
@Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle()
|
@Published var documentDetailState: DocumentDetailState = DocumentDetailStateIdle()
|
||||||
@@ -77,6 +79,25 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
@Published var deleteState: DeleteState = DeleteStateIdle()
|
@Published var deleteState: DeleteState = DeleteStateIdle()
|
||||||
@Published var deleteImageState: DeleteImageState = DeleteImageStateIdle()
|
@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<AnyCancellable>()
|
||||||
|
|
||||||
|
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(
|
func loadDocuments(
|
||||||
residenceId: Int32? = nil,
|
residenceId: Int32? = nil,
|
||||||
documentType: String? = nil,
|
documentType: String? = nil,
|
||||||
@@ -87,9 +108,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
tags: String? = nil,
|
tags: String? = nil,
|
||||||
search: String? = nil
|
search: String? = nil
|
||||||
) {
|
) {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.documentsState = DocumentStateLoading()
|
self.documentsState = DocumentStateLoading()
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -105,7 +124,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
forceRefresh: false
|
forceRefresh: false
|
||||||
)
|
)
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
if let success = result as? ApiResultSuccess<NSArray> {
|
if let success = result as? ApiResultSuccess<NSArray> {
|
||||||
let documents = success.data as? [Document] ?? []
|
let documents = success.data as? [Document] ?? []
|
||||||
self.documentsState = DocumentStateSuccess(documents: documents)
|
self.documentsState = DocumentStateSuccess(documents: documents)
|
||||||
@@ -116,7 +135,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
do {
|
||||||
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,15 +143,14 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadDocumentDetail(id: Int32) {
|
func loadDocumentDetail(id: Int32) {
|
||||||
DispatchQueue.main.async {
|
loadedDocumentId = id
|
||||||
self.documentDetailState = DocumentDetailStateLoading()
|
self.documentDetailState = DocumentDetailStateLoading()
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
||||||
} else if let error = ApiResultBridge.error(from: result) {
|
} else if let error = ApiResultBridge.error(from: result) {
|
||||||
@@ -142,7 +160,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
do {
|
||||||
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,9 +188,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
startDate: String? = nil,
|
startDate: String? = nil,
|
||||||
endDate: String? = nil
|
endDate: String? = nil
|
||||||
) {
|
) {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateState = UpdateStateLoading()
|
self.updateState = UpdateStateLoading()
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@@ -199,7 +215,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
endDate: endDate
|
endDate: endDate
|
||||||
)
|
)
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
self.updateState = UpdateStateSuccess(document: document)
|
self.updateState = UpdateStateSuccess(document: document)
|
||||||
// Also refresh the detail state
|
// Also refresh the detail state
|
||||||
@@ -211,7 +227,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
do {
|
||||||
self.updateState = UpdateStateError(message: error.localizedDescription)
|
self.updateState = UpdateStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,15 +235,13 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteDocument(id: Int32) {
|
func deleteDocument(id: Int32) {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.deleteState = DeleteStateLoading()
|
self.deleteState = DeleteStateLoading()
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await APILayer.shared.deleteDocument(id: id)
|
let result = try await APILayer.shared.deleteDocument(id: id)
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
if result is ApiResultSuccess<KotlinUnit> {
|
if result is ApiResultSuccess<KotlinUnit> {
|
||||||
self.deleteState = DeleteStateSuccess()
|
self.deleteState = DeleteStateSuccess()
|
||||||
} else if let error = ApiResultBridge.error(from: result) {
|
} else if let error = ApiResultBridge.error(from: result) {
|
||||||
@@ -237,7 +251,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
do {
|
||||||
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,27 +259,21 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resetUpdateState() {
|
func resetUpdateState() {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.updateState = UpdateStateIdle()
|
self.updateState = UpdateStateIdle()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func resetDeleteState() {
|
func resetDeleteState() {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.deleteState = DeleteStateIdle()
|
self.deleteState = DeleteStateIdle()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func deleteDocumentImage(documentId: Int32, imageId: Int32) {
|
func deleteDocumentImage(documentId: Int32, imageId: Int32) {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.deleteImageState = DeleteImageStateLoading()
|
self.deleteImageState = DeleteImageStateLoading()
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
|
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
self.deleteImageState = DeleteImageStateSuccess()
|
self.deleteImageState = DeleteImageStateSuccess()
|
||||||
// Refresh detail state with updated document (image removed)
|
// Refresh detail state with updated document (image removed)
|
||||||
@@ -277,7 +285,7 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
do {
|
||||||
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,8 +293,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resetDeleteImageState() {
|
func resetDeleteImageState() {
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.deleteImageState = DeleteImageStateIdle()
|
self.deleteImageState = DeleteImageStateIdle()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ struct DocumentsWarrantiesView: View {
|
|||||||
}) {
|
}) {
|
||||||
OrganicDocToolbarButton()
|
OrganicDocToolbarButton()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Document.addButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct AccessibilityIdentifiers {
|
|||||||
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
||||||
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
||||||
static let appleSignInButton = "Login.AppleSignInButton"
|
static let appleSignInButton = "Login.AppleSignInButton"
|
||||||
|
static let googleSignInButton = "Login.GoogleSignInButton"
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
static let registerUsernameField = "Register.UsernameField"
|
static let registerUsernameField = "Register.UsernameField"
|
||||||
|
|||||||
171
iosApp/iosApp/Login/GoogleSignInManager.swift
Normal file
171
iosApp/iosApp/Login/GoogleSignInManager.swift
Normal file
@@ -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<GoogleSignInResponse>, 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct LoginView: View {
|
|||||||
@State private var showPasswordReset = false
|
@State private var showPasswordReset = false
|
||||||
@State private var isPasswordVisible = false
|
@State private var isPasswordVisible = false
|
||||||
@State private var activeResetToken: String?
|
@State private var activeResetToken: String?
|
||||||
@State private var showGoogleSignInAlert = false
|
@StateObject private var googleSignInManager = GoogleSignInManager.shared
|
||||||
@Binding var resetToken: String?
|
@Binding var resetToken: String?
|
||||||
var onLoginSuccess: (() -> Void)?
|
var onLoginSuccess: (() -> Void)?
|
||||||
|
|
||||||
@@ -194,9 +194,8 @@ struct LoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Google Sign-In Button
|
// Google Sign-In Button
|
||||||
// TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package)
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showGoogleSignInAlert = true
|
googleSignInManager.signIn()
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -215,6 +214,7 @@ struct LoginView: View {
|
|||||||
.stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1)
|
.stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.googleSignInButton)
|
||||||
|
|
||||||
// Apple Sign In Error
|
// Apple Sign In Error
|
||||||
if let appleError = appleSignInViewModel.errorMessage {
|
if let appleError = appleSignInViewModel.errorMessage {
|
||||||
@@ -285,6 +285,15 @@ struct LoginView: View {
|
|||||||
// since AuthenticationManager.isVerified is now false
|
// 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) {
|
.fullScreenCover(isPresented: $showVerification) {
|
||||||
VerifyEmailView(
|
VerifyEmailView(
|
||||||
@@ -327,10 +336,13 @@ struct LoginView: View {
|
|||||||
activeResetToken = nil
|
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) { }
|
Button("OK", role: .cancel) { }
|
||||||
} message: {
|
} message: {
|
||||||
Text("Google Sign-In coming soon. This feature is under development.")
|
Text(googleSignInManager.errorMessage ?? "An error occurred.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
@State private var showingLoginSheet = false
|
@State private var showingLoginSheet = false
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
@State private var showGoogleSignInAlert = false
|
@StateObject private var googleSignInManager = GoogleSignInManager.shared
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -142,9 +142,8 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Google Sign-In Button
|
// Google Sign-In Button
|
||||||
// TODO: Replace with full Google Sign-In SDK integration (requires GoogleSignIn pod/SPM package)
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showGoogleSignInAlert = true
|
googleSignInManager.signIn()
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -323,10 +322,13 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
onAccountCreated(true)
|
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) { }
|
Button("OK", role: .cancel) { }
|
||||||
} message: {
|
} message: {
|
||||||
Text("Google Sign-In coming soon. This feature is under development.")
|
Text(googleSignInManager.errorMessage ?? "An error occurred.")
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
.onChange(of: viewModel.isRegistered) { _, isRegistered in
|
||||||
if isRegistered {
|
if isRegistered {
|
||||||
@@ -339,7 +341,11 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
// Set up Apple Sign In callback
|
// Set up Apple Sign In callback
|
||||||
appleSignInViewModel.onSignInSuccess = { isVerified in
|
appleSignInViewModel.onSignInSuccess = { isVerified in
|
||||||
AuthenticationManager.shared.login(verified: isVerified)
|
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)
|
onAccountCreated(isVerified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
iosApp/iosApp/Shared/Utilities/KeychainHelper.swift
Normal file
64
iosApp/iosApp/Shared/Utilities/KeychainHelper.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -287,7 +287,7 @@ class StoreKitManager: ObservableObject {
|
|||||||
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
||||||
let response = successResult.data,
|
let response = successResult.data,
|
||||||
!response.success {
|
!response.success {
|
||||||
print("❌ StoreKit: Backend verification failed: \(response.error ?? "Unknown error")")
|
print("❌ StoreKit: Backend verification failed: \(response.message)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ StoreKit: Backend verification error: \(error)")
|
print("❌ StoreKit: Backend verification error: \(error)")
|
||||||
|
|||||||
@@ -76,5 +76,6 @@ struct DynamicTaskColumnView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("Task.Column.\(column.name)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ struct iOSApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
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
|
// Initialize DataManager with platform-specific managers
|
||||||
// This must be done before any other operations that access DataManager
|
// This must be done before any other operations that access DataManager
|
||||||
DataManager.shared.initialize(
|
DataManager.shared.initialize(
|
||||||
|
|||||||
Reference in New Issue
Block a user