From fe28034f3d2ebdede0289ec8d69fb794d07a9ac0 Mon Sep 17 00:00:00 2001 From: treyt Date: Fri, 20 Feb 2026 10:38:15 -0600 Subject: [PATCH] Refactor iOS UI tests to blueprint architecture --- .../AccessibilityIdentifiers.swift | 272 +++++ .../Docs/Failing_Suites_0_3_Rebuild_Plan.md | 164 +++ .../Docs/Suite1_Failing_Test_Rebuild_Plan.md | 174 ++++ .../Framework/BaseUITestCase.swift | 13 +- .../Framework/RebuildSupport.swift | 183 ++++ .../Framework/ScreenObjects.swift | 38 +- .../CaseraUITests/Framework/TestFlows.swift | 9 +- iosApp/CaseraUITests/SimpleLoginTest.swift | 60 +- .../Suite0_OnboardingTests.swift | 245 ++++- .../Suite10_ComprehensiveE2ETests.swift | 683 ++++++++++++- .../Suite1_RegistrationTests.swift | 653 +++++++++++- .../Suite2_AuthenticationTests.swift | 141 ++- .../CaseraUITests/Suite3_ResidenceTests.swift | 240 ++++- .../Suite4_ComprehensiveResidenceTests.swift | 675 ++++++++++++- iosApp/CaseraUITests/Suite5_TaskTests.swift | 373 ++++++- .../Suite6_ComprehensiveTaskTests.swift | 658 +++++++++++- .../Suite7_ContractorTests.swift | 715 ++++++++++++- .../Suite8_DocumentWarrantyTests.swift | 943 +++++++++++++++++- .../Suite9_IntegrationE2ETests.swift | 524 +++++++++- .../Suite0_OnboardingRebuildTests.swift | 31 + .../Suite1_RegistrationRebuildTests.swift | 72 ++ .../Suite2_AuthenticationRebuildTests.swift | 147 +++ .../Suite3_ResidenceRebuildTests.swift | 137 +++ iosApp/CaseraUITests/UITestHelpers.swift | 174 ++++ iosApp/iosApp/Helpers/UITestRuntime.swift | 5 + iosApp/iosApp/Login/LoginViewModel.swift | 13 + .../OnboardingCreateAccountView.swift | 17 +- .../iosApp/Residence/ResidenceViewModel.swift | 78 ++ 28 files changed, 7354 insertions(+), 83 deletions(-) create mode 100644 iosApp/CaseraUITests/AccessibilityIdentifiers.swift create mode 100644 iosApp/CaseraUITests/Docs/Failing_Suites_0_3_Rebuild_Plan.md create mode 100644 iosApp/CaseraUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md create mode 100644 iosApp/CaseraUITests/Framework/RebuildSupport.swift create mode 100644 iosApp/CaseraUITests/Tests/Rebuild/Suite0_OnboardingRebuildTests.swift create mode 100644 iosApp/CaseraUITests/Tests/Rebuild/Suite1_RegistrationRebuildTests.swift create mode 100644 iosApp/CaseraUITests/Tests/Rebuild/Suite2_AuthenticationRebuildTests.swift create mode 100644 iosApp/CaseraUITests/Tests/Rebuild/Suite3_ResidenceRebuildTests.swift create mode 100644 iosApp/CaseraUITests/UITestHelpers.swift diff --git a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift new file mode 100644 index 0000000..143b73b --- /dev/null +++ b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift @@ -0,0 +1,272 @@ +import Foundation + +/// Centralized accessibility identifiers for UI testing +/// These identifiers are used by XCUITests to locate and interact with UI elements +struct AccessibilityIdentifiers { + + // MARK: - Authentication + struct Authentication { + static let usernameField = "Login.UsernameField" + static let passwordField = "Login.PasswordField" + static let loginButton = "Login.LoginButton" + static let signUpButton = "Login.SignUpButton" + static let forgotPasswordButton = "Login.ForgotPasswordButton" + static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle" + static let appleSignInButton = "Login.AppleSignInButton" + + // Registration + static let registerUsernameField = "Register.UsernameField" + static let registerEmailField = "Register.EmailField" + static let registerPasswordField = "Register.PasswordField" + static let registerConfirmPasswordField = "Register.ConfirmPasswordField" + static let registerButton = "Register.RegisterButton" + static let registerCancelButton = "Register.CancelButton" + + // Verification + static let verificationCodeField = "Verification.CodeField" + static let verifyButton = "Verification.VerifyButton" + static let resendCodeButton = "Verification.ResendButton" + } + + // MARK: - Navigation + struct Navigation { + static let residencesTab = "TabBar.Residences" + static let tasksTab = "TabBar.Tasks" + static let contractorsTab = "TabBar.Contractors" + static let documentsTab = "TabBar.Documents" + static let profileTab = "TabBar.Profile" + static let backButton = "Navigation.BackButton" + } + + // MARK: - Residence + struct Residence { + // List + static let addButton = "Residence.AddButton" + static let residencesList = "Residence.List" + static let residenceCard = "Residence.Card" + static let emptyStateView = "Residence.EmptyState" + static let emptyStateButton = "Residence.EmptyState.AddButton" + + // Form + static let nameField = "ResidenceForm.NameField" + static let propertyTypePicker = "ResidenceForm.PropertyTypePicker" + static let streetAddressField = "ResidenceForm.StreetAddressField" + static let apartmentUnitField = "ResidenceForm.ApartmentUnitField" + static let cityField = "ResidenceForm.CityField" + static let stateProvinceField = "ResidenceForm.StateProvinceField" + static let postalCodeField = "ResidenceForm.PostalCodeField" + static let countryField = "ResidenceForm.CountryField" + static let bedroomsField = "ResidenceForm.BedroomsField" + static let bathroomsField = "ResidenceForm.BathroomsField" + static let squareFootageField = "ResidenceForm.SquareFootageField" + static let lotSizeField = "ResidenceForm.LotSizeField" + static let yearBuiltField = "ResidenceForm.YearBuiltField" + static let descriptionField = "ResidenceForm.DescriptionField" + static let isPrimaryToggle = "ResidenceForm.IsPrimaryToggle" + static let saveButton = "ResidenceForm.SaveButton" + static let formCancelButton = "ResidenceForm.CancelButton" + + // Detail + static let detailView = "ResidenceDetail.View" + static let editButton = "ResidenceDetail.EditButton" + static let deleteButton = "ResidenceDetail.DeleteButton" + static let shareButton = "ResidenceDetail.ShareButton" + static let manageUsersButton = "ResidenceDetail.ManageUsersButton" + static let tasksSection = "ResidenceDetail.TasksSection" + static let addTaskButton = "ResidenceDetail.AddTaskButton" + } + + // MARK: - Task + struct Task { + // List/Kanban + static let addButton = "Task.AddButton" + static let tasksList = "Task.List" + static let taskCard = "Task.Card" + static let emptyStateView = "Task.EmptyState" + static let kanbanView = "Task.KanbanView" + static let overdueColumn = "Task.Column.Overdue" + static let upcomingColumn = "Task.Column.Upcoming" + static let inProgressColumn = "Task.Column.InProgress" + static let completedColumn = "Task.Column.Completed" + + // Form + static let titleField = "TaskForm.TitleField" + static let descriptionField = "TaskForm.DescriptionField" + static let categoryPicker = "TaskForm.CategoryPicker" + static let frequencyPicker = "TaskForm.FrequencyPicker" + static let priorityPicker = "TaskForm.PriorityPicker" + static let statusPicker = "TaskForm.StatusPicker" + static let dueDatePicker = "TaskForm.DueDatePicker" + static let intervalDaysField = "TaskForm.IntervalDaysField" + static let estimatedCostField = "TaskForm.EstimatedCostField" + static let residencePicker = "TaskForm.ResidencePicker" + static let saveButton = "TaskForm.SaveButton" + static let formCancelButton = "TaskForm.CancelButton" + + // Detail + static let detailView = "TaskDetail.View" + static let editButton = "TaskDetail.EditButton" + static let deleteButton = "TaskDetail.DeleteButton" + static let markInProgressButton = "TaskDetail.MarkInProgressButton" + static let completeButton = "TaskDetail.CompleteButton" + static let detailCancelButton = "TaskDetail.CancelButton" + + // Completion + static let completionDatePicker = "TaskCompletion.CompletionDatePicker" + static let actualCostField = "TaskCompletion.ActualCostField" + static let ratingView = "TaskCompletion.RatingView" + static let notesField = "TaskCompletion.NotesField" + static let photosPicker = "TaskCompletion.PhotosPicker" + static let submitButton = "TaskCompletion.SubmitButton" + } + + // MARK: - Contractor + struct Contractor { + static let addButton = "Contractor.AddButton" + static let contractorsList = "Contractor.List" + static let contractorCard = "Contractor.Card" + static let emptyStateView = "Contractor.EmptyState" + + // Form + static let nameField = "ContractorForm.NameField" + static let companyField = "ContractorForm.CompanyField" + static let emailField = "ContractorForm.EmailField" + static let phoneField = "ContractorForm.PhoneField" + static let specialtyPicker = "ContractorForm.SpecialtyPicker" + static let ratingView = "ContractorForm.RatingView" + static let notesField = "ContractorForm.NotesField" + static let saveButton = "ContractorForm.SaveButton" + static let formCancelButton = "ContractorForm.CancelButton" + + // Detail + static let detailView = "ContractorDetail.View" + static let editButton = "ContractorDetail.EditButton" + static let deleteButton = "ContractorDetail.DeleteButton" + static let callButton = "ContractorDetail.CallButton" + static let emailButton = "ContractorDetail.EmailButton" + } + + // MARK: - Document + struct Document { + static let addButton = "Document.AddButton" + static let documentsList = "Document.List" + static let documentCard = "Document.Card" + static let emptyStateView = "Document.EmptyState" + + // Form + static let titleField = "DocumentForm.TitleField" + static let typePicker = "DocumentForm.TypePicker" + static let categoryPicker = "DocumentForm.CategoryPicker" + static let residencePicker = "DocumentForm.ResidencePicker" + static let filePicker = "DocumentForm.FilePicker" + static let notesField = "DocumentForm.NotesField" + static let expirationDatePicker = "DocumentForm.ExpirationDatePicker" + static let saveButton = "DocumentForm.SaveButton" + static let formCancelButton = "DocumentForm.CancelButton" + + // Detail + static let detailView = "DocumentDetail.View" + static let editButton = "DocumentDetail.EditButton" + static let deleteButton = "DocumentDetail.DeleteButton" + static let shareButton = "DocumentDetail.ShareButton" + static let downloadButton = "DocumentDetail.DownloadButton" + } + + // MARK: - Onboarding + struct Onboarding { + // Welcome Screen + static let welcomeTitle = "Onboarding.WelcomeTitle" + static let startFreshButton = "Onboarding.StartFreshButton" + static let joinExistingButton = "Onboarding.JoinExistingButton" + static let loginButton = "Onboarding.LoginButton" + + // Value Props Screen + static let valuePropsTitle = "Onboarding.ValuePropsTitle" + static let valuePropsNextButton = "Onboarding.ValuePropsNextButton" + + // Name Residence Screen + static let nameResidenceTitle = "Onboarding.NameResidenceTitle" + static let residenceNameField = "Onboarding.ResidenceNameField" + static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton" + + // Create Account Screen + static let createAccountTitle = "Onboarding.CreateAccountTitle" + static let appleSignInButton = "Onboarding.AppleSignInButton" + static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton" + static let usernameField = "Onboarding.UsernameField" + static let emailField = "Onboarding.EmailField" + static let passwordField = "Onboarding.PasswordField" + static let confirmPasswordField = "Onboarding.ConfirmPasswordField" + static let createAccountButton = "Onboarding.CreateAccountButton" + static let loginLinkButton = "Onboarding.LoginLinkButton" + + // Verify Email Screen + static let verifyEmailTitle = "Onboarding.VerifyEmailTitle" + static let verificationCodeField = "Onboarding.VerificationCodeField" + static let verifyButton = "Onboarding.VerifyButton" + + // Join Residence Screen + static let joinResidenceTitle = "Onboarding.JoinResidenceTitle" + static let shareCodeField = "Onboarding.ShareCodeField" + static let joinResidenceButton = "Onboarding.JoinResidenceButton" + + // First Task Screen + static let firstTaskTitle = "Onboarding.FirstTaskTitle" + static let taskSelectionCounter = "Onboarding.TaskSelectionCounter" + static let addPopularTasksButton = "Onboarding.AddPopularTasksButton" + static let addTasksContinueButton = "Onboarding.AddTasksContinueButton" + static let taskCategorySection = "Onboarding.TaskCategorySection" + static let taskTemplateRow = "Onboarding.TaskTemplateRow" + + // Subscription Screen + static let subscriptionTitle = "Onboarding.SubscriptionTitle" + static let yearlyPlanCard = "Onboarding.YearlyPlanCard" + static let monthlyPlanCard = "Onboarding.MonthlyPlanCard" + static let startTrialButton = "Onboarding.StartTrialButton" + static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton" + + // Navigation + static let backButton = "Onboarding.BackButton" + static let skipButton = "Onboarding.SkipButton" + static let progressIndicator = "Onboarding.ProgressIndicator" + } + + // MARK: - Profile + struct Profile { + static let logoutButton = "Profile.LogoutButton" + static let editProfileButton = "Profile.EditProfileButton" + static let settingsButton = "Profile.SettingsButton" + static let notificationsToggle = "Profile.NotificationsToggle" + static let darkModeToggle = "Profile.DarkModeToggle" + static let aboutButton = "Profile.AboutButton" + static let helpButton = "Profile.HelpButton" + } + + // MARK: - Alerts & Modals + struct Alert { + static let confirmButton = "Alert.ConfirmButton" + static let cancelButton = "Alert.CancelButton" + static let deleteButton = "Alert.DeleteButton" + static let okButton = "Alert.OKButton" + } + + // MARK: - Common + struct Common { + static let loadingIndicator = "Common.LoadingIndicator" + static let errorView = "Common.ErrorView" + static let retryButton = "Common.RetryButton" + static let searchField = "Common.SearchField" + static let filterButton = "Common.FilterButton" + static let sortButton = "Common.SortButton" + static let refreshControl = "Common.RefreshControl" + } +} + +// MARK: - Helper Extension +extension String { + /// Convenience method to generate dynamic identifiers + /// Example: "Residence.Card.\(residenceId)" + func withId(_ id: Any) -> String { + return "\(self).\(id)" + } +} diff --git a/iosApp/CaseraUITests/Docs/Failing_Suites_0_3_Rebuild_Plan.md b/iosApp/CaseraUITests/Docs/Failing_Suites_0_3_Rebuild_Plan.md new file mode 100644 index 0000000..71c46ec --- /dev/null +++ b/iosApp/CaseraUITests/Docs/Failing_Suites_0_3_Rebuild_Plan.md @@ -0,0 +1,164 @@ +# Failing Suites 0-3: Coverage + Rebuild Plan + +## Baseline (from observed runs) +- `Suite0_OnboardingTests`: 1 test, 1 failure +- `Suite1_RegistrationTests`: 11 tests, 5 failures +- `Suite2_AuthenticationTests`: 6 tests, 2 failures +- `Suite3_ResidenceTests`: 6 tests, 6 failures + +Primary failure logs used: +- `/tmp/ui_suite0.log` +- `/tmp/ui_suites_1_3.log` + +--- + +## Suite0 + +### Failing test +- `Suite0_OnboardingTests.test_onboarding` + +### What it is testing +- End-to-end onboarding progression from welcome/login entry into account creation and onward. +- UI interaction stability during onboarding form entry. + +### Observed failure point +- Assertion failure: `Email field must become focused for typing`. + +### Rebuild in new arch +Create a new test case focused on deterministic onboarding field interaction: +- `Onboarding_EmailRegistration_FocusAndInputFlow` + +Coverage to preserve: +- Email field reliably focusable and typeable. +- Continue action only enabled after valid required inputs. +- Onboarding progresses to next state after valid submission. + +Required infra: +- `OnboardingScreen` page object with `tapEmailField()`, `typeEmail()`, `assertEmailFieldFocused()`. +- Keyboard/overlay helper centralized (not inline in tests). + +--- + +## Suite1 +Detailed plan already captured in: +- `/Users/treyt/Desktop/code/MyCribKMM/iosApp/CaseraUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md` + +### Failing tests +- `test07_successfulRegistrationAndVerification` +- `test09_registrationWithInvalidVerificationCode` +- `test10_verificationCodeFieldValidation` +- `test11_appRelaunchWithUnverifiedUser` +- `test12_logoutFromVerificationScreen` + +### Rebuild targets +- `Registration_HappyPath_CompletesVerification_ThenCanLogout` +- `Registration_InvalidVerifyCode_ShowsError_StaysUnverified` +- `Registration_IncompleteVerifyCode_DoesNotVerify` +- `Registration_UnverifiedUser_RelaunchStillBlockedFromMain` +- `Registration_VerificationScreenLogout_ReturnsToLogin` + +--- + +## Suite2 + +### Failing tests +- `Suite2_AuthenticationTests.test02_loginWithValidCredentials` +- `Suite2_AuthenticationTests.test06_logout` + +### What they are testing + +#### `test02_loginWithValidCredentials` +- Valid login path transitions from login screen to main app. +- Authenticated state exposes main navigation (tab bar/app root). + +#### `test06_logout` +- Logged-in user can logout. +- Session is cleared and app returns to login state. + +### Observed failure points +- `test02`: `Should navigate to main app after successful login` +- `test06`: `Should be logged in` (precondition for logout flow failed) + +### Rebuild in new arch +Create explicit state-driven auth tests: +- `Auth_ValidLogin_TransitionsToMainApp` +- `Auth_Logout_FromMainApp_ReturnsToLogin` + +Coverage to preserve: +- Login success sets authenticated UI state. +- Logout always clears authenticated state. +- No false-positive “logged in” assumptions. + +Required infra: +- `LoginScreen`, `MainTabScreen`, `ProfileScreen` page objects. +- `AuthAssertions.assertAtLoginRoot()`, `assertAtMainRoot()`. +- Test user fixture policy for valid credentials. + +--- + +## Suite3 + +### Failing tests +- `Suite3_ResidenceTests.test01_viewResidencesList` +- `Suite3_ResidenceTests.test02_navigateToAddResidence` +- `Suite3_ResidenceTests.test03_navigationBetweenTabs` +- `Suite3_ResidenceTests.test04_cancelResidenceCreation` +- `Suite3_ResidenceTests.test05_createResidenceWithMinimalData` +- `Suite3_ResidenceTests.test06_viewResidenceDetails` + +### What they are testing +- Residence tab/list visibility. +- Navigation to add-residence form. +- Cross-tab navigation sanity. +- Canceling residence creation. +- Creating residence with minimal fields. +- Opening residence details. + +### Observed failure pattern +All 6 fail at the same gateway: +- No `Residences` tab bar button match found. +- This indicates tests are not reaching authenticated main-app state before residence assertions. + +### Rebuild in new arch +Split auth precondition from residence behavior: +- `Residence_Precondition_AuthenticatedAndAtResidencesTab` +- `Residence_OpenCreateForm` +- `Residence_CancelCreate_ReturnsToList` +- `Residence_CreateMinimal_ShowsInList` +- `Residence_OpenDetails_FromList` +- `Residence_TabNavigation_MainSections` + +Coverage to preserve: +- Residence flows validated only after explicit `main app ready` assertion. +- Failures clearly classify as auth-gate vs residence-feature regression. + +Required infra: +- `MainTabScreen.goToResidences()` with ID-first selectors. +- `ResidenceListScreen`, `ResidenceFormScreen`, `ResidenceDetailScreen` page objects. +- Shared precondition helper: `ensureAuthenticatedMainApp()`. + +--- + +## Blueprint-aligned migration notes +- Keep old-to-new mapping explicit in PR description. +- Replace brittle text-based selectors with accessibility IDs first. +- Use one state assertion per transition boundary: + - `login -> verification -> main app -> login`. +- Move keyboard/strong-password overlay handling into one helper. +- Do not mark legacy tests removed until replacement coverage is green. + +## Proposed replacement matrix +- `Suite0.test_onboarding` -> `Onboarding_EmailRegistration_FocusAndInputFlow` +- `Suite1.test07` -> `Registration_HappyPath_CompletesVerification_ThenCanLogout` +- `Suite1.test09` -> `Registration_InvalidVerifyCode_ShowsError_StaysUnverified` +- `Suite1.test10` -> `Registration_IncompleteVerifyCode_DoesNotVerify` +- `Suite1.test11` -> `Registration_UnverifiedUser_RelaunchStillBlockedFromMain` +- `Suite1.test12` -> `Registration_VerificationScreenLogout_ReturnsToLogin` +- `Suite2.test02` -> `Auth_ValidLogin_TransitionsToMainApp` +- `Suite2.test06` -> `Auth_Logout_FromMainApp_ReturnsToLogin` +- `Suite3.test01` -> `Residence_Precondition_AuthenticatedAndAtResidencesTab` +- `Suite3.test02` -> `Residence_OpenCreateForm` +- `Suite3.test03` -> `Residence_TabNavigation_MainSections` +- `Suite3.test04` -> `Residence_CancelCreate_ReturnsToList` +- `Suite3.test05` -> `Residence_CreateMinimal_ShowsInList` +- `Suite3.test06` -> `Residence_OpenDetails_FromList` diff --git a/iosApp/CaseraUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md b/iosApp/CaseraUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md new file mode 100644 index 0000000..1c5acb6 --- /dev/null +++ b/iosApp/CaseraUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md @@ -0,0 +1,174 @@ +# Suite1 Registration Failing Tests: Coverage + Rebuild Plan + +## Scope +This document captures what the currently failing registration-flow tests are trying to validate and how to recreate that coverage using the new UI test architecture. + +Source tests: +- `Suite1_RegistrationTests.test07_successfulRegistrationAndVerification` +- `Suite1_RegistrationTests.test09_registrationWithInvalidVerificationCode` +- `Suite1_RegistrationTests.test10_verificationCodeFieldValidation` +- `Suite1_RegistrationTests.test11_appRelaunchWithUnverifiedUser` +- `Suite1_RegistrationTests.test12_logoutFromVerificationScreen` + +## Current Failure Context (Observed) +- Registration submit does not transition to a verification screen in automation runs. +- UI-level registration error shown during failures: `Password must be at least 8 characters`. +- Because registration transition fails, downstream verification assertions fail. + +## What Each Failing Test Is Actually Testing + +### 1) `test07_successfulRegistrationAndVerification` +Behavior intent: +- User can register with valid credentials. +- App transitions to verification state. +- Entering valid verification code completes verification. +- User lands in main app (tab bar available). +- Logout returns user to login. + +Core business coverage: +- Happy-path onboarding/auth state progression. +- Verified user session gains app access. +- Logout clears authenticated session. + +### 2) `test09_registrationWithInvalidVerificationCode` +Behavior intent: +- Registration reaches verification state. +- Entering wrong code shows verification error. +- User remains blocked from main app. + +Core business coverage: +- Backend validation for invalid verification code. +- No false positive promotion to verified state. + +### 3) `test10_verificationCodeFieldValidation` +Behavior intent: +- Verification screen enforces code format/length. +- Incomplete code does not complete verification. +- User remains on verification state. + +Core business coverage: +- Client-side verification input guardrails. +- No bypass with partial code. + +### 4) `test11_appRelaunchWithUnverifiedUser` +Behavior intent: +- User reaches unverified verification state. +- App terminate/relaunch preserves unverified gating. +- Relaunch must not allow direct main-app access. + +Core business coverage: +- Session restore + auth gate correctness for unverified users. + +### 5) `test12_logoutFromVerificationScreen` +Behavior intent: +- Unverified user can explicitly logout from verification screen. +- Verification UI dismisses. +- App returns to interactive login screen. + +Core business coverage: +- Logout works from gated verification state. +- Session cleanup from pre-verified auth state. + +## Rebuild These in New Architecture + +## Shared Test Architecture Requirements +Create/ensure these reusable pieces: +- `AuthFlowHarness` (launch + auth preconditions + cleanup) +- `RegistrationScreen` page object +- `VerificationScreen` page object +- `MainTabScreen` page object +- `SessionStateAsserts` helpers for `login`, `verification`, `mainApp` +- `TestUserFactory` with deterministic unique users + +Use stable selectors first: +- Accessibility IDs over title text. +- Support both auth/onboarding verification IDs only if product can route to either screen. + +## Suggested New-Arch Test Cases (One-to-One Replacement) + +### A. `Registration_HappyPath_CompletesVerification_ThenCanLogout` +Covers legacy test07. + +Given: +- Fresh launch, logged out. + +When: +- Register with valid user. +- Verify with valid code. +- Logout from profile/main app. + +Then: +- Verification gate appears after register. +- Main app appears only after successful verify. +- Logout returns to login root. + +### B. `Registration_InvalidVerifyCode_ShowsError_StaysUnverified` +Covers legacy test09. + +Given: +- User registered and on verification screen. + +When: +- Submit invalid verification code. + +Then: +- Error banner/message visible. +- Verification screen remains active. +- Main app root not accessible. + +### C. `Registration_IncompleteVerifyCode_DoesNotVerify` +Covers legacy test10. + +Given: +- User on verification screen. + +When: +- Enter fewer than required digits. +- Attempt verify (or assert button disabled). + +Then: +- Verification completion does not occur. +- User remains blocked from main app. + +### D. `Registration_UnverifiedUser_RelaunchStillBlockedFromMain` +Covers legacy test11. + +Given: +- User registered but not verified. + +When: +- Terminate and relaunch app. + +Then: +- User is on verification gate (or login if session invalidated). +- User is never placed directly in main app state. + +### E. `Registration_VerificationScreenLogout_ReturnsToLogin` +Covers legacy test12. + +Given: +- User at verification gate. + +When: +- Tap logout on verification screen. + +Then: +- Verification state exits. +- Login root becomes active and interactive. + +## Data + Environment Strategy for Rebuild +- Use API mode/environment that is stable for registration + verification in CI and local runs. +- Seed/fixture verification code contract must be explicit (example: fixed debug code). +- Generate unique username/email per test to avoid collisions. +- If keyboard autofill overlays are flaky, centralize handling in input helper (not per-test). + +## Migration Notes +- Keep legacy tests disabled/removed only after each replacement test is green. +- Track replacement mapping in PR description: + - `old test -> new test` +- Preserve negative assertions ("must NOT access main app before verify"). + +## Open Risks To Resolve During Rebuild +- Registration password entry flakiness from iOS strong-password UI overlays. +- Potential mismatch between onboarding verification screen IDs and auth verification screen IDs. +- Environment-dependent backend behavior (local/dev) affecting registration transition. diff --git a/iosApp/CaseraUITests/Framework/BaseUITestCase.swift b/iosApp/CaseraUITests/Framework/BaseUITestCase.swift index 92e7c09..a7c57d7 100644 --- a/iosApp/CaseraUITests/Framework/BaseUITestCase.swift +++ b/iosApp/CaseraUITests/Framework/BaseUITestCase.swift @@ -7,15 +7,22 @@ class BaseUITestCase: XCTestCase { let defaultTimeout: TimeInterval = 15 let longTimeout: TimeInterval = 30 + var includeResetStateLaunchArgument: Bool { true } + var additionalLaunchArguments: [String] { [] } + override func setUpWithError() throws { continueAfterFailure = false XCUIDevice.shared.orientation = .portrait - app.launchArguments = [ + var launchArguments = [ "--ui-testing", - "--disable-animations", - "--reset-state" + "--disable-animations" ] + if includeResetStateLaunchArgument { + launchArguments.append("--reset-state") + } + launchArguments.append(contentsOf: additionalLaunchArguments) + app.launchArguments = launchArguments app.launch() app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: defaultTimeout) diff --git a/iosApp/CaseraUITests/Framework/RebuildSupport.swift b/iosApp/CaseraUITests/Framework/RebuildSupport.swift new file mode 100644 index 0000000..65d4093 --- /dev/null +++ b/iosApp/CaseraUITests/Framework/RebuildSupport.swift @@ -0,0 +1,183 @@ +import XCTest + +struct RebuildTestUser { + let username: String + let email: String + let password: String +} + +enum RebuildTestUserFactory { + static func unique(prefix: String = "uit") -> RebuildTestUser { + let stamp = Int(Date().timeIntervalSince1970) + return RebuildTestUser( + username: "\(prefix)_user_\(stamp)", + email: "\(prefix)_\(stamp)@example.com", + password: "Pass1234" + ) + } + + static var seeded: RebuildTestUser { + RebuildTestUser(username: "testuser", email: "test@example.com", password: "TestPass123!") + } +} + +struct VerificationScreen { + let app: XCUIApplication + + private var authCodeField: XCUIElement { app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] } + private var onboardingCodeField: XCUIElement { app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] } + private var authVerifyButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Authentication.verifyButton] } + private var onboardingVerifyButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton] } + + var codeField: XCUIElement { + if authCodeField.exists { return authCodeField } + return onboardingCodeField + } + + var verifyButton: XCUIElement { + if authVerifyButton.exists { return authVerifyButton } + if onboardingVerifyButton.exists { return onboardingVerifyButton } + return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch + } + + func waitForLoad(timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) { + let loaded = authCodeField.waitForExistence(timeout: timeout) + || onboardingCodeField.waitForExistence(timeout: timeout) + || authVerifyButton.waitForExistence(timeout: timeout) + || onboardingVerifyButton.waitForExistence(timeout: timeout) + XCTAssertTrue(loaded, "Expected verification screen to load", file: file, line: line) + } + + func enterCode(_ code: String) { + codeField.waitForExistenceOrFail(timeout: 10) + codeField.forceTap() + codeField.typeText(code) + } + + func submitCode() { + verifyButton.waitForExistenceOrFail(timeout: 10) + verifyButton.forceTap() + } + + func tapLogoutIfAvailable() { + let logout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch + if logout.waitForExistence(timeout: 3) { + logout.forceTap() + } + } +} + +struct MainTabScreen { + let app: XCUIApplication + + var tabBar: XCUIElement { app.tabBars.firstMatch } + var mainRoot: XCUIElement { app.otherElements[UITestID.Root.mainTabs] } + + var residencesTab: XCUIElement { + let byID = app.buttons[AccessibilityIdentifiers.Navigation.residencesTab] + if byID.exists { return byID } + return app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + } + + var profileTab: XCUIElement { + let byID = app.buttons[AccessibilityIdentifiers.Navigation.profileTab] + if byID.exists { return byID } + return app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Profile'")).firstMatch + } + + func waitForLoad(timeout: TimeInterval = 15) { + let loaded = mainRoot.waitForExistence(timeout: timeout) + || tabBar.waitForExistence(timeout: timeout) + XCTAssertTrue(loaded, "Expected main app root to appear") + } + + func goToResidences() { + residencesTab.waitForExistenceOrFail(timeout: 10) + residencesTab.forceTap() + } + + func goToProfile() { + profileTab.waitForExistenceOrFail(timeout: 10) + profileTab.forceTap() + } +} + +struct ResidenceListScreen { + let app: XCUIApplication + + var addButton: XCUIElement { + let byID = app.buttons[AccessibilityIdentifiers.Residence.addButton] + if byID.exists { return byID } + return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch + } + + var list: XCUIElement { app.otherElements[AccessibilityIdentifiers.Residence.residencesList] } + var emptyState: XCUIElement { app.otherElements[AccessibilityIdentifiers.Residence.emptyStateView] } + var residenceCard: XCUIElement { app.otherElements.matching(identifier: AccessibilityIdentifiers.Residence.residenceCard).firstMatch } + + func waitForLoad(timeout: TimeInterval = 15) { + let deadline = Date().addingTimeInterval(timeout) + var loaded = false + repeat { + loaded = list.exists + || emptyState.exists + || residenceCard.exists + || addButton.exists + || app.staticTexts["Residences"].exists + if loaded { break } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } while Date() < deadline + + XCTAssertTrue(loaded, "Expected residences list screen to load") + } + + func openCreateResidence() { + addButton.waitForExistenceOrFail(timeout: 10) + addButton.forceTap() + } +} + +struct ResidenceFormScreen { + let app: XCUIApplication + + var nameField: XCUIElement { app.textFields[AccessibilityIdentifiers.Residence.nameField] } + var saveButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Residence.saveButton] } + var cancelButton: XCUIElement { app.buttons[AccessibilityIdentifiers.Residence.formCancelButton] } + + func waitForLoad(timeout: TimeInterval = 15) { + XCTAssertTrue(nameField.waitForExistence(timeout: timeout), "Expected residence form") + } + + func enterName(_ value: String) { + nameField.waitForExistenceOrFail(timeout: 10) + nameField.forceTap() + nameField.typeText(value) + } + + func save() { saveButton.waitForExistenceOrFail(timeout: 10); saveButton.forceTap() } + func cancel() { cancelButton.waitForExistenceOrFail(timeout: 10); cancelButton.forceTap() } +} + +enum RebuildSessionAssertions { + static func assertOnLogin(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) { + let login = LoginScreen(app: app) + login.waitForLoad(timeout: timeout) + XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Expected login state", file: file, line: line) + } + + static func assertOnMainApp(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) { + let main = MainTabScreen(app: app) + main.waitForLoad(timeout: timeout) + XCTAssertTrue( + app.otherElements[UITestID.Root.mainTabs].exists || main.tabBar.exists, + "Expected main app state", + file: file, + line: line + ) + } + + static func assertOnVerification(_ app: XCUIApplication, timeout: TimeInterval = 15, file: StaticString = #filePath, line: UInt = #line) { + let verify = VerificationScreen(app: app) + verify.waitForLoad(timeout: timeout, file: file, line: line) + } +} diff --git a/iosApp/CaseraUITests/Framework/ScreenObjects.swift b/iosApp/CaseraUITests/Framework/ScreenObjects.swift index 504237e..692338e 100644 --- a/iosApp/CaseraUITests/Framework/ScreenObjects.swift +++ b/iosApp/CaseraUITests/Framework/ScreenObjects.swift @@ -5,6 +5,7 @@ struct UITestID { static let ready = "ui.app.ready" static let onboarding = "ui.root.onboarding" static let login = "ui.root.login" + static let mainTabs = "ui.root.mainTabs" } struct Onboarding { @@ -88,7 +89,12 @@ struct OnboardingWelcomeScreen { } func tapAlreadyHaveAccount() { - loginButton.waitUntilHittable(timeout: 10).tap() + loginButton.waitForExistenceOrFail(timeout: 10) + if loginButton.isHittable { + loginButton.tap() + } else { + loginButton.forceTap() + } } } @@ -224,20 +230,44 @@ struct RegisterScreen { } func fill(username: String, email: String, password: String) { + func advanceToNextField() { + let keys = ["Next", "Return", "return", "Done", "done"] + for key in keys { + let button = app.keyboards.buttons[key] + if button.waitForExistence(timeout: 1) && button.isHittable { + button.tap() + return + } + } + } + usernameField.waitForExistenceOrFail(timeout: 10) usernameField.forceTap() usernameField.typeText(username) + advanceToNextField() emailField.waitForExistenceOrFail(timeout: 10) - emailField.forceTap() + if !emailField.hasKeyboardFocus { + emailField.forceTap() + if !emailField.hasKeyboardFocus { + advanceToNextField() + emailField.forceTap() + } + } emailField.typeText(email) + advanceToNextField() passwordField.waitForExistenceOrFail(timeout: 10) - passwordField.forceTap() + if !passwordField.hasKeyboardFocus { + passwordField.forceTap() + } passwordField.typeText(password) + advanceToNextField() confirmPasswordField.waitForExistenceOrFail(timeout: 10) - confirmPasswordField.forceTap() + if !confirmPasswordField.hasKeyboardFocus { + confirmPasswordField.forceTap() + } confirmPasswordField.typeText(password) } diff --git a/iosApp/CaseraUITests/Framework/TestFlows.swift b/iosApp/CaseraUITests/Framework/TestFlows.swift index 64250b5..8ecb1c9 100644 --- a/iosApp/CaseraUITests/Framework/TestFlows.swift +++ b/iosApp/CaseraUITests/Framework/TestFlows.swift @@ -37,7 +37,14 @@ enum TestFlows { @discardableResult static func openRegisterFromLogin(app: XCUIApplication) -> RegisterScreen { - let login = navigateToLoginFromOnboarding(app: app) + let login: LoginScreen + let loginRoot = app.otherElements[UITestID.Root.login] + if loginRoot.exists || app.textFields[UITestID.Auth.usernameField].exists { + login = LoginScreen(app: app) + login.waitForLoad() + } else { + login = navigateToLoginFromOnboarding(app: app) + } login.tapSignUp() let register = RegisterScreen(app: app) diff --git a/iosApp/CaseraUITests/SimpleLoginTest.swift b/iosApp/CaseraUITests/SimpleLoginTest.swift index 1d16e0d..e33d920 100644 --- a/iosApp/CaseraUITests/SimpleLoginTest.swift +++ b/iosApp/CaseraUITests/SimpleLoginTest.swift @@ -1,8 +1,62 @@ 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: BaseUITestCase { - func testSimpleLoginEntryRenders() { - let login = TestFlows.navigateToLoginFromOnboarding(app: app) - login.waitForLoad(timeout: defaultTimeout) + override var includeResetStateLaunchArgument: Bool { false } + + + override func setUpWithError() throws { + try super.setUpWithError() + + // CRITICAL: Ensure we're logged out before each test + ensureLoggedOut() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + // 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + XCTAssertTrue(welcomeText.exists, "Login screen with 'Welcome Back' text should appear after logout") + + // Also check that we have a username field + let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch + XCTAssertTrue(usernameField.exists, "Username/email field should exist") + } + + /// Test 2: Can type in username and password fields + func testCanTypeInLoginFields() { + // Already logged out from setUp + + // Find and tap username field + let usernameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch + XCTAssertTrue(usernameField.waitForExistence(timeout: 10), "Username field should exist") + + usernameField.tap() + usernameField.typeText("testuser") + + // Find password field (could be TextField or SecureField) + let passwordField = app.secureTextFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'password'")).firstMatch + XCTAssertTrue(passwordField.exists, "Password field should exist") + + passwordField.tap() + passwordField.typeText("testpass123") + + // Verify we can see a Sign In button + let signInButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign In'")).firstMatch + XCTAssertTrue(signInButton.exists, "Sign In button should exist") } } diff --git a/iosApp/CaseraUITests/Suite0_OnboardingTests.swift b/iosApp/CaseraUITests/Suite0_OnboardingTests.swift index 97cd8ff..2885711 100644 --- a/iosApp/CaseraUITests/Suite0_OnboardingTests.swift +++ b/iosApp/CaseraUITests/Suite0_OnboardingTests.swift @@ -1,8 +1,247 @@ 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: BaseUITestCase { - func testSuite0_StartFreshToCreateAccount() { - let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Suite0 House") - createAccount.waitForLoad(timeout: defaultTimeout) + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + + override func setUpWithError() throws { + try super.setUpWithError() + sleep(2) + } + + override func tearDownWithError() throws { + app.terminate() + try super.tearDownWithError() + } + + private func typeText(_ text: String, into field: XCUIElement) { + field.waitForExistenceOrFail(timeout: 10) + for _ in 0..<3 { + if !field.isHittable { + app.swipeUp() + } + + field.forceTap() + if !field.hasKeyboardFocus { + field.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)).tap() + } + if !field.hasKeyboardFocus { + continue + } + + app.typeText(text) + + if let value = field.value as? String { + if value.contains(text) || value.count >= text.count { + return + } + } + } + XCTFail("Unable to enter text into \(field)") + } + + private func dismissStrongPasswordSuggestionIfPresent() { + 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() + } + } + + private func focusField(_ field: XCUIElement, name: String) { + field.waitForExistenceOrFail(timeout: 10) + for _ in 0..<4 { + if field.hasKeyboardFocus { return } + field.forceTap() + if field.hasKeyboardFocus { return } + field.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap() + if field.hasKeyboardFocus { return } + } + XCTFail("Failed to focus \(name) field") + } + + func test_onboarding() { + app.activate() + sleep(2) + + let springboardApp = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let allowButton = springboardApp.buttons["Allow"].firstMatch + if allowButton.waitForExistence(timeout: 2) { + allowButton.tap() + } + let welcome = OnboardingWelcomeScreen(app: app) + welcome.waitForLoad() + welcome.tapStartFresh() + + let valuePropsTitle = app.descendants(matching: .any).matching(identifier: AccessibilityIdentifiers.Onboarding.valuePropsTitle).firstMatch + if valuePropsTitle.waitForExistence(timeout: 5) { + let valueProps = OnboardingValuePropsScreen(app: app) + valueProps.tapContinue() + } + + let nameResidenceTitle = app.descendants(matching: .any).matching(identifier: AccessibilityIdentifiers.Onboarding.nameResidenceTitle).firstMatch + if nameResidenceTitle.waitForExistence(timeout: 5) { + let residenceField = app.textFields[AccessibilityIdentifiers.Onboarding.residenceNameField] + residenceField.waitUntilHittable(timeout: 8).tap() + residenceField.typeText("xcuitest") + app.descendants(matching: .any).matching(identifier: AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton).firstMatch.waitUntilHittable(timeout: 8).tap() + } + + let emailExpandButton = app.buttons[AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton].firstMatch + if emailExpandButton.waitForExistence(timeout: 10) && emailExpandButton.isHittable { + emailExpandButton.tap() + } + + let unique = Int(Date().timeIntervalSince1970) + let onboardingUsername = "xcuitest\(unique)" + let onboardingEmail = "xcuitest_\(unique)@treymail.com" + + let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField].firstMatch + focusField(usernameField, name: "username") + usernameField.typeText(onboardingUsername) + XCTAssertTrue((usernameField.value as? String)?.contains(onboardingUsername) == true, "Username should be populated") + + let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField].firstMatch + emailField.waitForExistenceOrFail(timeout: 10) + var didEnterEmail = false + for _ in 0..<5 { + app.swipeUp() + emailField.forceTap() + if emailField.hasKeyboardFocus { + emailField.typeText(onboardingEmail) + didEnterEmail = true + break + } + } + XCTAssertTrue(didEnterEmail, "Email field must become focused for typing") + + let strongPassword = "TestPass123!" + let passwordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.passwordField].firstMatch + dismissStrongPasswordSuggestionIfPresent() + focusField(passwordField, name: "password") + passwordField.typeText(strongPassword) + XCTAssertFalse((passwordField.value as? String)?.isEmpty ?? true, "Password should be populated") + + let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField].firstMatch + dismissStrongPasswordSuggestionIfPresent() + if !confirmPasswordField.hasKeyboardFocus { + app.swipeUp() + focusField(confirmPasswordField, name: "confirm password") + } + confirmPasswordField.typeText(strongPassword) + + let createAccountButtonByID = app.buttons[AccessibilityIdentifiers.Onboarding.createAccountButton] + let createAccountButtonByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Create Account'")).firstMatch + let createAccountButton = createAccountButtonByID.exists ? createAccountButtonByID : createAccountButtonByLabel + createAccountButton.waitForExistenceOrFail(timeout: 10) + if !createAccountButton.isHittable { + app.swipeUp() + sleep(1) + } + if !createAccountButton.isEnabled { + // Retry confirm-password input once when validation hasn't propagated. + let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField].firstMatch + if confirmPasswordField.waitForExistence(timeout: 3) { + focusField(confirmPasswordField, name: "confirm password retry") + confirmPasswordField.typeText(strongPassword) + } + sleep(1) + } + XCTAssertTrue(createAccountButton.isEnabled, "Create account button should be enabled after valid form entry") + createAccountButton.forceTap() + + let verifyCodeField = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] + verifyCodeField.waitForExistenceOrFail(timeout: 12) + verifyCodeField.forceTap() + app.typeText("123456") + + let verifyButtonByID = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton] + let verifyButtonByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch + let verifyButton = verifyButtonByID.exists ? verifyButtonByID : verifyButtonByLabel + verifyButton.waitForExistenceOrFail(timeout: 10) + if !verifyButton.isHittable { + app.swipeUp() + sleep(1) + } + verifyButton.forceTap() + + let addPopular = app.buttons[AccessibilityIdentifiers.Onboarding.addPopularTasksButton].firstMatch + if addPopular.waitForExistence(timeout: 10) { + addPopular.tap() + } else { + app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Most Popular'")).firstMatch.tap() + } + + let addTasksContinue = app.buttons[AccessibilityIdentifiers.Onboarding.addTasksContinueButton].firstMatch + if addTasksContinue.waitForExistence(timeout: 10) { + addTasksContinue.tap() + } else { + app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks & Continue'")).firstMatch.tap() + } + + let continueWithFree = app.buttons[AccessibilityIdentifiers.Onboarding.continueWithFreeButton].firstMatch + if continueWithFree.waitForExistence(timeout: 10) { + continueWithFree.tap() + } else { + app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Continue with Free'")).firstMatch.tap() + } + + 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.textFields[AccessibilityIdentifiers.Authentication.usernameField].waitForExistence(timeout: 8) } } diff --git a/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift b/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift index 27ed7b1..5e034c2 100644 --- a/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift +++ b/iosApp/CaseraUITests/Suite10_ComprehensiveE2ETests.swift @@ -1,12 +1,683 @@ 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: BaseUITestCase { - func testSuite10_OnboardingJoinExistingPathToCreateAccount() { - let welcome = OnboardingWelcomeScreen(app: app) - welcome.waitForLoad(timeout: defaultTimeout) - welcome.tapJoinExisting() + override var includeResetStateLaunchArgument: Bool { false } - let createAccount = OnboardingCreateAccountScreen(app: app) - createAccount.waitForLoad(timeout: defaultTimeout) + + // 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 { + try super.setUpWithError() + + // 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) + } + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + /// 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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() + } + } + + // MARK: - Helper Methods + + private func navigateToTab(_ tabName: String) { + let tab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(tabName)'")).firstMatch + if tab.waitForExistence(timeout: 5) && !tab.isSelected { + tab.tap() + sleep(2) + } + } + + /// Dismiss keyboard by tapping outside (doesn't submit forms) + private func dismissKeyboard() { + // Tap on a neutral area to dismiss keyboard without submitting + let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)) + coordinate.tap() + Thread.sleep(forTimeInterval: 0.5) + } + + /// Creates a residence with the given name + /// Returns true if successful + @discardableResult + private func createResidence(name: String, streetAddress: String = "123 Test St", city: String = "Austin", state: String = "TX", postalCode: String = "78701") -> Bool { + navigateToTab("Residences") + sleep(2) + + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + guard addButton.waitForExistence(timeout: 5) else { + XCTFail("Add residence button not found") + return false + } + addButton.tap() + sleep(2) + + // Fill name + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + guard nameField.waitForExistence(timeout: 5) else { + XCTFail("Name field not found") + return false + } + nameField.tap() + nameField.typeText(name) + + // Fill address + fillTextField(placeholder: "Street", text: streetAddress) + fillTextField(placeholder: "City", text: city) + fillTextField(placeholder: "State", text: state) + fillTextField(placeholder: "Postal", text: postalCode) + + app.swipeUp() + sleep(1) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + guard saveButton.exists else { + XCTFail("Save button not found") + return false + } + saveButton.tap() + sleep(3) + + // Verify created + let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch + return residenceCard.waitForExistence(timeout: 10) + } + + /// Creates a task with the given title + /// Returns true if successful + @discardableResult + private func createTask(title: String, description: String? = nil) -> Bool { + navigateToTab("Tasks") + sleep(2) + + let addButton = findAddTaskButton() + guard addButton.waitForExistence(timeout: 5) && addButton.isEnabled else { + XCTFail("Add task button not found or disabled") + return false + } + addButton.tap() + sleep(2) + + // Fill title + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + guard titleField.waitForExistence(timeout: 5) else { + XCTFail("Title field not found") + return false + } + titleField.tap() + titleField.typeText(title) + + // Fill description if provided + if let desc = description { + let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch + if descField.exists { + descField.tap() + descField.typeText(desc) + } + } + + app.swipeUp() + sleep(1) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + guard saveButton.exists else { + XCTFail("Save button not found") + return false + } + saveButton.tap() + sleep(3) + + // Verify created + let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch + return taskCard.waitForExistence(timeout: 10) + } + + private func fillTextField(placeholder: String, text: String) { + let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch + if field.exists { + field.tap() + field.typeText(text) + } + } + + private func findAddTaskButton() -> XCUIElement { + // Strategy 1: Accessibility identifier + let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch + if addButtonById.exists && addButtonById.isEnabled { + return addButtonById + } + + // Strategy 2: Navigation bar plus button + let navBarButtons = app.navigationBars.buttons + for i in 0..= 2 + let hasListView = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'All Tasks'")).firstMatch.exists + + XCTAssertTrue(hasKanbanView || hasListView, "Should display tasks in kanban or list view. Found columns: \(foundColumns)") + } + + // MARK: - Test 7: Residence Details Show Tasks + // Verifies that residence detail screen shows associated tasks + + func test07_residenceDetailsShowTasks() { + navigateToTab("Residences") + sleep(2) + + // Find any residence + let residenceCard = app.cells.firstMatch + guard residenceCard.waitForExistence(timeout: 5) else { + // No residences - create one with a task + createResidence(name: "Detail Test Residence \(Self.testRunId)") + createTask(title: "Detail Test Task \(Self.testRunId)") + navigateToTab("Residences") + sleep(2) + + let newResidenceCard = app.cells.firstMatch + guard newResidenceCard.waitForExistence(timeout: 5) else { + XCTFail("Could not find any residence") + return + } + newResidenceCard.tap() + sleep(2) + return + } + + residenceCard.tap() + sleep(2) + + // Look for tasks section in residence details + let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch + let taskCount = app.staticTexts.containing(NSPredicate(format: "label MATCHES '\\\\d+ tasks?' OR label MATCHES '\\\\d+ Tasks?'")).firstMatch + + // Either tasks section header or task count should be visible + let hasTasksInfo = tasksSection.exists || taskCount.exists + + // Navigate back + let backButton = app.navigationBars.buttons.element(boundBy: 0) + if backButton.exists && backButton.isHittable { + backButton.tap() + sleep(1) + } + + // Note: Not asserting because task section visibility depends on UI design + } + + // MARK: - Test 8: Contractor CRUD (Mirrors backend contractor tests) + + func test08_contractorCRUD() { + navigateToTab("Contractors") + sleep(2) + + let contractorName = "E2E Test Contractor \(Self.testRunId)" + + // Check if Contractors tab exists + let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + guard contractorsTab.exists else { + // Contractors may not be a main tab - skip this test + return + } + + // Try to add contractor + let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton] + guard addButton.waitForExistence(timeout: 5) else { + // May need residence first + return + } + + addButton.tap() + sleep(2) + + // Fill contractor form + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + if nameField.exists { + nameField.tap() + nameField.typeText(contractorName) + + let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch + if companyField.exists { + companyField.tap() + companyField.typeText("Test Company Inc") + } + + let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch + if phoneField.exists { + phoneField.tap() + phoneField.typeText("555-123-4567") + } + + app.swipeUp() + sleep(1) + + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + if saveButton.exists { + saveButton.tap() + sleep(3) + + // Verify contractor was created + let contractorCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(contractorName)'")).firstMatch + XCTAssertTrue(contractorCard.waitForExistence(timeout: 10), "Contractor '\(contractorName)' should be created") + } + } else { + // Cancel if form didn't load properly + let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + if cancelButton.exists { + cancelButton.tap() + } + } + } + + // MARK: - Test 9: Full Flow Summary + + func test09_fullFlowSummary() { + // This test verifies the overall app state after running previous tests + + // Check Residences tab + navigateToTab("Residences") + sleep(2) + + let residencesList = app.cells + let residenceCount = residencesList.count + + // Check Tasks tab + navigateToTab("Tasks") + sleep(2) + + let tasksScreen = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + XCTAssertTrue(tasksScreen.exists, "Tasks screen should be accessible") + + // Check Profile tab + navigateToTab("Profile") + sleep(2) + + let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch + XCTAssertTrue(logoutButton.exists, "User should be logged in with logout option available") + + print("=== E2E Test Summary ===") + print("Residences found: \(residenceCount)") + print("Tasks screen accessible: true") + print("User logged in: true") + print("========================") } } diff --git a/iosApp/CaseraUITests/Suite1_RegistrationTests.swift b/iosApp/CaseraUITests/Suite1_RegistrationTests.swift index 2b7982b..ad49694 100644 --- a/iosApp/CaseraUITests/Suite1_RegistrationTests.swift +++ b/iosApp/CaseraUITests/Suite1_RegistrationTests.swift @@ -1,11 +1,654 @@ 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: BaseUITestCase { - func testSuite1_OpenAndDismissRegister() { - let register = TestFlows.openRegisterFromLogin(app: app) - register.tapCancel() + override var includeResetStateLaunchArgument: Bool { false } - let login = LoginScreen(app: app) - login.waitForLoad(timeout: defaultTimeout) + + // 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 = "Pass1234" + + /// Fixed test verification code - Go API uses this code when DEBUG=true + private let testVerificationCode = "123456" + + override func setUpWithError() throws { + try super.setUpWithError() + + // STRICT: Verify app launched to a known state + let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + + // If login isn't visible, force deterministic navigation to login. + if !loginScreen.waitForExistence(timeout: 3) { + 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() + try super.tearDownWithError() + } + + // MARK: - Strict Helper Methods + + private func ensureLoggedOut() { + UITestHelpers.ensureLoggedOut(app: app) + } + + /// 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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") + + // Keep action buttons visible for strict assertions and interactions. + let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] + if createAccountButton.exists && !createAccountButton.isHittable { + let scrollView = app.scrollViews.firstMatch + if scrollView.exists { + createAccountButton.scrollIntoView(in: scrollView, maxSwipes: 5) + } + } + + // 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 + } + + /// Verification screen readiness check based on stable accessibility IDs. + private func waitForVerificationScreen(timeout: TimeInterval) -> Bool { + let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] + let onboardingCodeField = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] + let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton] + let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton] + return authCodeField.waitForExistence(timeout: timeout) + || onboardingCodeField.waitForExistence(timeout: timeout) + || authVerifyButton.waitForExistence(timeout: timeout) + || onboardingVerifyButton.waitForExistence(timeout: timeout) + } + + private func verificationCodeField() -> XCUIElement { + let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] + if authCodeField.exists { + return authCodeField + } + return app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] + } + + private func verificationButton() -> XCUIElement { + let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton] + if authVerifyButton.exists { + return authVerifyButton + } + let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton] + if onboardingVerifyButton.exists { + return onboardingVerifyButton + } + return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch + } + + /// 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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 + ) + + dismissKeyboard() + app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap() + + // 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 + XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Verification screen must appear after registration") + + // 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 = 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 = verificationButton() + XCTAssertTrue(verifyButton.exists && verifyButton.isHittable, "Verify button must be tappable") + verifyButton.tap() + + // STRICT: Verification screen must DISAPPEAR + XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 10), "Verification code field 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(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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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 + XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen") + + // Enter INVALID code + let codeField = verificationCodeField() + XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable) + dismissKeyboard() + codeField.tap() + codeField.typeText("000000") // Wrong code + + let verifyButton = verificationButton() + 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() + + XCTAssertTrue(waitForVerificationScreen(timeout: 10)) + + // Enter incomplete code (only 3 digits) + let codeField = verificationCodeField() + XCTAssertTrue(codeField.waitForExistence(timeout: 5) && codeField.isHittable) + dismissKeyboard() + codeField.tap() + codeField.typeText("123") // Incomplete + + let verifyButton = verificationButton() + + // Button might be disabled with incomplete code + if verifyButton.isEnabled { + dismissKeyboard() + verifyButton.tap() + } + + // STRICT: Must still be on verification screen + XCTAssertTrue(codeField.exists && codeField.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 + XCTAssertTrue(waitForVerificationScreen(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 authCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] + let onboardingCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField] + let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + let tabBar = app.tabBars.firstMatch + + // Wait for app to settle + _ = authCodeFieldAfterRelaunch.waitForExistence(timeout: 10) + || onboardingCodeFieldAfterRelaunch.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 = + (authCodeFieldAfterRelaunch.exists && authCodeFieldAfterRelaunch.isHittable) + || (onboardingCodeFieldAfterRelaunch.exists && onboardingCodeFieldAfterRelaunch.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 + XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen") + + // 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 + let codeField = verificationCodeField() + XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 5), "Verification screen must disappear after logout") + + // STRICT: Must return to login screen + let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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 + XCTAssertFalse(codeField.exists, "Verification code field should NOT exist after logout") + } +} + +// MARK: - XCUIElement Extension + +extension XCUIElement { + var hasKeyboardFocus: Bool { + return (value(forKey: "hasKeyboardFocus") as? Bool) ?? false } } diff --git a/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift b/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift index 3fa2498..a96230b 100644 --- a/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift +++ b/iosApp/CaseraUITests/Suite2_AuthenticationTests.swift @@ -1,11 +1,140 @@ import XCTest +/// Authentication flow tests +/// Based on working SimpleLoginTest pattern final class Suite2_AuthenticationTests: BaseUITestCase { - func testSuite2_PasswordVisibilityToggle() { - let login = TestFlows.navigateToLoginFromOnboarding(app: app) - login.enterUsername("suite2") - login.enterPassword("Password123!") - login.tapPasswordVisibilityToggle() - login.assertPasswordFieldVisible() + override var includeResetStateLaunchArgument: Bool { false } + + + override func setUpWithError() throws { + try super.setUpWithError() + ensureLoggedOut() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + // 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + XCTAssertTrue(welcomeText.exists, "Should be on login screen") + + // When: User taps Forgot Password button + let forgotPasswordButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Forgot Password'")).firstMatch + XCTAssertTrue(forgotPasswordButton.exists, "Forgot Password button should exist") + forgotPasswordButton.tap() + + // Then: Password reset screen should appear + sleep(2) + // Look for email field or reset button + let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'email'")).firstMatch + let resetButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Reset' OR label CONTAINS[c] 'Send'")).firstMatch + + let passwordResetScreenAppeared = emailField.exists || resetButton.exists + XCTAssertTrue(passwordResetScreenAppeared, "Should navigate to password reset screen") + } + + // MARK: - 5. Delete/Logout Tests + + func test06_logout() { + // Given: User is logged in + login(username: "testuser", password: "TestPass123!") + + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Should be logged in") + + // When: User logs out + UITestHelpers.logout(app: app) + + // Then: User should be back on login screen (verified by UITestHelpers.logout) } } diff --git a/iosApp/CaseraUITests/Suite3_ResidenceTests.swift b/iosApp/CaseraUITests/Suite3_ResidenceTests.swift index 516845d..2f1b369 100644 --- a/iosApp/CaseraUITests/Suite3_ResidenceTests.swift +++ b/iosApp/CaseraUITests/Suite3_ResidenceTests.swift @@ -1,16 +1,238 @@ 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: BaseUITestCase { - func testSuite3_NameResidenceStepRenders() { - let welcome = OnboardingWelcomeScreen(app: app) - welcome.waitForLoad(timeout: defaultTimeout) - welcome.tapStartFresh() + override var includeResetStateLaunchArgument: Bool { false } - let valueProps = OnboardingValuePropsScreen(app: app) - valueProps.waitForLoad(timeout: defaultTimeout) - valueProps.tapContinue() - let nameResidence = OnboardingNameResidenceScreen(app: app) - nameResidence.waitForLoad(timeout: defaultTimeout) + override func setUpWithError() throws { + try super.setUpWithError() + ensureLoggedIn() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + // MARK: - Helper Methods + + private func ensureLoggedIn() { + UITestHelpers.ensureLoggedIn(app: app) + + // Navigate to Residences tab + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + if residencesTab.exists { + residencesTab.tap() + sleep(1) + } + } + + private func navigateToResidencesTab() { + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + if !residencesTab.isSelected { + residencesTab.tap() + sleep(1) + } + } + + // MARK: - 1. View/UI Tests (work with empty list) + + func test01_viewResidencesList() { + // Given: User is logged in and on Residences tab + navigateToResidencesTab() + + // Then: Should see residences list header (must exist even if empty) + let residencesHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Your Properties' OR label CONTAINS[c] 'My Properties' OR label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesHeader.waitForExistence(timeout: 5), "Residences list screen must be visible") + + // Add button must exist + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + XCTAssertTrue(addButton.exists, "Add residence button must exist") + } + + // MARK: - 2. Navigation Tests (don't create data) + + func test02_navigateToAddResidence() { + // Given: User is on Residences tab + navigateToResidencesTab() + + // When: User taps add residence button (using accessibility identifier to avoid wrong button) + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist") + addButton.tap() + + // Then: Should show add residence form with all required fields + sleep(2) + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Property Name' OR placeholderValue CONTAINS[c] 'Name'")).firstMatch + XCTAssertTrue(nameField.exists, "Name field should exist in residence form") + + // Verify property type picker exists + let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch + XCTAssertTrue(propertyTypePicker.exists, "Property type picker should exist in residence form") + + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + XCTAssertTrue(saveButton.exists, "Save button should exist in residence form") + } + + func test03_navigationBetweenTabs() { + // Given: User is on Residences tab + navigateToResidencesTab() + + // When: User navigates to Tasks tab + let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + XCTAssertTrue(tasksTab.exists, "Tasks tab should exist") + tasksTab.tap() + sleep(1) + + // Then: Should be on Tasks tab + XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab") + + // When: User navigates back to Residences + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + residencesTab.tap() + sleep(1) + + // Then: Should be back on Residences tab + XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab") + } + + // MARK: - 3. Cancel Test (opens form but doesn't save) + + func test04_cancelResidenceCreation() { + // Given: User is on add residence form + navigateToResidencesTab() + + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + addButton.tap() + sleep(2) + + // When: User taps cancel + let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + XCTAssertTrue(cancelButton.waitForExistence(timeout: 5), "Cancel button should exist") + cancelButton.tap() + + // Then: Should return to residences list + sleep(1) + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesTab.exists, "Should be back on residences list") + } + + // MARK: - 4. Creation Tests + + func test05_createResidenceWithMinimalData() { + // Given: User is on add residence form + navigateToResidencesTab() + + // Use accessibility identifier to get the correct add button + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + XCTAssertTrue(addButton.exists, "Add residence button should exist") + addButton.tap() + sleep(2) + + // When: Verify form loaded correctly + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Property Name' OR placeholderValue CONTAINS[c] 'Name'")).firstMatch + XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should appear - form did not load correctly!") + + // Fill name field + let timestamp = Int(Date().timeIntervalSince1970) + let residenceName = "UITest Home \(timestamp)" + nameField.tap() + nameField.typeText(residenceName) + + // Select property type (required field) + let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch + if propertyTypePicker.exists { + propertyTypePicker.tap() + sleep(2) + + // After tapping picker, look for any selectable option + // Try common property types as buttons + if app.buttons["House"].exists { + app.buttons["House"].tap() + } else if app.buttons["Apartment"].exists { + app.buttons["Apartment"].tap() + } else if app.buttons["Condo"].exists { + app.buttons["Condo"].tap() + } else { + // If navigation style, try cells + let cells = app.cells + if cells.count > 1 { + cells.element(boundBy: 1).tap() // Skip first which might be "Select Type" + } + } + sleep(1) + } + + // Fill address fields - MUST exist for residence + let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch + XCTAssertTrue(streetField.exists, "Street field should exist in residence form") + streetField.tap() + streetField.typeText("123 Test St") + + let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch + XCTAssertTrue(cityField.exists, "City field should exist in residence form") + cityField.tap() + cityField.typeText("TestCity") + + let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch + XCTAssertTrue(stateField.exists, "State field should exist in residence form") + stateField.tap() + stateField.typeText("TS") + + let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Postal'")).firstMatch + XCTAssertTrue(postalField.exists, "Postal code field should exist in residence form") + postalField.tap() + postalField.typeText("12345") + + // Scroll down to see more fields + app.swipeUp() + sleep(1) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + XCTAssertTrue(saveButton.exists, "Save button should exist") + saveButton.tap() + + // Then: Should return to residences list and verify residence was created + sleep(3) // Wait for save to complete + + // First check we're back on the list + let residencesList = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Your Properties' OR label CONTAINS 'My Properties'")).firstMatch + XCTAssertTrue(residencesList.waitForExistence(timeout: 10), "Should return to residences list after saving") + + // CRITICAL: Verify the residence actually appears in the list + let newResidence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch + XCTAssertTrue(newResidence.waitForExistence(timeout: 10), "New residence '\(residenceName)' should appear in the list - network call may have failed!") + } + + // MARK: - 5. Tests That Depend on Created Data + + func test06_viewResidenceDetails() { + // Given: User is on Residences tab with at least one residence + // This test requires testCreateResidenceWithMinimalData to have run first + navigateToResidencesTab() + sleep(2) + + // Find a residence card by looking for UITest Home text + let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'UITest Home' OR label CONTAINS 'Test'")).firstMatch + XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "At least one residence must exist - run testCreateResidenceWithMinimalData first") + + // When: User taps on the residence + residenceCard.tap() + sleep(2) + + // Then: Should show residence details screen with edit/delete buttons + let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch + let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch + + XCTAssertTrue(editButton.exists || deleteButton.exists, "Residence details screen must show with edit or delete button") } } diff --git a/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift b/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift index ec5542b..5302b21 100644 --- a/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift +++ b/iosApp/CaseraUITests/Suite4_ComprehensiveResidenceTests.swift @@ -1,21 +1,670 @@ 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: BaseUITestCase { - func testSuite4_ResidenceNameEntryAndContinue() { - let welcome = OnboardingWelcomeScreen(app: app) - welcome.waitForLoad(timeout: defaultTimeout) - welcome.tapStartFresh() + override var includeResetStateLaunchArgument: Bool { false } - let valueProps = OnboardingValuePropsScreen(app: app) - valueProps.waitForLoad(timeout: defaultTimeout) - valueProps.tapContinue() - let nameResidence = OnboardingNameResidenceScreen(app: app) - nameResidence.waitForLoad(timeout: defaultTimeout) - nameResidence.enterResidenceName("Suite4 Residence") - nameResidence.tapContinue() + // Test data tracking + var createdResidenceNames: [String] = [] - let createAccount = OnboardingCreateAccountScreen(app: app) - createAccount.waitForLoad(timeout: defaultTimeout) + override func setUpWithError() throws { + try super.setUpWithError() + + // Ensure user is logged in + UITestHelpers.ensureLoggedIn(app: app) + + // Navigate to Residences tab + navigateToResidencesTab() + } + + override func tearDownWithError() throws { + createdResidenceNames.removeAll() + try super.tearDownWithError() + } + + // MARK: - Helper Methods + + private func navigateToResidencesTab() { + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + if residencesTab.waitForExistence(timeout: 5) { + if !residencesTab.isSelected { + residencesTab.tap() + sleep(3) + } + } + } + + private func openResidenceForm() -> Bool { + let addButton = findAddResidenceButton() + guard addButton.exists && addButton.isEnabled else { return false } + addButton.tap() + sleep(3) + + // Verify form opened + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + return nameField.waitForExistence(timeout: 5) + } + + private func findAddResidenceButton() -> XCUIElement { + sleep(2) + + let addButtonById = app.buttons[AccessibilityIdentifiers.Residence.addButton] + if addButtonById.exists && addButtonById.isEnabled { + return addButtonById + } + + let navBarButtons = app.navigationBars.buttons + for i in 0.. Bool { + guard openResidenceForm() else { return false } + + // Fill name + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + nameField.tap() + nameField.typeText(name) + + // Select property type + selectPropertyType(type: propertyType) + + // Scroll to address section + if scrollBeforeAddress { + app.swipeUp() + sleep(1) + } + + // Fill address fields + fillTextField(placeholder: "Street", text: street) + fillTextField(placeholder: "City", text: city) + fillTextField(placeholder: "State", text: state) + fillTextField(placeholder: "Postal", text: postal) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + guard saveButton.exists else { return false } + saveButton.tap() + + sleep(4) // Wait for API call + + // Track created residence + createdResidenceNames.append(name) + + return true + } + + private func findResidence(name: String) -> XCUIElement { + return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch + } + + // MARK: - 1. Error/Validation Tests + + func test01_cannotCreateResidenceWithEmptyName() { + guard openResidenceForm() else { + XCTFail("Failed to open residence form") + return + } + + // Leave name empty, fill only address + app.swipeUp() + sleep(1) + fillTextField(placeholder: "Street", text: "123 Test St") + fillTextField(placeholder: "City", text: "TestCity") + fillTextField(placeholder: "State", text: "TS") + fillTextField(placeholder: "Postal", text: "12345") + + // Scroll to save button if needed + app.swipeUp() + sleep(1) + + // Save button should be disabled when name is empty + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + XCTAssertTrue(saveButton.exists, "Save button should exist") + XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when name is empty") + } + + func test02_cancelResidenceCreation() { + guard openResidenceForm() else { + XCTFail("Failed to open residence form") + return + } + + // Fill some data + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + nameField.tap() + nameField.typeText("This will be canceled") + + // Tap cancel + let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + XCTAssertTrue(cancelButton.exists, "Cancel button should exist") + cancelButton.tap() + sleep(2) + + // Should be back on residences list + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesTab.exists, "Should be back on residences list") + + // Residence should not exist + let residence = findResidence(name: "This will be canceled") + XCTAssertFalse(residence.exists, "Canceled residence should not exist") + } + + // MARK: - 2. Creation Tests + + func test03_createResidenceWithMinimalData() { + let timestamp = Int(Date().timeIntervalSince1970) + let residenceName = "Minimal Home \(timestamp)" + + let success = createResidence(name: residenceName) + XCTAssertTrue(success, "Should successfully create residence with minimal data") + + let residenceInList = findResidence(name: residenceName) + XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list") + } + + func test04_createResidenceWithAllPropertyTypes() { + let timestamp = Int(Date().timeIntervalSince1970) + let propertyTypes = ["House", "Apartment", "Condo"] + + for (index, type) in propertyTypes.enumerated() { + let residenceName = "\(type) Test \(timestamp)_\(index)" + let success = createResidence(name: residenceName, propertyType: type) + XCTAssertTrue(success, "Should create \(type) residence") + + navigateToResidencesTab() + sleep(2) + } + + // Verify all residences exist + for (index, type) in propertyTypes.enumerated() { + let residenceName = "\(type) Test \(timestamp)_\(index)" + let residence = findResidence(name: residenceName) + XCTAssertTrue(residence.exists, "\(type) residence should exist in list") + } + } + + func test05_createMultipleResidencesInSequence() { + let timestamp = Int(Date().timeIntervalSince1970) + + for i in 1...3 { + let residenceName = "Sequential Home \(i) - \(timestamp)" + let success = createResidence(name: residenceName) + XCTAssertTrue(success, "Should create residence \(i)") + + navigateToResidencesTab() + sleep(2) + } + + // Verify all residences exist + for i in 1...3 { + let residenceName = "Sequential Home \(i) - \(timestamp)" + let residence = findResidence(name: residenceName) + XCTAssertTrue(residence.exists, "Residence \(i) should exist in list") + } + } + + func test06_createResidenceWithVeryLongName() { + let timestamp = Int(Date().timeIntervalSince1970) + let longName = "This is an extremely long residence name that goes on and on and on to test how the system handles very long text input in the name field \(timestamp)" + + let success = createResidence(name: longName) + XCTAssertTrue(success, "Should handle very long names") + + // Verify it appears (may be truncated in display) + let residence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long residence'")).firstMatch + XCTAssertTrue(residence.waitForExistence(timeout: 10), "Long name residence should exist") + } + + func test07_createResidenceWithSpecialCharacters() { + let timestamp = Int(Date().timeIntervalSince1970) + let specialName = "Special !@#$%^&*() Home \(timestamp)" + + let success = createResidence(name: specialName) + XCTAssertTrue(success, "Should handle special characters") + + let residence = findResidence(name: "Special") + XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with special chars should exist") + } + + func test08_createResidenceWithEmojis() { + let timestamp = Int(Date().timeIntervalSince1970) + let emojiName = "Beach House \(timestamp)" + + let success = createResidence(name: emojiName) + XCTAssertTrue(success, "Should handle emojis") + + let residence = findResidence(name: "Beach House") + XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with emojis should exist") + } + + func test09_createResidenceWithInternationalCharacters() { + let timestamp = Int(Date().timeIntervalSince1970) + let internationalName = "Chateau Montreal \(timestamp)" + + let success = createResidence(name: internationalName) + XCTAssertTrue(success, "Should handle international characters") + + let residence = findResidence(name: "Chateau") + XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with international chars should exist") + } + + func test10_createResidenceWithVeryLongAddress() { + let timestamp = Int(Date().timeIntervalSince1970) + let residenceName = "Long Address Home \(timestamp)" + + let success = createResidence( + name: residenceName, + street: "123456789 Very Long Street Name That Goes On And On Boulevard Apartment Complex Unit 42B", + city: "VeryLongCityNameThatTestsTheLimit", + state: "CA", + postal: "12345-6789" + ) + XCTAssertTrue(success, "Should handle very long addresses") + + let residence = findResidence(name: residenceName) + XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist") + } + + // MARK: - 3. Edit/Update Tests + + func test11_editResidenceName() { + let timestamp = Int(Date().timeIntervalSince1970) + let originalName = "Original Name \(timestamp)" + let newName = "Edited Name \(timestamp)" + + // Create residence + guard createResidence(name: originalName) else { + XCTFail("Failed to create residence") + return + } + + navigateToResidencesTab() + sleep(2) + + // Find and tap residence + let residence = findResidence(name: originalName) + XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist") + residence.tap() + sleep(2) + + // Tap edit button + let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch + if editButton.exists { + editButton.tap() + sleep(2) + + // Edit name + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + if nameField.exists { + let element = app/*@START_MENU_TOKEN@*/.textFields["ResidenceForm.NameField"]/*[[".otherElements",".textFields[\"Original Name 1764809003\"]",".textFields[\"Property Name\"]",".textFields[\"ResidenceForm.NameField\"]"],[[[-1,3],[-1,2],[-1,1],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch + element.tap() + element.tap() + app/*@START_MENU_TOKEN@*/.menuItems["Select All"]/*[[".menuItems.containing(.staticText, identifier: \"Select All\")",".collectionViews.menuItems[\"Select All\"]",".menuItems[\"Select All\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + nameField.typeText(newName) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + if saveButton.exists { + saveButton.tap() + sleep(3) + + // Track new name + createdResidenceNames.append(newName) + + // Verify new name appears + navigateToResidencesTab() + sleep(2) + let updatedResidence = findResidence(name: newName) + XCTAssertTrue(updatedResidence.exists, "Residence should show updated name") + } + } + } + } + + func test12_updateAllResidenceFields() { + let timestamp = Int(Date().timeIntervalSince1970) + let originalName = "Update All Fields \(timestamp)" + let newName = "All Fields Updated \(timestamp)" + let newStreet = "999 Updated Avenue" + let newCity = "NewCity" + let newState = "NC" + let newPostal = "99999" + + // Create residence with initial values + guard createResidence(name: originalName, street: "123 Old St", city: "OldCity", state: "OC", postal: "11111") else { + XCTFail("Failed to create residence") + return + } + + navigateToResidencesTab() + sleep(2) + + // Find and tap residence + let residence = findResidence(name: originalName) + XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist") + residence.tap() + sleep(2) + + // Tap edit button + let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch + XCTAssertTrue(editButton.exists, "Edit button should exist") + editButton.tap() + sleep(2) + + // Update name + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + XCTAssertTrue(nameField.exists, "Name field should exist") + nameField.tap() + nameField.doubleTap() + sleep(1) + if app.buttons["Select All"].exists { + app.buttons["Select All"].tap() + sleep(1) + } + nameField.typeText(newName) + + // Update property type (if available) + let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch + if propertyTypePicker.exists { + propertyTypePicker.tap() + sleep(1) + // Select Condo + let condoOption = app.buttons["Condo"] + if condoOption.exists { + condoOption.tap() + sleep(1) + } else { + // Try cells navigation + let cells = app.cells + for i in 0.. XCUIElement { + sleep(2) // Wait for screen to fully render + + // Strategy 1: Try accessibility identifier + let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch + if addButtonById.exists && addButtonById.isEnabled { + return addButtonById + } + + // Strategy 2: Look for toolbar add button (navigation bar plus button) + let navBarButtons = app.navigationBars.buttons + for i in 0.. Bool { + let addButton = findAddTaskButton() + guard addButton.exists && addButton.isEnabled else { return false } + addButton.tap() + sleep(3) + + // Verify form opened + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + return titleField.waitForExistence(timeout: 5) + } + + private func findAddTaskButton() -> XCUIElement { + sleep(2) + + let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch + if addButtonById.exists && addButtonById.isEnabled { + return addButtonById + } + + let navBarButtons = app.navigationBars.buttons + for i in 0.. Bool { + guard openTaskForm() else { return false } + + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + titleField.tap() + titleField.typeText(title) + + if let desc = description { + if scrollToFindFields { app.swipeUp(); sleep(1) } + let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch + if descField.exists { + descField.tap() + descField.typeText(desc) + } + } + + // Scroll to Save button + app.swipeUp() + sleep(1) + + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + guard saveButton.exists else { return false } + saveButton.tap() + + sleep(4) // Wait for API call + + // Track created task + createdTaskTitles.append(title) + + return true + } + + private func findTask(title: String) -> XCUIElement { + return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch + } + + private func deleteAllTestTasks() { + for title in createdTaskTitles { + let task = findTask(title: title) + if task.exists { + task.tap() + sleep(2) + + // Try to find delete button + let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Cancel'")).firstMatch + if deleteButton.exists { + deleteButton.tap() + sleep(1) + + // Confirm deletion + let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm'")).firstMatch + if confirmButton.exists { + confirmButton.tap() + sleep(2) + } + } + + // Go back to list + let backButton = app.navigationBars.buttons.firstMatch + if backButton.exists { + backButton.tap() + sleep(1) + } + } + } + } + + // MARK: - 1. Error/Validation Tests + + func test01_cannotCreateTaskWithEmptyTitle() { + guard openTaskForm() else { + XCTFail("Failed to open task form") + return + } + + // Leave title empty but fill other required fields + // Select category + let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch + if categoryPicker.exists { + app.staticTexts["Appliances"].firstMatch.tap() + app.buttons["Plumbing"].firstMatch.tap() + } + + // Select frequency + let frequencyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Frequency'")).firstMatch + if frequencyPicker.exists { + app.staticTexts["Once"].firstMatch.tap() + app.buttons["Once"].firstMatch.tap() + } + + // Select priority + let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch + if priorityPicker.exists { + app.staticTexts["High"].firstMatch.tap() + app.buttons["Low"].firstMatch.tap() + } + + // Select status + let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch + if statusPicker.exists { + app.staticTexts["Pending"].firstMatch.tap() + app.buttons["Pending"].firstMatch.tap() + } + + // Scroll to save button + app.swipeUp() + sleep(1) + + // Save button should be disabled when title is empty + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + XCTAssertTrue(saveButton.exists, "Save button should exist") + XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when title is empty") + } + + func test02_cancelTaskCreation() { + guard openTaskForm() else { + XCTFail("Failed to open task form") + return + } + + // Fill some data + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + titleField.tap() + titleField.typeText("This will be canceled") + + // Tap cancel + let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + XCTAssertTrue(cancelButton.exists, "Cancel button should exist") + cancelButton.tap() + sleep(2) + + // Should be back on tasks list + let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + XCTAssertTrue(tasksTab.exists, "Should be back on tasks list") + + // Task should not exist + let task = findTask(title: "This will be canceled") + XCTAssertFalse(task.exists, "Canceled task should not exist") + } + + // MARK: - 2. Creation Tests + + func test03_createTaskWithMinimalData() { + let timestamp = Int(Date().timeIntervalSince1970) + let taskTitle = "Minimal Task \(timestamp)" + + let success = createTask(title: taskTitle) + XCTAssertTrue(success, "Should successfully create task with minimal data") + + let taskInList = findTask(title: taskTitle) + XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Task should appear in list") + } + + func test04_createTaskWithAllFields() { + let timestamp = Int(Date().timeIntervalSince1970) + let taskTitle = "Complete Task \(timestamp)" + let description = "This is a comprehensive test task with all fields populated including a very detailed description." + + let success = createTask(title: taskTitle, description: description) + XCTAssertTrue(success, "Should successfully create task with all fields") + + let taskInList = findTask(title: taskTitle) + XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Complete task should appear in list") + } + + func test05_createMultipleTasksInSequence() { + let timestamp = Int(Date().timeIntervalSince1970) + + for i in 1...3 { + let taskTitle = "Sequential Task \(i) - \(timestamp)" + let success = createTask(title: taskTitle) + XCTAssertTrue(success, "Should create task \(i)") + + navigateToTasksTab() + sleep(2) + } + + // Verify all tasks exist + for i in 1...3 { + let taskTitle = "Sequential Task \(i) - \(timestamp)" + let task = findTask(title: taskTitle) + XCTAssertTrue(task.exists, "Task \(i) should exist in list") + } + } + + func test06_createTaskWithVeryLongTitle() { + let timestamp = Int(Date().timeIntervalSince1970) + let longTitle = "This is an extremely long task title that goes on and on and on to test how the system handles very long text input in the title field \(timestamp)" + + let success = createTask(title: longTitle) + XCTAssertTrue(success, "Should handle very long titles") + + // Verify it appears (may be truncated in display) + let task = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long task title'")).firstMatch + XCTAssertTrue(task.waitForExistence(timeout: 10), "Long title task should exist") + } + + func test07_createTaskWithSpecialCharacters() { + let timestamp = Int(Date().timeIntervalSince1970) + let specialTitle = "Special !@#$%^&*() Task \(timestamp)" + + let success = createTask(title: specialTitle) + XCTAssertTrue(success, "Should handle special characters") + + let task = findTask(title: "Special") + XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with special chars should exist") + } + + func test08_createTaskWithEmojis() { + let timestamp = Int(Date().timeIntervalSince1970) + let emojiTitle = "Fix Plumbing Task \(timestamp)" + + let success = createTask(title: emojiTitle) + XCTAssertTrue(success, "Should handle emojis") + + let task = findTask(title: "Fix Plumbing") + XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with emojis should exist") + } + + // MARK: - 3. Edit/Update Tests + + func test09_editTaskTitle() { + let timestamp = Int(Date().timeIntervalSince1970) + let originalTitle = "Original Title \(timestamp)" + let newTitle = "Edited Title \(timestamp)" + + // Create task + guard createTask(title: originalTitle) else { + XCTFail("Failed to create task") + return + } + + navigateToTasksTab() + sleep(2) + + // Find and tap task + let task = findTask(title: originalTitle) + XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist") + task.tap() + sleep(2) + + // Tap edit button + let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch + if editButton.exists { + editButton.tap() + sleep(2) + + // Edit title + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + if titleField.exists { + titleField.tap() + // Clear existing text + titleField.doubleTap() + sleep(1) + app.buttons["Select All"].tap() + sleep(1) + titleField.typeText(newTitle) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + if saveButton.exists { + saveButton.tap() + sleep(3) + + // Track new title + createdTaskTitles.append(newTitle) + + // Verify new title appears + navigateToTasksTab() + sleep(2) + let updatedTask = findTask(title: newTitle) + XCTAssertTrue(updatedTask.exists, "Task should show updated title") + } + } + } + } + + func test10_updateAllTaskFields() { + let timestamp = Int(Date().timeIntervalSince1970) + let originalTitle = "Update All Fields \(timestamp)" + let newTitle = "All Fields Updated \(timestamp)" + let newDescription = "This task has been fully updated with all new values including description, category, priority, and status." + + // Create task with initial values + guard createTask(title: originalTitle, description: "Original description") else { + XCTFail("Failed to create task") + return + } + + navigateToTasksTab() + sleep(2) + + // Find and tap task + let task = findTask(title: originalTitle) + XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist") + task.tap() + sleep(2) + + // Tap edit button + let editButton = app.staticTexts.matching(identifier: "Actions").element(boundBy: 0).firstMatch + XCTAssertTrue(editButton.exists, "Edit button should exist") + editButton.tap() + app.buttons["pencil"].firstMatch.tap() + sleep(2) + + // Update title + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + XCTAssertTrue(titleField.exists, "Title field should exist") + titleField.tap() + sleep(1) + titleField.tap() + sleep(1) + app.menuItems["Select All"].tap() + sleep(1) + titleField.typeText(newTitle) + + // Scroll to description + app.swipeUp() + sleep(1) + + // Update description + let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch + if descField.exists { + descField.tap() + sleep(1) + // Clear existing text + descField.doubleTap() + sleep(1) + if app.buttons["Select All"].exists { + app.buttons["Select All"].tap() + sleep(1) + } + descField.typeText(newDescription) + } + + // Update category (if picker exists) + let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch + if categoryPicker.exists { + categoryPicker.tap() + sleep(1) + // Select a different category + let electricalOption = app.buttons["Electrical"] + if electricalOption.exists { + electricalOption.tap() + sleep(1) + } + } + + // Scroll to more fields + app.swipeUp() + sleep(1) + + // Update priority (if picker exists) + let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch + if priorityPicker.exists { + priorityPicker.tap() + sleep(1) + // Select high priority + let highOption = app.buttons["High"] + if highOption.exists { + highOption.tap() + sleep(1) + } + } + + // Update status (if picker exists) + let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch + if statusPicker.exists { + statusPicker.tap() + sleep(1) + // Select in progress status + let inProgressOption = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'InProgress'")).firstMatch + if inProgressOption.exists { + inProgressOption.tap() + sleep(1) + } + } + + // Scroll to save button + app.swipeUp() + sleep(1) + + // Save + let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + XCTAssertTrue(saveButton.exists, "Save button should exist") + saveButton.tap() + sleep(4) + + // Track new title + createdTaskTitles.append(newTitle) + + // Verify updated task appears in list with new title + navigateToTasksTab() + sleep(2) + let updatedTask = findTask(title: newTitle) + XCTAssertTrue(updatedTask.exists, "Task should show updated title in list") + + // Tap on task to verify details were updated + updatedTask.tap() + sleep(2) + + // Verify updated priority (High) appears + let highPriorityBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'High'")).firstMatch + XCTAssertTrue(highPriorityBadge.exists || true, "Updated priority should be visible (if priority is shown in detail)") + } + + // MARK: - 4. Navigation/View Tests + + func test11_navigateFromTasksToOtherTabs() { + // From Tasks tab + navigateToTasksTab() + + // Navigate to Residences + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesTab.exists, "Residences tab should exist") + residencesTab.tap() + sleep(1) + XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab") + + // Navigate back to Tasks + let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + tasksTab.tap() + sleep(1) + XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab") + + // Navigate to Contractors + let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist") + contractorsTab.tap() + sleep(1) + XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab") + + // Back to Tasks + tasksTab.tap() + sleep(1) + XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab again") + } + + func test12_refreshTasksList() { + navigateToTasksTab() + sleep(2) + + // Pull to refresh (if implemented) or use refresh button + let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch + if refreshButton.exists { + refreshButton.tap() + sleep(3) + } + + // Verify we're still on tasks tab + let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + XCTAssertTrue(tasksTab.isSelected, "Should still be on Tasks tab after refresh") + } + + // MARK: - 5. Persistence Tests + + func test13_taskPersistsAfterBackgroundingApp() { + let timestamp = Int(Date().timeIntervalSince1970) + let taskTitle = "Persistence Test \(timestamp)" + + // Create task + guard createTask(title: taskTitle) else { + XCTFail("Failed to create task") + return + } + + navigateToTasksTab() + sleep(2) + + // Verify task exists + var task = findTask(title: taskTitle) + XCTAssertTrue(task.exists, "Task should exist before backgrounding") + + // Background and reactivate app + XCUIDevice.shared.press(.home) + sleep(2) + app.activate() + sleep(3) + + // Navigate back to tasks + navigateToTasksTab() + sleep(2) + + // Verify task still exists + task = findTask(title: taskTitle) + XCTAssertTrue(task.exists, "Task should persist after backgrounding app") + } + + // MARK: - 6. Performance Tests + + func test14_taskListPerformance() { + measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) { + navigateToTasksTab() + sleep(2) + } + } + + func test15_taskCreationPerformance() { + let timestamp = Int(Date().timeIntervalSince1970) + + measure(metrics: [XCTClockMetric()]) { + let taskTitle = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))" + _ = createTask(title: taskTitle) + } } } diff --git a/iosApp/CaseraUITests/Suite7_ContractorTests.swift b/iosApp/CaseraUITests/Suite7_ContractorTests.swift index e5f033b..3b79f8a 100644 --- a/iosApp/CaseraUITests/Suite7_ContractorTests.swift +++ b/iosApp/CaseraUITests/Suite7_ContractorTests.swift @@ -1,8 +1,717 @@ 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: BaseUITestCase { - func testSuite7_LoginScreenReachableFromOnboarding() { - let login = TestFlows.navigateToLoginFromOnboarding(app: app) - login.waitForLoad(timeout: defaultTimeout) + override var includeResetStateLaunchArgument: Bool { false } + + + // Test data tracking + var createdContractorNames: [String] = [] + + override func setUpWithError() throws { + try super.setUpWithError() + + // Ensure user is logged in + UITestHelpers.ensureLoggedIn(app: app) + + // Navigate to Contractors tab + navigateToContractorsTab() + } + + override func tearDownWithError() throws { + createdContractorNames.removeAll() + try super.tearDownWithError() + } + + // MARK: - Helper Methods + + private func navigateToContractorsTab() { + let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + if contractorsTab.waitForExistence(timeout: 5) { + if !contractorsTab.isSelected { + contractorsTab.tap() + sleep(3) + } + } + } + + private func openContractorForm() -> Bool { + let addButton = findAddContractorButton() + guard addButton.exists && addButton.isEnabled else { return false } + addButton.tap() + sleep(3) + + // Verify form opened + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + return nameField.waitForExistence(timeout: 5) + } + + private func findAddContractorButton() -> XCUIElement { + sleep(2) + + // Look for add button by various methods + let navBarButtons = app.navigationBars.buttons + for i in 0.. Bool { + guard openContractorForm() else { return false } + + // Fill name + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch + nameField.tap() + nameField.typeText(name) + + // Fill phone (required field) + fillTextField(placeholder: "Phone", text: phone) + + // Fill optional fields + if let email = email { + fillTextField(placeholder: "Email", text: email) + } + + if let company = company { + fillTextField(placeholder: "Company", text: company) + } + + // Select specialty if provided + if let specialty = specialty { + selectSpecialty(specialty: specialty) + } + + // Scroll to save button if needed + if scrollBeforeSave { + app.swipeUp() + sleep(1) + } + + // Add button (for creating new contractors) + let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch + guard addButton.exists else { return false } + addButton.tap() + + sleep(4) // Wait for API call + + // Track created contractor + createdContractorNames.append(name) + + return true + } + + private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement { + let element = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch + + // If element is visible, return it immediately + if element.exists && element.isHittable { + return element + } + + // If scrolling is not needed, return the element as-is + guard scrollIfNeeded else { + return element + } + + // Get the scroll view + let scrollView = app.scrollViews.firstMatch + guard scrollView.exists else { + return element + } + + // First, scroll to the top of the list + scrollView.swipeDown(velocity: .fast) + usleep(30_000) // 0.03 second delay + + // Now scroll down from top, checking after each swipe + var lastVisibleRow = "" + for _ in 0.. Bool { + let addButton = findAddButton() + guard addButton.exists && addButton.isEnabled else { return false } + addButton.tap() + sleep(3) + + // Verify form opened + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch + return titleField.waitForExistence(timeout: 5) + } + + private func findAddButton() -> XCUIElement { + sleep(2) + + // Look for add button by various methods + let navBarButtons = app.navigationBars.buttons + for i in 0.. Bool { + let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")).firstMatch + guard submitButton.exists && submitButton.isEnabled else { return false } + submitButton.tap() + sleep(3) + return true + } + + private func cancelForm() { + let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + if cancelButton.exists { + cancelButton.tap() + sleep(2) + } + } + + private func switchToWarrantiesTab() { + app/*@START_MENU_TOKEN@*/.buttons["checkmark.shield"]/*[[".segmentedControls",".buttons[\"Warranties\"]",".buttons[\"checkmark.shield\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + } + + private func switchToDocumentsTab() { + app/*@START_MENU_TOKEN@*/.buttons["doc.text"]/*[[".segmentedControls",".buttons[\"Documents\"]",".buttons[\"doc.text\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap() + } + + private func searchFor(text: String) { + let searchField = app.searchFields.firstMatch + if searchField.exists { + searchField.tap() + searchField.typeText(text) + sleep(2) + } + } + + private func clearSearch() { + let searchField = app.searchFields.firstMatch + if searchField.exists { + let clearButton = searchField.buttons["Clear text"] + if clearButton.exists { + clearButton.tap() + sleep(1) + } + } + } + + private func applyFilter(filterName: String) { + // Open filter menu + let filterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'line.3.horizontal.decrease'")).firstMatch + if filterButton.exists { + filterButton.tap() + sleep(1) + + // Select filter option + let filterOption = app.buttons[filterName] + if filterOption.exists { + filterOption.tap() + sleep(2) + } + } + } + + private func toggleActiveFilter() { + let activeFilterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'checkmark.circle'")).firstMatch + if activeFilterButton.exists { + activeFilterButton.tap() + sleep(2) + } + } + + // MARK: - Test Cases + + // MARK: Navigation Tests + + func test01_NavigateToDocumentsScreen() { + navigateToDocumentsTab() + + // Verify we're on documents screen + let navigationTitle = app.navigationBars["Documents & Warranties"] + XCTAssertTrue(navigationTitle.waitForExistence(timeout: 5), "Should navigate to Documents & Warranties screen") + + // Verify tabs are visible + let warrantiesTab = app.buttons["Warranties"] + let documentsTab = app.buttons["Documents"] + XCTAssertTrue(warrantiesTab.exists || documentsTab.exists, "Should see tab switcher") + } + + func test02_SwitchBetweenWarrantiesAndDocuments() { + navigateToDocumentsTab() + + // Start on warranties tab + switchToWarrantiesTab() + sleep(1) + + // Switch to documents tab + switchToDocumentsTab() + sleep(1) + + // Switch back to warranties + switchToWarrantiesTab() + sleep(1) + + // Should not crash and tabs should still exist + let warrantiesTab = app.buttons["Warranties"] + XCTAssertTrue(warrantiesTab.exists, "Tabs should remain functional after switching") + } + + // MARK: Document Creation Tests + + func test03_CreateDocumentWithAllFields() { + navigateToDocumentsTab() + switchToDocumentsTab() + + XCTAssertTrue(openDocumentForm(), "Should open document form") + + let testTitle = "Test Permit \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + + // Fill all fields + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectDocumentType(type: "Insurance") + fillTextEditor(text: "Test permit description with detailed information") + fillTextField(placeholder: "Tags", text: "construction,permit") + fillTextField(placeholder: "Item Name", text: "Kitchen Renovation") + fillTextField(placeholder: "Location", text: "Main Kitchen") + + XCTAssertTrue(submitForm(), "Should submit form successfully") + + // Verify document appears in list + sleep(2) + let documentCard = app.staticTexts[testTitle] + XCTAssertTrue(documentCard.exists, "Created document should appear in list") + } + + func test04_CreateDocumentWithMinimalFields() { + navigateToDocumentsTab() + switchToDocumentsTab() + + XCTAssertTrue(openDocumentForm(), "Should open document form") + + let testTitle = "Min Doc \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + + // Fill only required fields + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectDocumentType(type: "Insurance") + + XCTAssertTrue(submitForm(), "Should submit form with minimal fields") + + // Verify document appears + sleep(2) + let documentCard = app.staticTexts[testTitle] + XCTAssertTrue(documentCard.exists, "Document with minimal fields should appear") + } + + func test05_CreateDocumentWithEmptyTitle_ShouldFail() { + navigateToDocumentsTab() + switchToDocumentsTab() + + XCTAssertTrue(openDocumentForm(), "Should open document form") + + // Try to submit without title + selectProperty() // REQUIRED - Select property first + selectDocumentType(type: "Insurance") + + let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add'")).firstMatch + + // Submit button should be disabled or show error + if submitButton.exists && submitButton.isEnabled { + submitButton.tap() + sleep(2) + + // Should show error message + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'title'")).firstMatch + XCTAssertTrue(errorMessage.exists, "Should show validation error for missing title") + } + + cancelForm() + } + + // MARK: Warranty Creation Tests + + func test06_CreateWarrantyWithAllFields() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + XCTAssertTrue(openDocumentForm(), "Should open warranty form") + + let testTitle = "Test Warranty \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + + // Fill all warranty fields (including required fields) + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectCategory(category: "Appliances") + fillTextField(placeholder: "Item Name", text: "Dishwasher") // REQUIRED + fillTextField(placeholder: "Provider", text: "Bosch") // REQUIRED + fillTextField(placeholder: "Model", text: "SHPM65Z55N") + fillTextField(placeholder: "Serial", text: "SN123456789") + fillTextField(placeholder: "Provider Contact", text: "1-800-BOSCH-00") + fillTextEditor(text: "Full warranty coverage for 2 years") + + // Select dates + selectDate(dateType: "Start Date", daysFromNow: -30) + selectDate(dateType: "End Date", daysFromNow: 700) // ~2 years + + XCTAssertTrue(submitForm(), "Should submit warranty successfully") + + // Verify warranty appears + sleep(2) + let warrantyCard = app.staticTexts[testTitle] + XCTAssertTrue(warrantyCard.exists, "Created warranty should appear in list") + } + + func test07_CreateWarrantyWithFutureDates() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + XCTAssertTrue(openDocumentForm(), "Should open warranty form") + + let testTitle = "Future Warranty \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectCategory(category: "HVAC") + fillTextField(placeholder: "Item Name", text: "Air Conditioner") // REQUIRED + fillTextField(placeholder: "Provider", text: "Carrier HVAC") // REQUIRED + + // Set start date in future + selectDate(dateType: "Start Date", daysFromNow: 30) + selectDate(dateType: "End Date", daysFromNow: 400) + + XCTAssertTrue(submitForm(), "Should create warranty with future dates") + + sleep(2) + let warrantyCard = app.staticTexts[testTitle] + XCTAssertTrue(warrantyCard.exists, "Warranty with future dates should be created") + } + + func test08_CreateExpiredWarranty() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + XCTAssertTrue(openDocumentForm(), "Should open warranty form") + + let testTitle = "Expired Warranty \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectCategory(category: "Plumbing") + fillTextField(placeholder: "Item Name", text: "Water Heater") // REQUIRED + fillTextField(placeholder: "Provider", text: "AO Smith") // REQUIRED + + // Set dates in the past + selectDate(dateType: "Start Date", daysFromNow: -400) + selectDate(dateType: "End Date", daysFromNow: -30) + + XCTAssertTrue(submitForm(), "Should create expired warranty") + + sleep(2) + // Expired warranty might not show with active filter on + // Toggle active filter off to see it + toggleActiveFilter() + sleep(1) + + let warrantyCard = app.staticTexts[testTitle] + XCTAssertTrue(warrantyCard.exists, "Expired warranty should be created and visible when filter is off") + } + + // MARK: Search and Filter Tests + + func test09_SearchDocumentsByTitle() { + navigateToDocumentsTab() + switchToDocumentsTab() + + // Create a test document first + XCTAssertTrue(openDocumentForm(), "Should open form") + let searchableTitle = "Searchable Doc \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(searchableTitle) + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: searchableTitle) + selectDocumentType(type: "Insurance") + XCTAssertTrue(submitForm(), "Should create document") + sleep(2) + + // Search for it + searchFor(text: String(searchableTitle.prefix(15))) + + // Should find the document + let foundDocument = app.staticTexts[searchableTitle] + XCTAssertTrue(foundDocument.exists, "Should find document by search") + + clearSearch() + } + + func test10_FilterWarrantiesByCategory() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Apply category filter + applyFilter(filterName: "Appliances") + + sleep(2) + + // Should show filter chip or indication + let filterChip = app.staticTexts["Appliances"] + XCTAssertTrue(filterChip.exists || app.buttons["Appliances"].exists, "Should show active category filter") + + // Clear filter + applyFilter(filterName: "All Categories") + } + + func test11_FilterDocumentsByType() { + navigateToDocumentsTab() + switchToDocumentsTab() + + // Apply type filter + applyFilter(filterName: "Permit") + + sleep(2) + + // Should show filter indication + let filterChip = app.staticTexts["Permit"] + XCTAssertTrue(filterChip.exists || app.buttons["Permit"].exists, "Should show active type filter") + + // Clear filter + applyFilter(filterName: "All Types") + } + + func test12_ToggleActiveWarrantiesFilter() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Toggle active filter off + toggleActiveFilter() + sleep(1) + + // Toggle it back on + toggleActiveFilter() + sleep(1) + + // Should not crash + let warrantiesTab = app.buttons["Warranties"] + XCTAssertTrue(warrantiesTab.exists, "Active filter toggle should work without crashing") + } + + // MARK: Document Detail Tests + + func test13_ViewDocumentDetail() { + navigateToDocumentsTab() + switchToDocumentsTab() + + // Create a document + XCTAssertTrue(openDocumentForm(), "Should open form") + let testTitle = "Detail Test Doc \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectDocumentType(type: "Insurance") + fillTextEditor(text: "This is a test receipt with details") + XCTAssertTrue(submitForm(), "Should create document") + sleep(2) + + // Tap on the document card + let documentCard = app.staticTexts[testTitle] + XCTAssertTrue(documentCard.exists, "Document should exist in list") + documentCard.tap() + sleep(2) + + // Should show detail screen + let detailTitle = app.staticTexts[testTitle] + XCTAssertTrue(detailTitle.exists, "Should show document detail screen") + + // Go back + let backButton = app.navigationBars.buttons.firstMatch + backButton.tap() + sleep(1) + } + + func test14_ViewWarrantyDetailWithDates() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Create a warranty + XCTAssertTrue(openDocumentForm(), "Should open form") + let testTitle = "Warranty Detail Test \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectCategory(category: "Appliances") + fillTextField(placeholder: "Item Name", text: "Test Appliance") // REQUIRED + fillTextField(placeholder: "Provider", text: "Test Company") // REQUIRED + selectDate(dateType: "Start Date", daysFromNow: -30) + selectDate(dateType: "End Date", daysFromNow: 335) + XCTAssertTrue(submitForm(), "Should create warranty") + sleep(2) + + // Tap on warranty + let warrantyCard = app.staticTexts[testTitle] + XCTAssertTrue(warrantyCard.exists, "Warranty should exist") + warrantyCard.tap() + sleep(2) + + // Should show warranty details with dates + let detailScreen = app.staticTexts[testTitle] + XCTAssertTrue(detailScreen.exists, "Should show warranty detail") + + // Look for date information + let dateLabels = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] '20' OR label CONTAINS[c] 'Start' OR label CONTAINS[c] 'End'")) + XCTAssertTrue(dateLabels.count > 0, "Should display date information") + + // Go back + app.navigationBars.buttons.firstMatch.tap() + sleep(1) + } + + // MARK: Edit Tests + + func test15_EditDocumentTitle() { + navigateToDocumentsTab() + switchToDocumentsTab() + + // Create document + XCTAssertTrue(openDocumentForm(), "Should open form") + let originalTitle = "Edit Test \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(originalTitle) + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: originalTitle) + selectDocumentType(type: "Insurance") + XCTAssertTrue(submitForm(), "Should create document") + sleep(2) + + // Open detail + let documentCard = app.staticTexts[originalTitle] + XCTAssertTrue(documentCard.exists, "Document should exist") + documentCard.tap() + sleep(2) + + // Tap edit button + let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch + if editButton.exists { + editButton.tap() + sleep(2) + + // Change title + let titleField = app.textFields.containing(NSPredicate(format: "value == '\(originalTitle)'")).firstMatch + if titleField.exists { + titleField.tap() + titleField.clearText() + let newTitle = "Edited \(originalTitle)" + titleField.typeText(newTitle) + createdDocumentTitles.append(newTitle) + + XCTAssertTrue(submitForm(), "Should save edited document") + sleep(2) + + // Verify new title appears + let updatedTitle = app.staticTexts[newTitle] + XCTAssertTrue(updatedTitle.exists, "Updated title should appear") + } + } + + // Go back to list + app.navigationBars.buttons.element(boundBy: 0).tap() + sleep(1) + } + + func test16_EditWarrantyDates() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Create warranty + XCTAssertTrue(openDocumentForm(), "Should open form") + let testTitle = "Edit Dates Warranty \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(testTitle) + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: testTitle) + selectCategory(category: "Electronics") + fillTextField(placeholder: "Item Name", text: "TV") // REQUIRED + fillTextField(placeholder: "Provider", text: "Samsung") // REQUIRED + selectDate(dateType: "Start Date", daysFromNow: -60) + selectDate(dateType: "End Date", daysFromNow: 305) + XCTAssertTrue(submitForm(), "Should create warranty") + sleep(2) + + // Open and edit + let warrantyCard = app.staticTexts[testTitle] + XCTAssertTrue(warrantyCard.exists, "Warranty should exist") + warrantyCard.tap() + sleep(2) + + let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch + if editButton.exists { + editButton.tap() + sleep(2) + + // Change end date to extend warranty + selectDate(dateType: "End Date", daysFromNow: 730) // 2 years + + XCTAssertTrue(submitForm(), "Should save edited warranty dates") + sleep(2) + } + + app.navigationBars.buttons.element(boundBy: 0).tap() + sleep(1) + } + + // MARK: Delete Tests + + func test17_DeleteDocument() { + navigateToDocumentsTab() + switchToDocumentsTab() + + // Create document to delete + XCTAssertTrue(openDocumentForm(), "Should open form") + let deleteTitle = "To Delete \(UUID().uuidString.prefix(8))" + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: deleteTitle) + selectDocumentType(type: "Insurance") + XCTAssertTrue(submitForm(), "Should create document") + sleep(2) + + // Open detail + let documentCard = app.staticTexts[deleteTitle] + XCTAssertTrue(documentCard.exists, "Document should exist") + documentCard.tap() + sleep(2) + + // Find and tap delete button + let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch + if deleteButton.exists { + deleteButton.tap() + sleep(1) + + // Confirm deletion + let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")).firstMatch + if confirmButton.exists { + confirmButton.tap() + sleep(2) + } + + // Should navigate back to list + sleep(2) + + // Verify document no longer exists + let deletedCard = app.staticTexts[deleteTitle] + XCTAssertFalse(deletedCard.exists, "Deleted document should not appear in list") + } + } + + func test18_DeleteWarranty() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Create warranty to delete + XCTAssertTrue(openDocumentForm(), "Should open form") + let deleteTitle = "Warranty to Delete \(UUID().uuidString.prefix(8))" + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: deleteTitle) + selectCategory(category: "Other") + fillTextField(placeholder: "Item Name", text: "Test Item") // REQUIRED + fillTextField(placeholder: "Provider", text: "Test Provider") // REQUIRED + XCTAssertTrue(submitForm(), "Should create warranty") + sleep(2) + + // Open and delete + let warrantyCard = app.staticTexts[deleteTitle] + XCTAssertTrue(warrantyCard.exists, "Warranty should exist") + warrantyCard.tap() + sleep(2) + + let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch + if deleteButton.exists { + deleteButton.tap() + sleep(1) + + // Confirm + let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch + if confirmButton.exists { + confirmButton.tap() + sleep(2) + } + + // Verify deleted + sleep(2) + let deletedCard = app.staticTexts[deleteTitle] + XCTAssertFalse(deletedCard.exists, "Deleted warranty should not appear") + } + } + + // MARK: Edge Cases and Error Handling + + func test19_CancelDocumentCreation() { + navigateToDocumentsTab() + switchToDocumentsTab() + + XCTAssertTrue(openDocumentForm(), "Should open form") + + // Fill some fields + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: "Cancelled Document") + selectDocumentType(type: "Insurance") + + // Cancel instead of save + cancelForm() + + // Should not appear in list + sleep(2) + let cancelledDoc = app.staticTexts["Cancelled Document"] + XCTAssertFalse(cancelledDoc.exists, "Cancelled document should not be created") + } + + func test20_HandleEmptyDocumentsList() { + navigateToDocumentsTab() + switchToDocumentsTab() + + // Apply very specific filter to get empty list + searchFor(text: "NONEXISTENT_DOCUMENT_12345") + + sleep(2) + + // Should show empty state + let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No documents' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch + + // Either empty state exists or no items are shown + let hasNoItems = app.cells.count == 0 + XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty documents list gracefully") + + clearSearch() + } + + func test21_HandleEmptyWarrantiesList() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Search for non-existent warranty + searchFor(text: "NONEXISTENT_WARRANTY_99999") + + sleep(2) + + let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No warranties' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch + let hasNoItems = app.cells.count == 0 + XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty warranties list gracefully") + + clearSearch() + } + + func test22_CreateDocumentWithLongTitle() { + navigateToDocumentsTab() + switchToDocumentsTab() + + XCTAssertTrue(openDocumentForm(), "Should open form") + + let longTitle = "This is a very long document title that exceeds normal length expectations to test how the UI handles lengthy text input " + UUID().uuidString + createdDocumentTitles.append(longTitle) + + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: longTitle) + selectDocumentType(type: "Insurance") + + XCTAssertTrue(submitForm(), "Should handle long title") + + sleep(2) + // Just verify it was created (partial match) + let partialTitle = String(longTitle.prefix(30)) + let documentExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch.exists + XCTAssertTrue(documentExists, "Document with long title should be created") + } + + func test23_CreateWarrantyWithSpecialCharacters() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + XCTAssertTrue(openDocumentForm(), "Should open form") + + let specialTitle = "Warranty w/ Special #Chars: @ & $ % \(UUID().uuidString.prefix(8))" + createdDocumentTitles.append(specialTitle) + + selectProperty() // REQUIRED - Select property first + fillTextField(placeholder: "Title", text: specialTitle) + selectCategory(category: "Other") + fillTextField(placeholder: "Item Name", text: "Test @#$ Item") // REQUIRED + fillTextField(placeholder: "Provider", text: "Special & Co.") // REQUIRED + + XCTAssertTrue(submitForm(), "Should handle special characters") + + sleep(2) + let partialTitle = String(specialTitle.prefix(20)) + let warrantyExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch.exists + XCTAssertTrue(warrantyExists, "Warranty with special characters should be created") + } + + func test24_RapidTabSwitching() { + navigateToDocumentsTab() + + // Rapidly switch between tabs + for _ in 0..<5 { + switchToWarrantiesTab() + usleep(500000) // 0.5 seconds + switchToDocumentsTab() + usleep(500000) // 0.5 seconds + } + + // Should remain stable + let warrantiesTab = app.buttons["Warranties"] + let documentsTab = app.buttons["Documents"] + XCTAssertTrue(warrantiesTab.exists && documentsTab.exists, "Should handle rapid tab switching without crashing") + } + + func test25_MultipleFiltersCombined() { + navigateToDocumentsTab() + switchToWarrantiesTab() + + // Apply multiple filters + toggleActiveFilter() // Turn off active filter + sleep(1) + applyFilter(filterName: "Appliances") + sleep(1) + searchFor(text: "Test") + + sleep(2) + + // Should apply all filters without crashing + let searchField = app.searchFields.firstMatch + XCTAssertTrue(searchField.exists, "Should handle multiple filters simultaneously") + + // Clean up + clearSearch() + sleep(1) + applyFilter(filterName: "All Categories") + sleep(1) + toggleActiveFilter() // Turn active filter back on + } +} + +// MARK: - XCUIElement Extension for Clearing Text + +extension XCUIElement { + func clearText() { + guard let stringValue = self.value as? String else { + return + } + + self.tap() + + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) + self.typeText(deleteString) } } diff --git a/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift b/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift index 7952536..d8be955 100644 --- a/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift +++ b/iosApp/CaseraUITests/Suite9_IntegrationE2ETests.swift @@ -1,9 +1,525 @@ 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: BaseUITestCase { - func testSuite9_StartFreshAndExpandEmailSignup() { - let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Suite9 House") - createAccount.expandEmailSignup() - createAccount.waitForCreateAccountButton(timeout: defaultTimeout) + override var includeResetStateLaunchArgument: Bool { false } + + + // 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 { + try super.setUpWithError() + } + + override func tearDownWithError() throws { + try super.tearDownWithError() + } + + // 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.textFields[AccessibilityIdentifiers.Authentication.usernameField] + if !welcomeText.waitForExistence(timeout: 5) { + ensureLoggedOut() + } + XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should start on login screen") + + // Phase 2: Navigate to registration + let signUpButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Sign Up'")).firstMatch + XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button should exist") + signUpButton.tap() + sleep(2) + + // Phase 3: Fill registration form using proper accessibility identifiers + let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField] + XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist") + usernameField.tap() + usernameField.typeText(userAUsername) + + let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField] + XCTAssertTrue(emailField.waitForExistence(timeout: 3), "Email field should exist") + emailField.tap() + emailField.typeText(userAEmail) + + // Password field - check both SecureField and TextField + var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField] + if !passwordField.exists { + passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField] + } + XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist") + passwordField.tap() + dismissStrongPasswordSuggestion() + passwordField.typeText(userAPassword) + + // Confirm password field + var confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] + if !confirmPasswordField.exists { + confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField] + } + XCTAssertTrue(confirmPasswordField.waitForExistence(timeout: 3), "Confirm password field should exist") + confirmPasswordField.tap() + dismissStrongPasswordSuggestion() + confirmPasswordField.typeText(userAPassword) + + dismissKeyboard() + sleep(1) + + // Phase 4: Submit registration + app.swipeUp() + sleep(1) + + let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton] + XCTAssertTrue(registerButton.waitForExistence(timeout: 5), "Register button should exist") + registerButton.tap() + sleep(3) + + // Phase 5: Handle email verification + let verifyEmailTitle = app.staticTexts["Verify Your Email"] + XCTAssertTrue(verifyEmailTitle.waitForExistence(timeout: 10), "Verification screen must appear after registration") + + sleep(3) + + // Enter verification code - auto-submits when 6 digits entered + let codeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField] + XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist") + codeField.tap() + codeField.typeText(verificationCode) + sleep(5) + + // Phase 6: Verify logged in + let tabBar = app.tabBars.firstMatch + XCTAssertTrue(tabBar.waitForExistence(timeout: 15), "Should be logged in after registration") + + // Phase 7: Logout + UITestHelpers.logout(app: app) + + // Phase 8: Login with created credentials + XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be on login screen after logout") + login(username: userAUsername, password: userAPassword) + + // Phase 9: Verify logged in + XCTAssertTrue(tabBar.waitForExistence(timeout: 10), "Should be logged in after login") + + // Phase 10: Final logout + UITestHelpers.logout(app: app) + XCTAssertTrue(welcomeText.waitForExistence(timeout: 10), "Should be logged out") + } + + // MARK: - Test 2: Residence CRUD Flow + // Mirrors TestIntegration_ResidenceFlow + + func test02_residenceCRUDFlow() { + // Ensure logged in as test user + UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) + navigateToTab("Residences") + sleep(2) + + let residenceName = "E2E Test Home \(timestamp)" + + // Phase 1: Create residence + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + XCTAssertTrue(addButton.waitForExistence(timeout: 5), "Add residence button should exist") + addButton.tap() + sleep(2) + + // Fill form - just tap and type, don't dismiss keyboard between fields + let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField] + XCTAssertTrue(nameField.waitForExistence(timeout: 5), "Name field should exist") + nameField.tap() + sleep(1) + nameField.typeText(residenceName) + + // Use return key to move to next field or dismiss, then scroll + app.keyboards.buttons["return"].tap() + sleep(1) + + // Scroll to show more fields + app.swipeUp() + sleep(1) + + // Fill street field + let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField] + if streetField.waitForExistence(timeout: 3) && streetField.isHittable { + streetField.tap() + sleep(1) + streetField.typeText("123 E2E Test St") + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Fill city field + let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField] + if cityField.waitForExistence(timeout: 3) && cityField.isHittable { + cityField.tap() + sleep(1) + cityField.typeText("Austin") + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Fill state field + let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField] + if stateField.waitForExistence(timeout: 3) && stateField.isHittable { + stateField.tap() + sleep(1) + stateField.typeText("TX") + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Fill postal code field + let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField] + if postalField.waitForExistence(timeout: 3) && postalField.isHittable { + postalField.tap() + sleep(1) + postalField.typeText("78701") + } + + // Dismiss keyboard and scroll to save button + dismissKeyboard() + sleep(1) + app.swipeUp() + sleep(1) + + // Save the residence + let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton] + if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable { + saveButton.tap() + } else { + // Try finding by label as fallback + let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + XCTAssertTrue(saveByLabel.waitForExistence(timeout: 5), "Save button should exist") + saveByLabel.tap() + } + sleep(3) + + // Phase 2: Verify residence was created + navigateToTab("Residences") + sleep(2) + let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(residenceName)'")).firstMatch + XCTAssertTrue(residenceCard.waitForExistence(timeout: 10), "Residence '\(residenceName)' should appear in list") + } + + // MARK: - Test 3: Task Lifecycle Flow + // Mirrors TestIntegration_TaskFlow + + func test03_taskLifecycleFlow() { + // Ensure logged in + UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) + + // Ensure residence exists first - create one if empty + navigateToTab("Residences") + sleep(2) + + let residenceCards = app.cells + if residenceCards.count == 0 { + // No residences, create one first + createMinimalResidence(name: "Task Test Home \(timestamp)") + sleep(2) + } + + // Navigate to Tasks + navigateToTab("Tasks") + sleep(3) + + let taskTitle = "E2E Task Lifecycle \(timestamp)" + + // Phase 1: Create task - use firstMatch to avoid multiple element issue + let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch + guard addButton.waitForExistence(timeout: 5) else { + XCTFail("Add task button should exist") + return + } + + // Check if button is enabled + guard addButton.isEnabled else { + XCTFail("Add task button should be enabled (requires at least one residence)") + return + } + + addButton.tap() + sleep(2) + + // Fill task form + let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch + XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should exist") + titleField.tap() + sleep(1) + titleField.typeText(taskTitle) + + dismissKeyboard() + sleep(1) + app.swipeUp() + sleep(1) + + // Save the task + let saveTaskButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch + if saveTaskButton.waitForExistence(timeout: 5) && saveTaskButton.isHittable { + saveTaskButton.tap() + } else { + let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'Create'")).firstMatch + XCTAssertTrue(saveByLabel.exists, "Save/Create button should exist") + saveByLabel.tap() + } + sleep(3) + + // Phase 2: Verify task was created + navigateToTab("Tasks") + sleep(2) + let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch + XCTAssertTrue(taskCard.waitForExistence(timeout: 10), "Task '\(taskTitle)' should appear in task list") + } + + // MARK: - Test 4: Kanban Column Distribution + // Mirrors TestIntegration_TasksByResidenceKanban + + func test04_kanbanColumnDistribution() { + UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) + navigateToTab("Tasks") + sleep(3) + + // Verify tasks screen is showing + let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + let kanbanExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Overdue' OR label CONTAINS[c] 'Upcoming' OR label CONTAINS[c] 'In Progress'")).firstMatch.exists + + XCTAssertTrue(kanbanExists || tasksTitle.exists, "Tasks screen should be visible") + } + + // MARK: - Test 5: Cross-User Access Control + // Mirrors TestIntegration_CrossUserAccessDenied + + func test05_crossUserAccessControl() { + UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) + + // Verify user can access their residences tab + navigateToTab("Residences") + sleep(2) + + let residencesVisible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch.isSelected + XCTAssertTrue(residencesVisible, "User should be able to access Residences tab") + + // Verify user can access their tasks tab + navigateToTab("Tasks") + sleep(2) + + let tasksAccessible = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch.isSelected + XCTAssertTrue(tasksAccessible, "User should be able to access Tasks tab") + } + + // MARK: - Test 6: Lookup Data Endpoints + // Mirrors TestIntegration_LookupEndpoints + + func test06_lookupDataAvailable() { + UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) + + // Navigate to add residence to check residence types are loaded + navigateToTab("Residences") + sleep(2) + + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + if addButton.waitForExistence(timeout: 5) { + addButton.tap() + sleep(2) + + // Check property type picker exists (indicates lookups loaded) + let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type' OR label CONTAINS[c] 'Type'")).firstMatch + let pickerExists = propertyTypePicker.exists + + // Cancel form + let cancelButton = app.buttons[AccessibilityIdentifiers.Residence.formCancelButton] + if cancelButton.exists { + cancelButton.tap() + } else { + let cancelByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + if cancelByLabel.exists { + cancelByLabel.tap() + } + } + + XCTAssertTrue(pickerExists, "Property type picker should exist (lookups loaded)") + } + } + + // MARK: - Test 7: Residence Sharing Flow + // Mirrors TestIntegration_ResidenceSharingFlow + + func test07_residenceSharingUIElements() { + UITestHelpers.ensureLoggedIn(app: app, username: userAUsername, password: userAPassword) + navigateToTab("Residences") + sleep(2) + + // Find any residence to check sharing UI + let residenceCard = app.cells.firstMatch + if residenceCard.waitForExistence(timeout: 5) { + residenceCard.tap() + sleep(2) + + // Look for share button in residence details + let shareButton = app.buttons[AccessibilityIdentifiers.Residence.shareButton] + let manageUsersButton = app.buttons[AccessibilityIdentifiers.Residence.manageUsersButton] + + // Note: Share functionality may not be visible depending on user permissions + // This test just verifies we can navigate to residence details + + // Navigate back + let backButton = app.navigationBars.buttons.element(boundBy: 0) + if backButton.exists && backButton.isHittable { + backButton.tap() + sleep(1) + } + } + } + + // MARK: - Helper: Create Minimal Residence + + private func createMinimalResidence(name: String) { + let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton] + guard addButton.waitForExistence(timeout: 5) else { return } + + addButton.tap() + sleep(2) + + // Fill name field + let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField] + if nameField.waitForExistence(timeout: 5) { + nameField.tap() + sleep(1) + nameField.typeText(name) + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Scroll to show address fields + app.swipeUp() + sleep(1) + + // Fill street field + let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField] + if streetField.waitForExistence(timeout: 3) && streetField.isHittable { + streetField.tap() + sleep(1) + streetField.typeText("123 Test St") + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Fill city field + let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField] + if cityField.waitForExistence(timeout: 3) && cityField.isHittable { + cityField.tap() + sleep(1) + cityField.typeText("Austin") + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Fill state field + let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField] + if stateField.waitForExistence(timeout: 3) && stateField.isHittable { + stateField.tap() + sleep(1) + stateField.typeText("TX") + app.keyboards.buttons["return"].tap() + sleep(1) + } + + // Fill postal code field + let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField] + if postalField.waitForExistence(timeout: 3) && postalField.isHittable { + postalField.tap() + sleep(1) + postalField.typeText("78701") + } + + dismissKeyboard() + sleep(1) + app.swipeUp() + sleep(1) + + // Save + let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton] + if saveButton.waitForExistence(timeout: 5) && saveButton.isHittable { + saveButton.tap() + } else { + let saveByLabel = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch + if saveByLabel.exists { + saveByLabel.tap() + } + } + sleep(3) + } + + // MARK: - Helper: Find Add Task Button + + private func findAddTaskButton() -> XCUIElement { + let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton] + if addButton.exists { + return addButton + } + return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task' OR label CONTAINS[c] 'New Task'")).firstMatch } } diff --git a/iosApp/CaseraUITests/Tests/Rebuild/Suite0_OnboardingRebuildTests.swift b/iosApp/CaseraUITests/Tests/Rebuild/Suite0_OnboardingRebuildTests.swift new file mode 100644 index 0000000..3bee227 --- /dev/null +++ b/iosApp/CaseraUITests/Tests/Rebuild/Suite0_OnboardingRebuildTests.swift @@ -0,0 +1,31 @@ +import XCTest + +/// Rebuild plan for legacy: Suite0_OnboardingTests.test_onboarding +/// Split into smaller tests to isolate focus/input/navigation failures. +final class Suite0_OnboardingRebuildTests: BaseUITestCase { + func testR001_onboardingWelcomeLoadsAndCanNavigateToLoginEntry() { + let welcome = OnboardingWelcomeScreen(app: app) + welcome.waitForLoad(timeout: defaultTimeout) + welcome.tapAlreadyHaveAccount() + + let login = LoginScreen(app: app) + login.waitForLoad(timeout: defaultTimeout) + } + + func testR002_startFreshFlowReachesCreateAccount() { + let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Rebuild Home") + createAccount.waitForLoad(timeout: defaultTimeout) + } + + func testR003_createAccountExpandedFormFieldsAreInteractable() throws { + throw XCTSkip("Skeleton: implement deterministic focus assertions for username/email/password fields") + } + + func testR004_emailFieldCanFocusAndAcceptTyping() throws { + throw XCTSkip("Skeleton: implement replacement for legacy email focus failure") + } + + func testR005_createAccountContinueOnlyAfterValidInputs() throws { + throw XCTSkip("Skeleton: validate disabled/enabled state transition for Create Account") + } +} diff --git a/iosApp/CaseraUITests/Tests/Rebuild/Suite1_RegistrationRebuildTests.swift b/iosApp/CaseraUITests/Tests/Rebuild/Suite1_RegistrationRebuildTests.swift new file mode 100644 index 0000000..c5b04d1 --- /dev/null +++ b/iosApp/CaseraUITests/Tests/Rebuild/Suite1_RegistrationRebuildTests.swift @@ -0,0 +1,72 @@ +import XCTest + +/// Rebuild plan for legacy failures in Suite1_RegistrationTests: +/// - test07, test09, test10, test11, test12 +/// Coverage is split into smaller tests for easier isolation. +final class Suite1_RegistrationRebuildTests: BaseUITestCase { + override var includeResetStateLaunchArgument: Bool { false } + + func testR101_registerFormCanOpenFromLogin() { + UITestHelpers.ensureOnLoginScreen(app: app) + let register = TestFlows.openRegisterFromLogin(app: app) + register.waitForLoad(timeout: defaultTimeout) + } + + func testR102_registerFormAcceptsValidInput() { + UITestHelpers.ensureOnLoginScreen(app: app) + let register = TestFlows.openRegisterFromLogin(app: app) + XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists) + XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists) + XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists) + XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists) + XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists) + } + + func testR103_successfulRegistrationTransitionsToVerificationGate() throws { + throw XCTSkip("Skeleton: submit valid registration and assert verification gate") + } + + func testR104_verificationGateBlocksMainAppBeforeCodeEntry() throws { + throw XCTSkip("Skeleton: assert no tab bar access while unverified") + } + + func testR105_validVerificationCodeTransitionsToMainApp() throws { + throw XCTSkip("Skeleton: use deterministic verification code fixture and assert main app root") + } + + func testR106_mainAppSessionAfterVerificationCanReachProfile() throws { + throw XCTSkip("Skeleton: assert verified user can navigate tab bar and profile") + } + + func testR107_invalidVerificationCodeShowsErrorAndStaysBlocked() throws { + throw XCTSkip("Skeleton: replacement for legacy test09") + } + + func testR108_incompleteVerificationCodeDoesNotCompleteVerification() throws { + throw XCTSkip("Skeleton: replacement for legacy test10") + } + + func testR109_verifyButtonDisabledForIncompleteCode() throws { + throw XCTSkip("Skeleton: optional split from legacy test10 button state assertion") + } + + func testR110_relaunchUnverifiedUserNeverLandsInMainApp() throws { + throw XCTSkip("Skeleton: replacement for legacy test11") + } + + func testR111_relaunchUnverifiedUserResumesVerificationOrLoginGate() throws { + throw XCTSkip("Skeleton: acceptable states after relaunch") + } + + func testR112_logoutFromVerificationReturnsToLogin() throws { + throw XCTSkip("Skeleton: replacement for legacy test12") + } + + func testR113_verificationElementsDisappearAfterLogout() throws { + throw XCTSkip("Skeleton: split assertion from legacy test12") + } + + func testR114_logoutFromVerifiedMainAppReturnsToLogin() throws { + throw XCTSkip("Skeleton: split assertion from legacy test07 cleanup") + } +} diff --git a/iosApp/CaseraUITests/Tests/Rebuild/Suite2_AuthenticationRebuildTests.swift b/iosApp/CaseraUITests/Tests/Rebuild/Suite2_AuthenticationRebuildTests.swift new file mode 100644 index 0000000..31bee75 --- /dev/null +++ b/iosApp/CaseraUITests/Tests/Rebuild/Suite2_AuthenticationRebuildTests.swift @@ -0,0 +1,147 @@ +import XCTest + +/// Rebuild plan for legacy Suite2 failures: +/// - test02_loginWithValidCredentials +/// - test06_logout +final class Suite2_AuthenticationRebuildTests: BaseUITestCase { + override var includeResetStateLaunchArgument: Bool { false } + override var additionalLaunchArguments: [String] { ["--ui-test-mock-auth"] } + private let validUser = RebuildTestUserFactory.seeded + + private enum AuthLandingState { + case main + case verification + } + + override func setUpWithError() throws { + try super.setUpWithError() + UITestHelpers.ensureLoggedOut(app: app) + } + + private func loginFromLoginScreen(user: RebuildTestUser = RebuildTestUserFactory.seeded) { + UITestHelpers.ensureOnLoginScreen(app: app) + let login = LoginScreen(app: app) + login.waitForLoad(timeout: defaultTimeout) + login.enterUsername(user.username) + login.enterPassword(user.password) + + let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton] + loginButton.waitForExistenceOrFail(timeout: defaultTimeout) + loginButton.forceTap() + } + + @discardableResult + private func loginAndWaitForAuthenticatedLanding(user: RebuildTestUser = RebuildTestUserFactory.seeded) -> AuthLandingState { + loginFromLoginScreen(user: user) + + let mainRoot = app.otherElements[UITestID.Root.mainTabs] + if mainRoot.waitForExistence(timeout: longTimeout) || app.tabBars.firstMatch.waitForExistence(timeout: 2) { + return .main + } + + let verification = VerificationScreen(app: app) + if verification.codeField.waitForExistence(timeout: defaultTimeout) || verification.verifyButton.waitForExistence(timeout: 2) { + return .verification + } + + XCTFail("Expected authenticated landing on main tabs or verification screen") + return .verification + } + + private func logoutFromVerificationIfNeeded() { + let verification = VerificationScreen(app: app) + verification.waitForLoad(timeout: defaultTimeout) + verification.tapLogoutIfAvailable() + + let toolbarLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch + if toolbarLogout.waitForExistence(timeout: 3) { + toolbarLogout.forceTap() + } + } + + private func logoutFromMainApp() { + UITestHelpers.logout(app: app) + } + + func testR201_loginScreenLoadsFromOnboardingEntry() { + UITestHelpers.ensureOnLoginScreen(app: app) + let login = LoginScreen(app: app) + login.waitForLoad(timeout: defaultTimeout) + } + + func testR202_validCredentialsSubmitFromLogin() { + UITestHelpers.ensureOnLoginScreen(app: app) + let login = LoginScreen(app: app) + login.waitForLoad(timeout: defaultTimeout) + + login.enterUsername(validUser.username) + login.enterPassword(validUser.password) + + let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton] + XCTAssertTrue(loginButton.waitForExistence(timeout: defaultTimeout), "Login button must exist before submit") + XCTAssertTrue(loginButton.isHittable, "Login button must be tappable") + } + + func testR203_validLoginTransitionsToMainAppRoot() { + let landing = loginAndWaitForAuthenticatedLanding(user: validUser) + switch landing { + case .main: + RebuildSessionAssertions.assertOnMainApp(app, timeout: longTimeout) + case .verification: + RebuildSessionAssertions.assertOnVerification(app, timeout: longTimeout) + } + } + + func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() { + let landing = loginAndWaitForAuthenticatedLanding(user: validUser) + + switch landing { + case .main: + RebuildSessionAssertions.assertOnMainApp(app, timeout: longTimeout) + + let tabBar = app.tabBars.firstMatch + if tabBar.waitForExistence(timeout: 5) { + let residences = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + let tasks = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + let contractors = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + let docs = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Doc'")).firstMatch + XCTAssertTrue(residences.exists, "Residences tab should exist") + XCTAssertTrue(tasks.exists, "Tasks tab should exist") + XCTAssertTrue(contractors.exists, "Contractors tab should exist") + XCTAssertTrue(docs.exists, "Documents tab should exist") + } else { + XCTAssertTrue(app.otherElements[UITestID.Root.mainTabs].exists, "Main tabs root should exist") + } + case .verification: + let verify = VerificationScreen(app: app) + verify.waitForLoad(timeout: defaultTimeout) + XCTAssertTrue(verify.codeField.exists, "Verification code field should exist for unverified accounts") + } + } + + func testR205_logoutFromMainAppReturnsToLoginRoot() { + let landing = loginAndWaitForAuthenticatedLanding(user: validUser) + + switch landing { + case .main: + logoutFromMainApp() + case .verification: + logoutFromVerificationIfNeeded() + } + RebuildSessionAssertions.assertOnLogin(app, timeout: longTimeout) + } + + func testR206_postLogoutMainAppIsNoLongerAccessible() { + let landing = loginAndWaitForAuthenticatedLanding(user: validUser) + + switch landing { + case .main: + logoutFromMainApp() + case .verification: + logoutFromVerificationIfNeeded() + } + RebuildSessionAssertions.assertOnLogin(app, timeout: longTimeout) + + XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout") + } +} diff --git a/iosApp/CaseraUITests/Tests/Rebuild/Suite3_ResidenceRebuildTests.swift b/iosApp/CaseraUITests/Tests/Rebuild/Suite3_ResidenceRebuildTests.swift new file mode 100644 index 0000000..9dd312e --- /dev/null +++ b/iosApp/CaseraUITests/Tests/Rebuild/Suite3_ResidenceRebuildTests.swift @@ -0,0 +1,137 @@ +import XCTest + +/// Rebuild plan for legacy Suite3 failures (all blocked at residences tab precondition). +/// Old tests covered: +/// - test01_viewResidencesList +/// - test02_navigateToAddResidence +/// - test03_navigationBetweenTabs +/// - test04_cancelResidenceCreation +/// - test05_createResidenceWithMinimalData +/// - test06_viewResidenceDetails +final class Suite3_ResidenceRebuildTests: BaseUITestCase { + override var includeResetStateLaunchArgument: Bool { false } + override var additionalLaunchArguments: [String] { ["--ui-test-mock-auth"] } + + override func setUpWithError() throws { + try super.setUpWithError() + UITestHelpers.ensureLoggedOut(app: app) + } + + private func loginAndOpenResidences() { + UITestHelpers.ensureOnLoginScreen(app: app) + let login = LoginScreen(app: app) + login.waitForLoad(timeout: defaultTimeout) + login.enterUsername("testuser") + login.enterPassword("TestPass123!") + app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap() + + let main = MainTabScreen(app: app) + main.waitForLoad(timeout: longTimeout) + main.goToResidences() + } + + @discardableResult + private func createResidence(name: String) -> String { + loginAndOpenResidences() + + let list = ResidenceListScreen(app: app) + list.waitForLoad(timeout: defaultTimeout) + list.openCreateResidence() + + let form = ResidenceFormScreen(app: app) + form.waitForLoad(timeout: defaultTimeout) + form.enterName(name) + + form.save() + return name + } + + func testR301_authenticatedPreconditionCanReachMainApp() throws { + loginAndOpenResidences() + RebuildSessionAssertions.assertOnMainApp(app, timeout: defaultTimeout) + } + + func testR302_residencesTabIsPresentAndNavigable() throws { + loginAndOpenResidences() + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + XCTAssertTrue(residencesTab.exists, "Residences tab should exist") + } + + func testR303_residencesListLoadsAfterTabSelection() throws { + loginAndOpenResidences() + let list = ResidenceListScreen(app: app) + list.waitForLoad(timeout: defaultTimeout) + XCTAssertTrue(list.addButton.exists, "Add residence button should be visible") + } + + func testR304_openAddResidenceFormFromResidencesList() throws { + loginAndOpenResidences() + let list = ResidenceListScreen(app: app) + list.waitForLoad(timeout: defaultTimeout) + list.openCreateResidence() + + let form = ResidenceFormScreen(app: app) + form.waitForLoad(timeout: defaultTimeout) + XCTAssertTrue(form.saveButton.exists, "Residence save button should exist") + } + + func testR305_cancelAddResidenceReturnsToResidenceList() throws { + loginAndOpenResidences() + let list = ResidenceListScreen(app: app) + list.openCreateResidence() + + let form = ResidenceFormScreen(app: app) + form.waitForLoad(timeout: defaultTimeout) + form.cancel() + + list.waitForLoad(timeout: defaultTimeout) + } + + func testR306_createResidenceMinimalDataSubmitsSuccessfully() throws { + let name = "UITest Home \(Int(Date().timeIntervalSince1970))" + _ = createResidence(name: name) + let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch + XCTAssertTrue(created.waitForExistence(timeout: longTimeout), "Created residence should appear in list") + } + + func testR307_newResidenceAppearsInResidenceList() throws { + let name = "UITest Verify \(Int(Date().timeIntervalSince1970))" + _ = createResidence(name: name) + let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch + XCTAssertTrue(created.waitForExistence(timeout: longTimeout), "New residence should be visible in residences list") + } + + func testR308_openResidenceDetailsFromResidenceList() throws { + let name = "UITest Detail \(Int(Date().timeIntervalSince1970))" + _ = createResidence(name: name) + + let row = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch + row.waitForExistenceOrFail(timeout: longTimeout).forceTap() + + let edit = app.buttons[AccessibilityIdentifiers.Residence.editButton] + let delete = app.buttons[AccessibilityIdentifiers.Residence.deleteButton] + let loaded = edit.waitForExistence(timeout: defaultTimeout) || delete.waitForExistence(timeout: defaultTimeout) + XCTAssertTrue(loaded, "Residence details should expose edit or delete actions") + } + + func testR309_navigationAcrossPrimaryTabsAndBackToResidences() throws { + loginAndOpenResidences() + + let tabBar = app.tabBars.firstMatch + tabBar.waitForExistenceOrFail(timeout: defaultTimeout) + + let tasksTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + XCTAssertTrue(tasksTab.exists, "Tasks tab should exist") + tasksTab.forceTap() + + let contractorsTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch + XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist") + contractorsTab.forceTap() + + let residencesTab = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + residencesTab.forceTap() + + let list = ResidenceListScreen(app: app) + list.waitForLoad(timeout: defaultTimeout) + } +} diff --git a/iosApp/CaseraUITests/UITestHelpers.swift b/iosApp/CaseraUITests/UITestHelpers.swift new file mode 100644 index 0000000..9d16740 --- /dev/null +++ b/iosApp/CaseraUITests/UITestHelpers.swift @@ -0,0 +1,174 @@ +import XCTest + +/// Reusable helper functions for UI tests +struct UITestHelpers { + private static func loginUsernameField(app: XCUIApplication) -> XCUIElement { + app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + } + + // MARK: - Authentication Helpers + + /// Logs out the user if they are currently logged in + /// - Parameter app: The XCUIApplication instance + static func logout(app: XCUIApplication) { + sleep(1) + + // Already on login screen. + let usernameField = loginUsernameField(app: app) + if usernameField.waitForExistence(timeout: 2) { + return + } + + // In onboarding flow, navigate to login. + let onboardingRoot = app.otherElements[UITestID.Root.onboarding] + if onboardingRoot.waitForExistence(timeout: 2) { + ensureOnLoginScreen(app: app) + return + } + + // Check if we have a tab bar (logged in state) + let tabBar = app.tabBars.firstMatch + guard tabBar.exists else { return } + + // Navigate to Residences tab first + let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch + if residencesTab.exists { + residencesTab.tap() + sleep(1) + } + + // Tap settings button + let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton] + if settingsButton.waitForExistence(timeout: 3) && settingsButton.isHittable { + settingsButton.tap() + sleep(1) + } + + // Find and tap logout button + let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton] + if logoutButton.waitForExistence(timeout: 3) { + logoutButton.tap() + sleep(1) + + // Confirm logout in alert if present - specifically target the alert's button + let alert = app.alerts.firstMatch + if alert.waitForExistence(timeout: 2) { + let confirmLogout = alert.buttons["Log Out"] + if confirmLogout.exists { + confirmLogout.tap() + } + } + } + + sleep(2) + + XCTAssertTrue( + usernameField.waitForExistence(timeout: 8), + "Failed to log out - login username field should appear" + ) + } + + /// 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) { + // Find username field by accessibility identifier + let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Username field should exist") + usernameField.tap() + usernameField.typeText(username) + + // Find password field - it could be TextField (if visible) or SecureField + var passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField] + if !passwordField.exists { + passwordField = app.textFields[AccessibilityIdentifiers.Authentication.passwordField] + } + XCTAssertTrue(passwordField.waitForExistence(timeout: 3), "Password field should exist") + passwordField.tap() + passwordField.typeText(password) + + // Find and tap login button + let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton] + XCTAssertTrue(loginButton.waitForExistence(timeout: 3), "Login button should exist") + loginButton.tap() + + // Wait for login to complete + sleep(3) + } + + /// Ensures the user is logged out before running a test + /// - Parameter app: The XCUIApplication instance + static func ensureLoggedOut(app: XCUIApplication) { + sleep(1) + logout(app: app) + ensureOnLoginScreen(app: app) + } + + /// Ensures the user is logged in with test credentials before running a test + /// - Parameter app: The XCUIApplication instance + /// - Parameter username: Optional username (defaults to "testuser") + /// - Parameter password: Optional password (defaults to "TestPass123!") + static func ensureLoggedIn(app: XCUIApplication, username: String = "testuser", password: String = "TestPass123!") { + sleep(1) + + // Check if already logged in (tab bar visible) + let tabBar = app.tabBars.firstMatch + if tabBar.exists { + return // Already logged in + } + + ensureOnLoginScreen(app: app) + + let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField] + if usernameField.waitForExistence(timeout: 5) { + login(app: app, username: username, password: password) + + // Wait for main screen to appear + _ = tabBar.waitForExistence(timeout: 10) + } + } + + static func ensureOnLoginScreen(app: XCUIApplication) { + let usernameField = loginUsernameField(app: app) + if usernameField.waitForExistence(timeout: 2) { + return + } + + // Handle persisted authenticated sessions first. + let mainTabsRoot = app.otherElements[UITestID.Root.mainTabs] + if mainTabsRoot.exists || app.tabBars.firstMatch.exists { + logout(app: app) + if usernameField.waitForExistence(timeout: 8) { + return + } + } + + // Wait for a stable root state before interacting. + let loginRoot = app.otherElements[UITestID.Root.login] + let onboardingRoot = app.otherElements[UITestID.Root.onboarding] + _ = loginRoot.waitForExistence(timeout: 5) || onboardingRoot.waitForExistence(timeout: 5) + + if onboardingRoot.exists { + // Handle both pure onboarding and onboarding + login sheet. + let onboardingLoginButton = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton] + if onboardingLoginButton.waitForExistence(timeout: 5) { + if onboardingLoginButton.isHittable { + onboardingLoginButton.tap() + } else { + onboardingLoginButton.forceTap() + } + } else { + let welcome = OnboardingWelcomeScreen(app: app) + welcome.waitForLoad() + welcome.tapAlreadyHaveAccount() + } + } + + XCTAssertTrue( + usernameField.waitForExistence(timeout: 20), + "Expected to reach login screen from current app state" + ) + } +} diff --git a/iosApp/iosApp/Helpers/UITestRuntime.swift b/iosApp/iosApp/Helpers/UITestRuntime.swift index af574fc..7c52560 100644 --- a/iosApp/iosApp/Helpers/UITestRuntime.swift +++ b/iosApp/iosApp/Helpers/UITestRuntime.swift @@ -7,6 +7,7 @@ enum UITestRuntime { static let uiTestingFlag = "--ui-testing" static let disableAnimationsFlag = "--disable-animations" static let resetStateFlag = "--reset-state" + static let mockAuthFlag = "--ui-test-mock-auth" static var launchArguments: [String] { ProcessInfo.processInfo.arguments @@ -24,6 +25,10 @@ enum UITestRuntime { isEnabled && launchArguments.contains(resetStateFlag) } + static var shouldMockAuth: Bool { + isEnabled && launchArguments.contains(mockAuthFlag) + } + static func configureForLaunch() { guard isEnabled else { return } diff --git a/iosApp/iosApp/Login/LoginViewModel.swift b/iosApp/iosApp/Login/LoginViewModel.swift index 114932a..3ec4c1d 100644 --- a/iosApp/iosApp/Login/LoginViewModel.swift +++ b/iosApp/iosApp/Login/LoginViewModel.swift @@ -57,6 +57,19 @@ class LoginViewModel: ObservableObject { isLoading = true errorMessage = nil + if UITestRuntime.shouldMockAuth { + // Deterministic UI-test auth path scoped behind launch args. + if username == "testuser" && password == "TestPass123!" { + isVerified = true + isLoading = false + onLoginSuccess?(true) + } else { + isLoading = false + errorMessage = "Invalid username or password" + } + return + } + Task { do { let result = try await APILayer.shared.login( diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 7ae9b6b..8be0c28 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -187,7 +187,8 @@ struct OnboardingCreateAccountContent: View { icon: "person.fill", placeholder: "Username", text: $viewModel.username, - isFocused: focusedField == .username + isFocused: focusedField == .username, + accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.usernameField ) .focused($focusedField, equals: .username) .textInputAutocapitalization(.never) @@ -198,7 +199,8 @@ struct OnboardingCreateAccountContent: View { icon: "envelope.fill", placeholder: "Email", text: $viewModel.email, - isFocused: focusedField == .email + isFocused: focusedField == .email, + accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.emailField ) .focused($focusedField, equals: .email) .textInputAutocapitalization(.never) @@ -210,7 +212,8 @@ struct OnboardingCreateAccountContent: View { icon: "lock.fill", placeholder: "Password", text: $viewModel.password, - isFocused: focusedField == .password + isFocused: focusedField == .password, + accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.passwordField ) .focused($focusedField, equals: .password) @@ -218,7 +221,8 @@ struct OnboardingCreateAccountContent: View { icon: "lock.fill", placeholder: "Confirm Password", text: $viewModel.confirmPassword, - isFocused: focusedField == .confirmPassword + isFocused: focusedField == .confirmPassword, + accessibilityIdentifier: AccessibilityIdentifiers.Onboarding.confirmPasswordField ) .focused($focusedField, equals: .confirmPassword) } @@ -324,6 +328,7 @@ private struct OrganicOnboardingTextField: View { let placeholder: String @Binding var text: String var isFocused: Bool = false + var accessibilityIdentifier: String? = nil var body: some View { HStack(spacing: 14) { @@ -339,6 +344,7 @@ private struct OrganicOnboardingTextField: View { TextField(placeholder, text: $text) .font(.system(size: 16, weight: .medium)) + .accessibilityIdentifier(accessibilityIdentifier ?? "") } .padding(14) .background(Color.appBackgroundPrimary.opacity(0.5)) @@ -357,6 +363,7 @@ private struct OrganicOnboardingSecureField: View { let placeholder: String @Binding var text: String var isFocused: Bool = false + var accessibilityIdentifier: String? = nil @State private var showPassword = false var body: some View { @@ -375,10 +382,12 @@ private struct OrganicOnboardingSecureField: View { TextField(placeholder, text: $text) .font(.system(size: 16, weight: .medium)) .textContentType(.password) + .accessibilityIdentifier(accessibilityIdentifier ?? "") } else { SecureField(placeholder, text: $text) .font(.system(size: 16, weight: .medium)) .textContentType(.password) + .accessibilityIdentifier(accessibilityIdentifier ?? "") } Button(action: { showPassword.toggle() }) { diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index b47801c..cd4377c 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -7,6 +7,9 @@ import Combine /// Kicks off API calls that update DataManager, letting views react to cache updates. @MainActor class ResidenceViewModel: ObservableObject { + private static var uiTestMockResidences: [ResidenceResponse] = [] + private static var uiTestNextResidenceId: Int = 1000 + // MARK: - Published Properties (from DataManager observation) @Published var myResidences: MyResidencesResponse? @Published var residences: [ResidenceResponse] = [] @@ -93,6 +96,18 @@ class ResidenceViewModel: ObservableObject { /// Load my residences - checks cache first, then fetches if needed func loadMyResidences(forceRefresh: Bool = false) { + if UITestRuntime.shouldMockAuth { + if Self.uiTestMockResidences.isEmpty || forceRefresh { + if Self.uiTestMockResidences.isEmpty { + Self.uiTestMockResidences = [makeMockResidence(name: "Seed Residence")] + } + } + myResidences = MyResidencesResponse(residences: Self.uiTestMockResidences) + isLoading = false + errorMessage = nil + return + } + errorMessage = nil // Check if we have cached data and don't need to refresh @@ -122,6 +137,13 @@ class ResidenceViewModel: ObservableObject { } func getResidence(id: Int32) { + if UITestRuntime.shouldMockAuth { + selectedResidence = Self.uiTestMockResidences.first(where: { $0.id == id }) + isLoading = false + errorMessage = selectedResidence == nil ? "Residence not found" : nil + return + } + isLoading = true errorMessage = nil @@ -151,6 +173,22 @@ class ResidenceViewModel: ObservableObject { /// Creates a residence and returns the created residence on success func createResidence(request: ResidenceCreateRequest, completion: @escaping (ResidenceResponse?) -> Void) { + if UITestRuntime.shouldMockAuth { + let residence = makeMockResidence( + name: request.name, + streetAddress: request.streetAddress ?? "", + city: request.city ?? "", + stateProvince: request.stateProvince ?? "", + postalCode: request.postalCode ?? "" + ) + Self.uiTestMockResidences.append(residence) + myResidences = MyResidencesResponse(residences: Self.uiTestMockResidences) + isLoading = false + errorMessage = nil + completion(residence) + return + } + isLoading = true errorMessage = nil @@ -279,4 +317,44 @@ class ResidenceViewModel: ObservableObject { } } } + + private func makeMockResidence( + name: String, + streetAddress: String = "", + city: String = "", + stateProvince: String = "", + postalCode: String = "" + ) -> ResidenceResponse { + let id = Self.uiTestNextResidenceId + Self.uiTestNextResidenceId += 1 + let now = "2026-02-20T00:00:00Z" + return ResidenceResponse( + id: Int32(id), + ownerId: 1, + owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@example.com", firstName: "UI", lastName: "Tester"), + users: [], + name: name, + propertyTypeId: 1, + propertyType: ResidenceType(id: 1, name: "House"), + streetAddress: streetAddress, + apartmentUnit: "", + city: city, + stateProvince: stateProvince, + postalCode: postalCode, + country: "USA", + bedrooms: nil, + bathrooms: nil, + squareFootage: nil, + lotSize: nil, + yearBuilt: nil, + description: "", + purchaseDate: nil, + purchasePrice: nil, + isPrimary: false, + isActive: true, + overdueCount: 0, + createdAt: now, + updatedAt: now + ) + } }