Rebrand from Casera/MyCrib to honeyDue
Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
274
iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift
Normal file
274
iosApp/HoneyDueUITests/AccessibilityIdentifiers.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
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"
|
||||
static let googleSignInButton = "Login.GoogleSignInButton"
|
||||
|
||||
// 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 settingsButton = "Navigation.SettingsButton"
|
||||
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)"
|
||||
}
|
||||
}
|
||||
111
iosApp/HoneyDueUITests/CriticalPath/AuthCriticalPathTests.swift
Normal file
111
iosApp/HoneyDueUITests/CriticalPath/AuthCriticalPathTests.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import XCTest
|
||||
|
||||
/// Critical path tests for authentication flows.
|
||||
///
|
||||
/// Validates login, logout, registration entry, and password reset entry.
|
||||
/// Zero sleep() calls — all waits are condition-based.
|
||||
final class AuthCriticalPathTests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
app = TestLaunchConfig.launchApp()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
app = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Login
|
||||
|
||||
func testLoginWithValidCredentials() {
|
||||
let login = LoginScreen(app: app)
|
||||
guard login.emailField.waitForExistence(timeout: 15) else {
|
||||
// Already logged in — verify main screen
|
||||
let main = MainTabScreen(app: app)
|
||||
XCTAssertTrue(main.isDisplayed, "Main screen should be visible when already logged in")
|
||||
return
|
||||
}
|
||||
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
|
||||
let main = MainTabScreen(app: app)
|
||||
XCTAssertTrue(
|
||||
main.residencesTab.waitForExistence(timeout: 15),
|
||||
"Should navigate to main screen after successful login"
|
||||
)
|
||||
}
|
||||
|
||||
func testLoginWithInvalidCredentials() {
|
||||
let login = LoginScreen(app: app)
|
||||
guard login.emailField.waitForExistence(timeout: 15) else {
|
||||
return // Already logged in, skip
|
||||
}
|
||||
|
||||
login.login(email: "invaliduser", password: "wrongpassword")
|
||||
|
||||
// Should stay on login screen — email field should still exist
|
||||
XCTAssertTrue(
|
||||
login.emailField.waitForExistence(timeout: 10),
|
||||
"Should remain on login screen after invalid credentials"
|
||||
)
|
||||
|
||||
// Tab bar should NOT appear
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
XCTAssertFalse(tabBar.exists, "Tab bar should not appear after failed login")
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
func testLogoutFlow() {
|
||||
let login = LoginScreen(app: app)
|
||||
if login.emailField.waitForExistence(timeout: 15) {
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
}
|
||||
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 15) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.logout()
|
||||
|
||||
// Should be back on login screen
|
||||
let loginAfterLogout = LoginScreen(app: app)
|
||||
XCTAssertTrue(
|
||||
loginAfterLogout.emailField.waitForExistence(timeout: 15),
|
||||
"Should return to login screen after logout"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Registration Entry
|
||||
|
||||
func testSignUpButtonNavigatesToRegistration() {
|
||||
let login = LoginScreen(app: app)
|
||||
guard login.emailField.waitForExistence(timeout: 15) else {
|
||||
return // Already logged in, skip
|
||||
}
|
||||
|
||||
let register = login.tapSignUp()
|
||||
XCTAssertTrue(register.isDisplayed, "Registration screen should appear after tapping Sign Up")
|
||||
}
|
||||
|
||||
// MARK: - Forgot Password Entry
|
||||
|
||||
func testForgotPasswordButtonExists() {
|
||||
let login = LoginScreen(app: app)
|
||||
guard login.emailField.waitForExistence(timeout: 15) else {
|
||||
return // Already logged in, skip
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
login.forgotPasswordButton.waitForExistence(timeout: 5),
|
||||
"Forgot password button should exist on login screen"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import XCTest
|
||||
|
||||
/// Critical path tests for core navigation.
|
||||
///
|
||||
/// Validates tab bar navigation, settings access, and screen transitions.
|
||||
/// Requires a logged-in user. Zero sleep() calls — all waits are condition-based.
|
||||
final class NavigationCriticalPathTests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
app = TestLaunchConfig.launchApp()
|
||||
ensureLoggedIn()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
app = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
private func ensureLoggedIn() {
|
||||
let login = LoginScreen(app: app)
|
||||
if login.emailField.waitForExistence(timeout: 15) {
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
}
|
||||
let main = MainTabScreen(app: app)
|
||||
_ = main.residencesTab.waitForExistence(timeout: 15)
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
func testAllTabsExist() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist")
|
||||
XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist")
|
||||
XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist")
|
||||
XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist")
|
||||
}
|
||||
|
||||
func testNavigateToTasksTab() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToTasks()
|
||||
XCTAssertTrue(main.tasksTab.isSelected, "Tasks tab should be selected")
|
||||
}
|
||||
|
||||
func testNavigateToContractorsTab() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToContractors()
|
||||
XCTAssertTrue(main.contractorsTab.isSelected, "Contractors tab should be selected")
|
||||
}
|
||||
|
||||
func testNavigateToDocumentsTab() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToDocuments()
|
||||
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
|
||||
}
|
||||
|
||||
func testNavigateBackToResidencesTab() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToDocuments()
|
||||
main.goToResidences()
|
||||
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
|
||||
}
|
||||
|
||||
// MARK: - Settings Access
|
||||
|
||||
func testSettingsButtonExists() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToResidences()
|
||||
XCTAssertTrue(
|
||||
main.settingsButton.waitForExistence(timeout: 5),
|
||||
"Settings button should exist on Residences screen"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Add Buttons
|
||||
|
||||
func testResidenceAddButtonExists() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToResidences()
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
XCTAssertTrue(
|
||||
addButton.waitForExistence(timeout: 5),
|
||||
"Residence add button should exist"
|
||||
)
|
||||
}
|
||||
|
||||
func testTaskAddButtonExists() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToTasks()
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
|
||||
XCTAssertTrue(
|
||||
addButton.waitForExistence(timeout: 5),
|
||||
"Task add button should exist"
|
||||
)
|
||||
}
|
||||
|
||||
func testContractorAddButtonExists() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToContractors()
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
|
||||
XCTAssertTrue(
|
||||
addButton.waitForExistence(timeout: 5),
|
||||
"Contractor add button should exist"
|
||||
)
|
||||
}
|
||||
|
||||
func testDocumentAddButtonExists() {
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 10) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
main.goToDocuments()
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton]
|
||||
XCTAssertTrue(
|
||||
addButton.waitForExistence(timeout: 5),
|
||||
"Document add button should exist"
|
||||
)
|
||||
}
|
||||
}
|
||||
118
iosApp/HoneyDueUITests/CriticalPath/SmokeTests.swift
Normal file
118
iosApp/HoneyDueUITests/CriticalPath/SmokeTests.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import XCTest
|
||||
|
||||
/// Smoke tests - run on every PR. Must complete in <2 minutes.
|
||||
///
|
||||
/// Tests that the app launches successfully, the auth screen renders correctly,
|
||||
/// and core navigation is functional. These are the minimum-viability tests
|
||||
/// that must pass before any PR can merge.
|
||||
///
|
||||
/// Zero sleep() calls — all waits are condition-based.
|
||||
final class SmokeTests: XCTestCase {
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
app = TestLaunchConfig.launchApp()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
app = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - App Launch
|
||||
|
||||
func testAppLaunches() {
|
||||
// App should show either login screen or main tab view
|
||||
let loginScreen = LoginScreen(app: app)
|
||||
let mainScreen = MainTabScreen(app: app)
|
||||
|
||||
let loginAppeared = loginScreen.emailField.waitForExistence(timeout: 15)
|
||||
let mainAppeared = mainScreen.residencesTab.waitForExistence(timeout: 5)
|
||||
|
||||
XCTAssertTrue(loginAppeared || mainAppeared, "App should show login or main screen on launch")
|
||||
}
|
||||
|
||||
// MARK: - Login Screen Elements
|
||||
|
||||
func testLoginScreenElements() {
|
||||
let login = LoginScreen(app: app)
|
||||
guard login.emailField.waitForExistence(timeout: 15) else {
|
||||
// Already logged in, skip this test
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(login.emailField.exists, "Email field should exist")
|
||||
XCTAssertTrue(login.passwordField.exists, "Password field should exist")
|
||||
XCTAssertTrue(login.loginButton.exists, "Login button should exist")
|
||||
}
|
||||
|
||||
// MARK: - Login Flow
|
||||
|
||||
func testLoginWithExistingCredentials() {
|
||||
let login = LoginScreen(app: app)
|
||||
guard login.emailField.waitForExistence(timeout: 15) else {
|
||||
// Already on main screen - verify tabs
|
||||
let main = MainTabScreen(app: app)
|
||||
XCTAssertTrue(main.isDisplayed, "Main tabs should be visible")
|
||||
return
|
||||
}
|
||||
|
||||
// Login with the known test user
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
|
||||
let main = MainTabScreen(app: app)
|
||||
XCTAssertTrue(main.residencesTab.waitForExistence(timeout: 15), "Should navigate to main screen after login")
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
func testMainTabsExistAfterLogin() {
|
||||
let login = LoginScreen(app: app)
|
||||
if login.emailField.waitForExistence(timeout: 15) {
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
}
|
||||
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 15) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
// App has 4 tabs: Residences, Tasks, Contractors, Documents
|
||||
XCTAssertTrue(main.residencesTab.exists, "Residences tab should exist")
|
||||
XCTAssertTrue(main.tasksTab.exists, "Tasks tab should exist")
|
||||
XCTAssertTrue(main.contractorsTab.exists, "Contractors tab should exist")
|
||||
XCTAssertTrue(main.documentsTab.exists, "Documents tab should exist")
|
||||
}
|
||||
|
||||
func testTabNavigation() {
|
||||
let login = LoginScreen(app: app)
|
||||
if login.emailField.waitForExistence(timeout: 15) {
|
||||
let user = TestFixtures.TestUser.existing
|
||||
login.login(email: user.email, password: user.password)
|
||||
}
|
||||
|
||||
let main = MainTabScreen(app: app)
|
||||
guard main.residencesTab.waitForExistence(timeout: 15) else {
|
||||
XCTFail("Main screen did not appear")
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate through each tab and verify selection
|
||||
main.goToTasks()
|
||||
XCTAssertTrue(main.tasksTab.isSelected, "Tasks tab should be selected")
|
||||
|
||||
main.goToContractors()
|
||||
XCTAssertTrue(main.contractorsTab.isSelected, "Contractors tab should be selected")
|
||||
|
||||
main.goToDocuments()
|
||||
XCTAssertTrue(main.documentsTab.isSelected, "Documents tab should be selected")
|
||||
|
||||
main.goToResidences()
|
||||
XCTAssertTrue(main.residencesTab.isSelected, "Residences tab should be selected")
|
||||
}
|
||||
}
|
||||
164
iosApp/HoneyDueUITests/Docs/Failing_Suites_0_3_Rebuild_Plan.md
Normal file
164
iosApp/HoneyDueUITests/Docs/Failing_Suites_0_3_Rebuild_Plan.md
Normal file
@@ -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/HoneyDueKMM/iosApp/HoneyDueUITests/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`
|
||||
174
iosApp/HoneyDueUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md
Normal file
174
iosApp/HoneyDueUITests/Docs/Suite1_Failing_Test_Rebuild_Plan.md
Normal file
@@ -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.
|
||||
120
iosApp/HoneyDueUITests/Fixtures/TestFixtures.swift
Normal file
120
iosApp/HoneyDueUITests/Fixtures/TestFixtures.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
|
||||
/// Reusable test data builders for UI tests.
|
||||
///
|
||||
/// Each fixture generates unique names using random numbers or UUIDs
|
||||
/// to ensure test isolation and prevent cross-test interference.
|
||||
enum TestFixtures {
|
||||
|
||||
// MARK: - Users
|
||||
|
||||
struct TestUser {
|
||||
let firstName: String
|
||||
let lastName: String
|
||||
let email: String
|
||||
let password: String
|
||||
|
||||
/// Standard test user with unique email.
|
||||
static let standard = TestUser(
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "uitest_\(UUID().uuidString.prefix(8))@test.com",
|
||||
password: "TestPassword123!"
|
||||
)
|
||||
|
||||
/// Secondary test user for multi-user scenarios.
|
||||
static let secondary = TestUser(
|
||||
firstName: "Second",
|
||||
lastName: "Tester",
|
||||
email: "uitest2_\(UUID().uuidString.prefix(8))@test.com",
|
||||
password: "TestPassword456!"
|
||||
)
|
||||
|
||||
/// Pre-existing test user with known credentials (must exist on backend).
|
||||
static let existing = TestUser(
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "testuser",
|
||||
password: "TestPass123!"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Residences
|
||||
|
||||
struct TestResidence {
|
||||
let name: String
|
||||
let address: String
|
||||
let type: String
|
||||
|
||||
static let house = TestResidence(
|
||||
name: "Test House \(Int.random(in: 1000...9999))",
|
||||
address: "123 Test St",
|
||||
type: "House"
|
||||
)
|
||||
|
||||
static let apartment = TestResidence(
|
||||
name: "Test Apt \(Int.random(in: 1000...9999))",
|
||||
address: "456 Mock Ave",
|
||||
type: "Apartment"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Tasks
|
||||
|
||||
struct TestTask {
|
||||
let title: String
|
||||
let description: String
|
||||
let priority: String
|
||||
let category: String
|
||||
|
||||
static let basic = TestTask(
|
||||
title: "Test Task \(Int.random(in: 1000...9999))",
|
||||
description: "A test task",
|
||||
priority: "Medium",
|
||||
category: "Cleaning"
|
||||
)
|
||||
|
||||
static let urgent = TestTask(
|
||||
title: "Urgent Task \(Int.random(in: 1000...9999))",
|
||||
description: "An urgent task",
|
||||
priority: "High",
|
||||
category: "Repair"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Documents
|
||||
|
||||
struct TestDocument {
|
||||
let title: String
|
||||
let description: String
|
||||
let type: String
|
||||
|
||||
static let basic = TestDocument(
|
||||
title: "Test Doc \(Int.random(in: 1000...9999))",
|
||||
description: "A test document",
|
||||
type: "Manual"
|
||||
)
|
||||
|
||||
static let warranty = TestDocument(
|
||||
title: "Test Warranty \(Int.random(in: 1000...9999))",
|
||||
description: "A test warranty",
|
||||
type: "Warranty"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Contractors
|
||||
|
||||
struct TestContractor {
|
||||
let name: String
|
||||
let phone: String
|
||||
let email: String
|
||||
let specialty: String
|
||||
|
||||
static let basic = TestContractor(
|
||||
name: "Test Contractor \(Int.random(in: 1000...9999))",
|
||||
phone: "555-0100",
|
||||
email: "contractor@test.com",
|
||||
specialty: "Plumber"
|
||||
)
|
||||
}
|
||||
}
|
||||
159
iosApp/HoneyDueUITests/Framework/AuthenticatedTestCase.swift
Normal file
159
iosApp/HoneyDueUITests/Framework/AuthenticatedTestCase.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import XCTest
|
||||
|
||||
/// Base class for tests requiring a logged-in session against the real local backend.
|
||||
///
|
||||
/// By default, creates a fresh verified account via the API, launches the app
|
||||
/// (without `--ui-test-mock-auth`), and drives the UI through login.
|
||||
///
|
||||
/// Override `useSeededAccount` to log in with a pre-existing database account instead.
|
||||
/// Override `performUILogin` to skip the UI login step (if you only need the API session).
|
||||
///
|
||||
/// ## Data Seeding & Cleanup
|
||||
/// Use the `cleaner` property to seed data that auto-cleans in tearDown:
|
||||
/// ```
|
||||
/// let residence = cleaner.seedResidence(name: "My Test Home")
|
||||
/// let task = cleaner.seedTask(residenceId: residence.id)
|
||||
/// ```
|
||||
/// Or seed without tracking via `TestDataSeeder` and track manually:
|
||||
/// ```
|
||||
/// let res = TestDataSeeder.createResidence(token: session.token)
|
||||
/// cleaner.trackResidence(res.id)
|
||||
/// ```
|
||||
class AuthenticatedTestCase: BaseUITestCase {
|
||||
|
||||
/// The active test session, populated during setUp.
|
||||
var session: TestSession!
|
||||
|
||||
/// Tracks and cleans up resources created during the test.
|
||||
/// Initialized in setUp after the session is established.
|
||||
private(set) var cleaner: TestDataCleaner!
|
||||
|
||||
/// Override to `true` in subclasses that should use the pre-seeded admin account.
|
||||
var useSeededAccount: Bool { false }
|
||||
|
||||
/// Seeded account credentials. Override in subclasses that use a different seeded user.
|
||||
var seededUsername: String { "admin" }
|
||||
var seededPassword: String { "test1234" }
|
||||
|
||||
/// Override to `false` to skip driving the app through the login UI.
|
||||
var performUILogin: Bool { true }
|
||||
|
||||
/// No mock auth - we're testing against the real backend.
|
||||
override var additionalLaunchArguments: [String] { [] }
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Check backend reachability before anything else
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
}
|
||||
|
||||
// Create or login account via API
|
||||
if useSeededAccount {
|
||||
guard let s = TestAccountManager.loginSeededAccount(
|
||||
username: seededUsername,
|
||||
password: seededPassword
|
||||
) else {
|
||||
throw XCTSkip("Could not login seeded account '\(seededUsername)'")
|
||||
}
|
||||
session = s
|
||||
} else {
|
||||
guard let s = TestAccountManager.createVerifiedAccount() else {
|
||||
throw XCTSkip("Could not create verified test account")
|
||||
}
|
||||
session = s
|
||||
}
|
||||
|
||||
// Initialize the cleaner with the session token
|
||||
cleaner = TestDataCleaner(token: session.token)
|
||||
|
||||
// Launch the app (calls BaseUITestCase.setUpWithError which launches and waits for ready)
|
||||
try super.setUpWithError()
|
||||
|
||||
// Drive the UI through login if needed
|
||||
if performUILogin {
|
||||
loginViaUI()
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Clean up all tracked test data
|
||||
cleaner?.cleanAll()
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - UI Login
|
||||
|
||||
/// Navigate from onboarding welcome → login screen → type credentials → wait for main tabs.
|
||||
func loginViaUI() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.enterUsername(session.username)
|
||||
login.enterPassword(session.password)
|
||||
|
||||
// Tap the login button
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
|
||||
// Wait for either main tabs or verification screen
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
while Date() < deadline {
|
||||
if mainTabs.exists || tabBar.exists {
|
||||
return
|
||||
}
|
||||
// Check for email verification gate - if we hit it, enter the debug code
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
if verificationScreen.codeField.exists {
|
||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verificationScreen.submitCode()
|
||||
// Wait for main tabs after verification
|
||||
if mainTabs.waitForExistence(timeout: longTimeout) || tabBar.waitForExistence(timeout: 5) {
|
||||
return
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTFail("Failed to reach main app after login. Debug tree:\n\(app.debugDescription)")
|
||||
}
|
||||
|
||||
// MARK: - Tab Navigation
|
||||
|
||||
func navigateToTab(_ tab: String) {
|
||||
let tabButton = app.buttons[tab]
|
||||
if tabButton.waitForExistence(timeout: defaultTimeout) {
|
||||
tabButton.forceTap()
|
||||
} else {
|
||||
// Fallback: search tab bar buttons by label
|
||||
let label = tab.replacingOccurrences(of: "TabBar.", with: "")
|
||||
let byLabel = app.tabBars.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", label)
|
||||
).firstMatch
|
||||
byLabel.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
byLabel.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
func navigateToResidences() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.residencesTab)
|
||||
}
|
||||
|
||||
func navigateToTasks() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.tasksTab)
|
||||
}
|
||||
|
||||
func navigateToContractors() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.contractorsTab)
|
||||
}
|
||||
|
||||
func navigateToDocuments() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.documentsTab)
|
||||
}
|
||||
|
||||
func navigateToProfile() {
|
||||
navigateToTab(AccessibilityIdentifiers.Navigation.profileTab)
|
||||
}
|
||||
}
|
||||
124
iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift
Normal file
124
iosApp/HoneyDueUITests/Framework/BaseUITestCase.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import XCTest
|
||||
|
||||
class BaseUITestCase: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
|
||||
let shortTimeout: TimeInterval = 5
|
||||
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
|
||||
|
||||
var launchArguments = [
|
||||
"--ui-testing",
|
||||
"--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)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
if let run = testRun, !run.hasSucceeded {
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Failure-\(name)"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension XCUIElement {
|
||||
@discardableResult
|
||||
func waitForExistenceOrFail(
|
||||
timeout: TimeInterval,
|
||||
message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> XCUIElement {
|
||||
if !waitForExistence(timeout: timeout) {
|
||||
XCTFail(message ?? "Expected element to exist: \(self)", file: file, line: line)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func waitUntilHittable(
|
||||
timeout: TimeInterval,
|
||||
message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> XCUIElement {
|
||||
let predicate = NSPredicate(format: "exists == true AND hittable == true")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
|
||||
|
||||
if result != .completed {
|
||||
XCTFail(message ?? "Expected element to become hittable: \(self)", file: file, line: line)
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func waitForNonExistence(
|
||||
timeout: TimeInterval,
|
||||
message: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: self)
|
||||
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
|
||||
|
||||
if result != .completed {
|
||||
XCTFail(message ?? "Expected element to disappear: \(self)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func scrollIntoView(
|
||||
in scrollView: XCUIElement,
|
||||
maxSwipes: Int = 8,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if isHittable { return }
|
||||
|
||||
for _ in 0..<maxSwipes {
|
||||
scrollView.swipeUp()
|
||||
if isHittable { return }
|
||||
}
|
||||
|
||||
for _ in 0..<maxSwipes {
|
||||
scrollView.swipeDown()
|
||||
if isHittable { return }
|
||||
}
|
||||
|
||||
XCTFail("Failed to scroll element into view: \(self)", file: file, line: line)
|
||||
}
|
||||
|
||||
func forceTap(file: StaticString = #filePath, line: UInt = #line) {
|
||||
if isHittable {
|
||||
tap()
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
return
|
||||
}
|
||||
XCTFail("Expected element to exist before forceTap: \(self)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
183
iosApp/HoneyDueUITests/Framework/RebuildSupport.swift
Normal file
183
iosApp/HoneyDueUITests/Framework/RebuildSupport.swift
Normal file
@@ -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 MainTabScreenObject {
|
||||
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 = LoginScreenObject(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 = MainTabScreenObject(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)
|
||||
}
|
||||
}
|
||||
411
iosApp/HoneyDueUITests/Framework/ScreenObjects.swift
Normal file
411
iosApp/HoneyDueUITests/Framework/ScreenObjects.swift
Normal file
@@ -0,0 +1,411 @@
|
||||
import XCTest
|
||||
|
||||
struct UITestID {
|
||||
struct Root {
|
||||
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 {
|
||||
static let welcomeTitle = "Onboarding.WelcomeTitle"
|
||||
static let startFreshButton = "Onboarding.StartFreshButton"
|
||||
static let joinExistingButton = "Onboarding.JoinExistingButton"
|
||||
static let loginButton = "Onboarding.LoginButton"
|
||||
static let valuePropsContainer = "Onboarding.ValuePropsTitle"
|
||||
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
|
||||
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
|
||||
static let residenceNameField = "Onboarding.ResidenceNameField"
|
||||
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
|
||||
static let createAccountTitle = "Onboarding.CreateAccountTitle"
|
||||
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
|
||||
static let createAccountButton = "Onboarding.CreateAccountButton"
|
||||
static let backButton = "Onboarding.BackButton"
|
||||
static let skipButton = "Onboarding.SkipButton"
|
||||
static let progressIndicator = "Onboarding.ProgressIndicator"
|
||||
}
|
||||
|
||||
struct PasswordReset {
|
||||
static let emailField = "PasswordReset.EmailField"
|
||||
static let sendCodeButton = "PasswordReset.SendCodeButton"
|
||||
static let backToLoginButton = "PasswordReset.BackToLoginButton"
|
||||
static let codeField = "PasswordReset.CodeField"
|
||||
static let verifyCodeButton = "PasswordReset.VerifyCodeButton"
|
||||
static let resendCodeButton = "PasswordReset.ResendCodeButton"
|
||||
static let newPasswordField = "PasswordReset.NewPasswordField"
|
||||
static let confirmPasswordField = "PasswordReset.ConfirmPasswordField"
|
||||
static let resetButton = "PasswordReset.ResetButton"
|
||||
static let returnToLoginButton = "PasswordReset.ReturnToLoginButton"
|
||||
}
|
||||
|
||||
struct Auth {
|
||||
static let usernameField = "Login.UsernameField"
|
||||
static let passwordField = "Login.PasswordField"
|
||||
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
||||
static let loginButton = "Login.LoginButton"
|
||||
static let signUpButton = "Login.SignUpButton"
|
||||
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
struct RootScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
func waitForReady(timeout: TimeInterval = 15) {
|
||||
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWelcomeScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var onboardingRoot: XCUIElement { app.otherElements[UITestID.Root.onboarding] }
|
||||
private var startFreshButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch }
|
||||
private var joinExistingButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.joinExistingButton).firstMatch }
|
||||
private var loginButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.loginButton).firstMatch }
|
||||
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
onboardingRoot.waitForExistenceOrFail(timeout: timeout)
|
||||
if startFreshButton.waitForExistence(timeout: 2) {
|
||||
return
|
||||
}
|
||||
|
||||
for _ in 0..<4 {
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
}
|
||||
if startFreshButton.waitForExistence(timeout: 2) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !startFreshButton.waitForExistence(timeout: timeout) {
|
||||
XCTFail("Expected onboarding welcome entry point. Debug tree:\n\(app.debugDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func tapStartFresh() {
|
||||
startFreshButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapJoinExisting() {
|
||||
joinExistingButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapAlreadyHaveAccount() {
|
||||
loginButton.waitForExistenceOrFail(timeout: 10)
|
||||
if loginButton.isHittable {
|
||||
loginButton.tap()
|
||||
} else {
|
||||
loginButton.forceTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingValuePropsScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var container: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsContainer).firstMatch }
|
||||
private var continueButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsNextButton).firstMatch }
|
||||
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
container.waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
|
||||
func tapContinue() {
|
||||
continueButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapBack() {
|
||||
backButton.waitForExistenceOrFail(timeout: 10)
|
||||
backButton.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingNameResidenceScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var title: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceTitle).firstMatch }
|
||||
private var nameField: XCUIElement { app.textFields[UITestID.Onboarding.residenceNameField] }
|
||||
private var continueButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceContinueButton).firstMatch }
|
||||
private var backButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
title.waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
|
||||
func enterResidenceName(_ value: String) {
|
||||
nameField.waitUntilHittable(timeout: 10).tap()
|
||||
nameField.typeText(value)
|
||||
}
|
||||
|
||||
func tapContinue() {
|
||||
continueButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapBack() {
|
||||
backButton.waitForExistenceOrFail(timeout: 10)
|
||||
backButton.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingCreateAccountScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var title: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountTitle).firstMatch }
|
||||
private var expandEmailButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.emailSignUpExpandButton).firstMatch }
|
||||
private var createAccountButton: XCUIElement { app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountButton).firstMatch }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
title.waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
|
||||
func expandEmailSignup() {
|
||||
expandEmailButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func waitForCreateAccountButton(timeout: TimeInterval = 10) {
|
||||
createAccountButton.waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginScreenObject {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.usernameField] }
|
||||
private var passwordSecureField: XCUIElement { app.secureTextFields[UITestID.Auth.passwordField] }
|
||||
private var passwordVisibleField: XCUIElement { app.textFields[UITestID.Auth.passwordField] }
|
||||
private var loginButton: XCUIElement { app.buttons[UITestID.Auth.loginButton] }
|
||||
private var signUpButton: XCUIElement { app.buttons[UITestID.Auth.signUpButton] }
|
||||
private var forgotPasswordButton: XCUIElement { app.buttons[UITestID.Auth.forgotPasswordButton] }
|
||||
private var visibilityToggle: XCUIElement { app.buttons[UITestID.Auth.passwordVisibilityToggle] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
usernameField.waitForExistenceOrFail(timeout: timeout)
|
||||
loginButton.waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
|
||||
func enterUsername(_ username: String) {
|
||||
usernameField.waitUntilHittable(timeout: 10).tap()
|
||||
usernameField.typeText(username)
|
||||
}
|
||||
|
||||
func enterPassword(_ password: String) {
|
||||
if passwordSecureField.exists {
|
||||
passwordSecureField.tap()
|
||||
passwordSecureField.typeText(password)
|
||||
} else {
|
||||
passwordVisibleField.waitUntilHittable(timeout: 10).tap()
|
||||
passwordVisibleField.typeText(password)
|
||||
}
|
||||
}
|
||||
|
||||
func tapPasswordVisibilityToggle() {
|
||||
visibilityToggle.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapSignUp() {
|
||||
signUpButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapForgotPassword() {
|
||||
forgotPasswordButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func assertPasswordFieldVisible() {
|
||||
XCTAssertTrue(passwordVisibleField.waitForExistence(timeout: 5), "Expected visible password text field after toggle")
|
||||
}
|
||||
}
|
||||
|
||||
struct RegisterScreenObject {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var usernameField: XCUIElement { app.textFields[UITestID.Auth.registerUsernameField] }
|
||||
private var emailField: XCUIElement { app.textFields[UITestID.Auth.registerEmailField] }
|
||||
private var passwordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerPasswordField] }
|
||||
private var confirmPasswordField: XCUIElement { app.secureTextFields[UITestID.Auth.registerConfirmPasswordField] }
|
||||
private var registerButton: XCUIElement { app.buttons[UITestID.Auth.registerButton] }
|
||||
private var cancelButton: XCUIElement { app.buttons[UITestID.Auth.registerCancelButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
usernameField.waitForExistenceOrFail(timeout: timeout)
|
||||
registerButton.waitForExistenceOrFail(timeout: timeout)
|
||||
}
|
||||
|
||||
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)
|
||||
if !emailField.hasKeyboardFocus {
|
||||
emailField.forceTap()
|
||||
if !emailField.hasKeyboardFocus {
|
||||
advanceToNextField()
|
||||
emailField.forceTap()
|
||||
}
|
||||
}
|
||||
emailField.typeText(email)
|
||||
advanceToNextField()
|
||||
|
||||
passwordField.waitForExistenceOrFail(timeout: 10)
|
||||
if !passwordField.hasKeyboardFocus {
|
||||
passwordField.forceTap()
|
||||
}
|
||||
passwordField.typeText(password)
|
||||
advanceToNextField()
|
||||
|
||||
confirmPasswordField.waitForExistenceOrFail(timeout: 10)
|
||||
if !confirmPasswordField.hasKeyboardFocus {
|
||||
confirmPasswordField.forceTap()
|
||||
}
|
||||
confirmPasswordField.typeText(password)
|
||||
}
|
||||
|
||||
func tapCancel() {
|
||||
cancelButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Reset Screens
|
||||
|
||||
struct ForgotPasswordScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var emailField: XCUIElement { app.textFields[UITestID.PasswordReset.emailField] }
|
||||
private var sendCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.sendCodeButton] }
|
||||
private var backToLoginButton: XCUIElement { app.buttons[UITestID.PasswordReset.backToLoginButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
// Wait for the email field or the "Forgot Password?" title
|
||||
let emailLoaded = emailField.waitForExistence(timeout: timeout)
|
||||
if !emailLoaded {
|
||||
let title = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Forgot Password'")
|
||||
).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected forgot password screen to load")
|
||||
}
|
||||
}
|
||||
|
||||
func enterEmail(_ email: String) {
|
||||
emailField.waitUntilHittable(timeout: 10).tap()
|
||||
emailField.typeText(email)
|
||||
}
|
||||
|
||||
func tapSendCode() {
|
||||
sendCodeButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapBackToLogin() {
|
||||
backToLoginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
}
|
||||
|
||||
struct VerifyResetCodeScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
private var codeField: XCUIElement { app.textFields[UITestID.PasswordReset.codeField] }
|
||||
private var verifyCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.verifyCodeButton] }
|
||||
private var resendCodeButton: XCUIElement { app.buttons[UITestID.PasswordReset.resendCodeButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
let codeLoaded = codeField.waitForExistence(timeout: timeout)
|
||||
if !codeLoaded {
|
||||
let title = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Check Your Email'")
|
||||
).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected verify reset code screen to load")
|
||||
}
|
||||
}
|
||||
|
||||
func enterCode(_ code: String) {
|
||||
codeField.waitUntilHittable(timeout: 10).tap()
|
||||
codeField.typeText(code)
|
||||
}
|
||||
|
||||
func tapVerify() {
|
||||
verifyCodeButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapResendCode() {
|
||||
resendCodeButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetPasswordScreen {
|
||||
let app: XCUIApplication
|
||||
|
||||
// The new password field may be a SecureField or TextField depending on visibility toggle
|
||||
private var newPasswordSecureField: XCUIElement { app.secureTextFields[UITestID.PasswordReset.newPasswordField] }
|
||||
private var newPasswordVisibleField: XCUIElement { app.textFields[UITestID.PasswordReset.newPasswordField] }
|
||||
private var confirmPasswordSecureField: XCUIElement { app.secureTextFields[UITestID.PasswordReset.confirmPasswordField] }
|
||||
private var confirmPasswordVisibleField: XCUIElement { app.textFields[UITestID.PasswordReset.confirmPasswordField] }
|
||||
private var resetButton: XCUIElement { app.buttons[UITestID.PasswordReset.resetButton] }
|
||||
private var returnToLoginButton: XCUIElement { app.buttons[UITestID.PasswordReset.returnToLoginButton] }
|
||||
|
||||
func waitForLoad(timeout: TimeInterval = 15) {
|
||||
let loaded = newPasswordSecureField.waitForExistence(timeout: timeout)
|
||||
|| newPasswordVisibleField.waitForExistence(timeout: 3)
|
||||
if !loaded {
|
||||
let title = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Set New Password'")
|
||||
).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 5), "Expected reset password screen to load")
|
||||
}
|
||||
}
|
||||
|
||||
func enterNewPassword(_ password: String) {
|
||||
if newPasswordSecureField.exists {
|
||||
newPasswordSecureField.waitUntilHittable(timeout: 10).tap()
|
||||
newPasswordSecureField.typeText(password)
|
||||
} else {
|
||||
newPasswordVisibleField.waitUntilHittable(timeout: 10).tap()
|
||||
newPasswordVisibleField.typeText(password)
|
||||
}
|
||||
}
|
||||
|
||||
func enterConfirmPassword(_ password: String) {
|
||||
if confirmPasswordSecureField.exists {
|
||||
confirmPasswordSecureField.waitUntilHittable(timeout: 10).tap()
|
||||
confirmPasswordSecureField.typeText(password)
|
||||
} else {
|
||||
confirmPasswordVisibleField.waitUntilHittable(timeout: 10).tap()
|
||||
confirmPasswordVisibleField.typeText(password)
|
||||
}
|
||||
}
|
||||
|
||||
func tapReset() {
|
||||
resetButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
func tapReturnToLogin() {
|
||||
returnToLoginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
var isResetButtonEnabled: Bool {
|
||||
resetButton.waitForExistenceOrFail(timeout: 10)
|
||||
return resetButton.isEnabled
|
||||
}
|
||||
}
|
||||
505
iosApp/HoneyDueUITests/Framework/TestAccountAPIClient.swift
Normal file
505
iosApp/HoneyDueUITests/Framework/TestAccountAPIClient.swift
Normal file
@@ -0,0 +1,505 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
// MARK: - API Result Type
|
||||
|
||||
/// Result of an API call with status code access for error assertions.
|
||||
struct APIResult<T> {
|
||||
let data: T?
|
||||
let statusCode: Int
|
||||
let errorBody: String?
|
||||
|
||||
var succeeded: Bool { (200...299).contains(statusCode) }
|
||||
|
||||
/// Unwrap data or fail the test.
|
||||
func unwrap(file: StaticString = #filePath, line: UInt = #line) -> T {
|
||||
guard let data = data else {
|
||||
XCTFail("Expected data but got status \(statusCode): \(errorBody ?? "nil")", file: file, line: line)
|
||||
preconditionFailure("unwrap failed")
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth Response Types
|
||||
|
||||
struct TestUser: Decodable {
|
||||
let id: Int
|
||||
let username: String
|
||||
let email: String
|
||||
let firstName: String?
|
||||
let lastName: String?
|
||||
let isActive: Bool?
|
||||
let verified: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, email
|
||||
case firstName = "first_name"
|
||||
case lastName = "last_name"
|
||||
case isActive = "is_active"
|
||||
case verified
|
||||
}
|
||||
}
|
||||
|
||||
struct TestAuthResponse: Decodable {
|
||||
let token: String
|
||||
let user: TestUser
|
||||
let message: String?
|
||||
}
|
||||
|
||||
struct TestVerifyEmailResponse: Decodable {
|
||||
let message: String
|
||||
let verified: Bool
|
||||
}
|
||||
|
||||
struct TestVerifyResetCodeResponse: Decodable {
|
||||
let message: String
|
||||
let resetToken: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case resetToken = "reset_token"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestMessageResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct TestSession {
|
||||
let token: String
|
||||
let user: TestUser
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
|
||||
// MARK: - CRUD Response Types
|
||||
|
||||
/// Wrapper for create/update/get responses that include a summary.
|
||||
struct TestWrappedResponse<T: Decodable>: Decodable {
|
||||
let data: T
|
||||
}
|
||||
|
||||
struct TestResidence: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let ownerId: Int?
|
||||
let streetAddress: String?
|
||||
let city: String?
|
||||
let stateProvince: String?
|
||||
let postalCode: String?
|
||||
let isPrimary: Bool?
|
||||
let isActive: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name
|
||||
case ownerId = "owner_id"
|
||||
case streetAddress = "street_address"
|
||||
case city
|
||||
case stateProvince = "state_province"
|
||||
case postalCode = "postal_code"
|
||||
case isPrimary = "is_primary"
|
||||
case isActive = "is_active"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestTask: Decodable {
|
||||
let id: Int
|
||||
let residenceId: Int
|
||||
let title: String
|
||||
let description: String?
|
||||
let inProgress: Bool?
|
||||
let isCancelled: Bool?
|
||||
let isArchived: Bool?
|
||||
let kanbanColumn: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, description
|
||||
case residenceId = "residence_id"
|
||||
case inProgress = "in_progress"
|
||||
case isCancelled = "is_cancelled"
|
||||
case isArchived = "is_archived"
|
||||
case kanbanColumn = "kanban_column"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestContractor: Decodable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let company: String?
|
||||
let phone: String?
|
||||
let email: String?
|
||||
let isFavorite: Bool?
|
||||
let isActive: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, company, phone, email
|
||||
case isFavorite = "is_favorite"
|
||||
case isActive = "is_active"
|
||||
}
|
||||
}
|
||||
|
||||
struct TestDocument: Decodable {
|
||||
let id: Int
|
||||
let residenceId: Int
|
||||
let title: String
|
||||
let documentType: String?
|
||||
let isActive: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title
|
||||
case residenceId = "residence_id"
|
||||
case documentType = "document_type"
|
||||
case isActive = "is_active"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Client
|
||||
|
||||
enum TestAccountAPIClient {
|
||||
static let baseURL = "http://127.0.0.1:8000/api"
|
||||
static let debugVerificationCode = "123456"
|
||||
|
||||
// MARK: - Auth Methods
|
||||
|
||||
static func register(username: String, email: String, password: String) -> TestAuthResponse? {
|
||||
let body: [String: Any] = [
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password
|
||||
]
|
||||
return performRequest(method: "POST", path: "/auth/register/", body: body, responseType: TestAuthResponse.self)
|
||||
}
|
||||
|
||||
static func login(username: String, password: String) -> TestAuthResponse? {
|
||||
let body: [String: Any] = ["username": username, "password": password]
|
||||
return performRequest(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
|
||||
}
|
||||
|
||||
static func verifyEmail(token: String) -> TestVerifyEmailResponse? {
|
||||
let body: [String: Any] = ["code": debugVerificationCode]
|
||||
return performRequest(method: "POST", path: "/auth/verify-email/", body: body, token: token, responseType: TestVerifyEmailResponse.self)
|
||||
}
|
||||
|
||||
static func getCurrentUser(token: String) -> TestUser? {
|
||||
return performRequest(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
|
||||
}
|
||||
|
||||
static func forgotPassword(email: String) -> TestMessageResponse? {
|
||||
let body: [String: Any] = ["email": email]
|
||||
return performRequest(method: "POST", path: "/auth/forgot-password/", body: body, responseType: TestMessageResponse.self)
|
||||
}
|
||||
|
||||
static func verifyResetCode(email: String) -> TestVerifyResetCodeResponse? {
|
||||
let body: [String: Any] = ["email": email, "code": debugVerificationCode]
|
||||
return performRequest(method: "POST", path: "/auth/verify-reset-code/", body: body, responseType: TestVerifyResetCodeResponse.self)
|
||||
}
|
||||
|
||||
static func resetPassword(resetToken: String, newPassword: String) -> TestMessageResponse? {
|
||||
let body: [String: Any] = ["reset_token": resetToken, "new_password": newPassword]
|
||||
return performRequest(method: "POST", path: "/auth/reset-password/", body: body, responseType: TestMessageResponse.self)
|
||||
}
|
||||
|
||||
static func logout(token: String) -> TestMessageResponse? {
|
||||
return performRequest(method: "POST", path: "/auth/logout/", token: token, responseType: TestMessageResponse.self)
|
||||
}
|
||||
|
||||
/// Convenience: register + verify + re-login, returns ready session.
|
||||
static func createVerifiedAccount(username: String, email: String, password: String) -> TestSession? {
|
||||
guard let registerResponse = register(username: username, email: email, password: password) else { return nil }
|
||||
guard verifyEmail(token: registerResponse.token) != nil else { return nil }
|
||||
guard let loginResponse = login(username: username, password: password) else { return nil }
|
||||
return TestSession(token: loginResponse.token, user: loginResponse.user, username: username, password: password)
|
||||
}
|
||||
|
||||
// MARK: - Auth with Status Code
|
||||
|
||||
/// Login returning full APIResult so callers can assert on 401, 400, etc.
|
||||
static func loginWithResult(username: String, password: String) -> APIResult<TestAuthResponse> {
|
||||
let body: [String: Any] = ["username": username, "password": password]
|
||||
return performRequestWithResult(method: "POST", path: "/auth/login/", body: body, responseType: TestAuthResponse.self)
|
||||
}
|
||||
|
||||
/// Hit a protected endpoint without a token to get the 401.
|
||||
static func getCurrentUserWithResult(token: String?) -> APIResult<TestUser> {
|
||||
return performRequestWithResult(method: "GET", path: "/auth/me/", token: token, responseType: TestUser.self)
|
||||
}
|
||||
|
||||
// MARK: - Residence CRUD
|
||||
|
||||
static func createResidence(token: String, name: String, fields: [String: Any] = [:]) -> TestResidence? {
|
||||
var body: [String: Any] = ["name": name]
|
||||
for (k, v) in fields { body[k] = v }
|
||||
let wrapped: TestWrappedResponse<TestResidence>? = performRequest(
|
||||
method: "POST", path: "/residences/", body: body, token: token,
|
||||
responseType: TestWrappedResponse<TestResidence>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
static func listResidences(token: String) -> [TestResidence]? {
|
||||
return performRequest(method: "GET", path: "/residences/", token: token, responseType: [TestResidence].self)
|
||||
}
|
||||
|
||||
static func updateResidence(token: String, id: Int, fields: [String: Any]) -> TestResidence? {
|
||||
let wrapped: TestWrappedResponse<TestResidence>? = performRequest(
|
||||
method: "PUT", path: "/residences/\(id)/", body: fields, token: token,
|
||||
responseType: TestWrappedResponse<TestResidence>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
static func deleteResidence(token: String, id: Int) -> Bool {
|
||||
let result: APIResult<TestWrappedResponse<String>> = performRequestWithResult(
|
||||
method: "DELETE", path: "/residences/\(id)/", token: token,
|
||||
responseType: TestWrappedResponse<String>.self
|
||||
)
|
||||
return result.succeeded
|
||||
}
|
||||
|
||||
// MARK: - Task CRUD
|
||||
|
||||
static func createTask(token: String, residenceId: Int, title: String, fields: [String: Any] = [:]) -> TestTask? {
|
||||
var body: [String: Any] = ["residence_id": residenceId, "title": title]
|
||||
for (k, v) in fields { body[k] = v }
|
||||
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
|
||||
method: "POST", path: "/tasks/", body: body, token: token,
|
||||
responseType: TestWrappedResponse<TestTask>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
static func listTasks(token: String) -> [TestTask]? {
|
||||
return performRequest(method: "GET", path: "/tasks/", token: token, responseType: [TestTask].self)
|
||||
}
|
||||
|
||||
static func listTasksByResidence(token: String, residenceId: Int) -> [TestTask]? {
|
||||
return performRequest(
|
||||
method: "GET", path: "/tasks/by-residence/\(residenceId)/", token: token,
|
||||
responseType: [TestTask].self
|
||||
)
|
||||
}
|
||||
|
||||
static func updateTask(token: String, id: Int, fields: [String: Any]) -> TestTask? {
|
||||
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
|
||||
method: "PUT", path: "/tasks/\(id)/", body: fields, token: token,
|
||||
responseType: TestWrappedResponse<TestTask>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
static func deleteTask(token: String, id: Int) -> Bool {
|
||||
let result: APIResult<TestWrappedResponse<String>> = performRequestWithResult(
|
||||
method: "DELETE", path: "/tasks/\(id)/", token: token,
|
||||
responseType: TestWrappedResponse<String>.self
|
||||
)
|
||||
return result.succeeded
|
||||
}
|
||||
|
||||
static func markTaskInProgress(token: String, id: Int) -> TestTask? {
|
||||
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
|
||||
method: "POST", path: "/tasks/\(id)/mark-in-progress/", token: token,
|
||||
responseType: TestWrappedResponse<TestTask>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
static func cancelTask(token: String, id: Int) -> TestTask? {
|
||||
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
|
||||
method: "POST", path: "/tasks/\(id)/cancel/", token: token,
|
||||
responseType: TestWrappedResponse<TestTask>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
static func uncancelTask(token: String, id: Int) -> TestTask? {
|
||||
let wrapped: TestWrappedResponse<TestTask>? = performRequest(
|
||||
method: "POST", path: "/tasks/\(id)/uncancel/", token: token,
|
||||
responseType: TestWrappedResponse<TestTask>.self
|
||||
)
|
||||
return wrapped?.data
|
||||
}
|
||||
|
||||
// MARK: - Contractor CRUD
|
||||
|
||||
static func createContractor(token: String, name: String, fields: [String: Any] = [:]) -> TestContractor? {
|
||||
var body: [String: Any] = ["name": name]
|
||||
for (k, v) in fields { body[k] = v }
|
||||
return performRequest(method: "POST", path: "/contractors/", body: body, token: token, responseType: TestContractor.self)
|
||||
}
|
||||
|
||||
static func listContractors(token: String) -> [TestContractor]? {
|
||||
return performRequest(method: "GET", path: "/contractors/", token: token, responseType: [TestContractor].self)
|
||||
}
|
||||
|
||||
static func updateContractor(token: String, id: Int, fields: [String: Any]) -> TestContractor? {
|
||||
return performRequest(method: "PUT", path: "/contractors/\(id)/", body: fields, token: token, responseType: TestContractor.self)
|
||||
}
|
||||
|
||||
static func deleteContractor(token: String, id: Int) -> Bool {
|
||||
let result: APIResult<TestMessageResponse> = performRequestWithResult(
|
||||
method: "DELETE", path: "/contractors/\(id)/", token: token,
|
||||
responseType: TestMessageResponse.self
|
||||
)
|
||||
return result.succeeded
|
||||
}
|
||||
|
||||
static func toggleContractorFavorite(token: String, id: Int) -> TestContractor? {
|
||||
return performRequest(method: "POST", path: "/contractors/\(id)/toggle-favorite/", token: token, responseType: TestContractor.self)
|
||||
}
|
||||
|
||||
// MARK: - Document CRUD
|
||||
|
||||
static func createDocument(token: String, residenceId: Int, title: String, documentType: String = "Other", fields: [String: Any] = [:]) -> TestDocument? {
|
||||
var body: [String: Any] = ["residence_id": residenceId, "title": title, "document_type": documentType]
|
||||
for (k, v) in fields { body[k] = v }
|
||||
return performRequest(method: "POST", path: "/documents/", body: body, token: token, responseType: TestDocument.self)
|
||||
}
|
||||
|
||||
static func listDocuments(token: String) -> [TestDocument]? {
|
||||
return performRequest(method: "GET", path: "/documents/", token: token, responseType: [TestDocument].self)
|
||||
}
|
||||
|
||||
static func updateDocument(token: String, id: Int, fields: [String: Any]) -> TestDocument? {
|
||||
return performRequest(method: "PUT", path: "/documents/\(id)/", body: fields, token: token, responseType: TestDocument.self)
|
||||
}
|
||||
|
||||
static func deleteDocument(token: String, id: Int) -> Bool {
|
||||
let result: APIResult<TestMessageResponse> = performRequestWithResult(
|
||||
method: "DELETE", path: "/documents/\(id)/", token: token,
|
||||
responseType: TestMessageResponse.self
|
||||
)
|
||||
return result.succeeded
|
||||
}
|
||||
|
||||
// MARK: - Raw Request (for custom/edge-case assertions)
|
||||
|
||||
/// Make a raw request and return the full APIResult with status code.
|
||||
static func rawRequest(method: String, path: String, body: [String: Any]? = nil, token: String? = nil) -> APIResult<Data> {
|
||||
guard let url = URL(string: "\(baseURL)\(path)") else {
|
||||
return APIResult(data: nil, statusCode: 0, errorBody: "Invalid URL")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 15
|
||||
|
||||
if let token = token {
|
||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
if let body = body {
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result = APIResult<Data>(data: nil, statusCode: 0, errorBody: "No response")
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
defer { semaphore.signal() }
|
||||
if let error = error {
|
||||
result = APIResult(data: nil, statusCode: 0, errorBody: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let bodyStr = data.flatMap { String(data: $0, encoding: .utf8) }
|
||||
if (200...299).contains(status) {
|
||||
result = APIResult(data: data, statusCode: status, errorBody: nil)
|
||||
} else {
|
||||
result = APIResult(data: nil, statusCode: status, errorBody: bodyStr)
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Reachability
|
||||
|
||||
static func isBackendReachable() -> Bool {
|
||||
let result = rawRequest(method: "POST", path: "/auth/login/", body: [:])
|
||||
// Any HTTP response (even 400) means the backend is up
|
||||
return result.statusCode > 0
|
||||
}
|
||||
|
||||
// MARK: - Private Core
|
||||
|
||||
/// Perform a request and return the decoded value, or nil on failure (logs errors).
|
||||
private static func performRequest<T: Decodable>(
|
||||
method: String,
|
||||
path: String,
|
||||
body: [String: Any]? = nil,
|
||||
token: String? = nil,
|
||||
responseType: T.Type
|
||||
) -> T? {
|
||||
let result = performRequestWithResult(method: method, path: path, body: body, token: token, responseType: responseType)
|
||||
return result.data
|
||||
}
|
||||
|
||||
/// Perform a request and return the full APIResult with status code.
|
||||
static func performRequestWithResult<T: Decodable>(
|
||||
method: String,
|
||||
path: String,
|
||||
body: [String: Any]? = nil,
|
||||
token: String? = nil,
|
||||
responseType: T.Type
|
||||
) -> APIResult<T> {
|
||||
guard let url = URL(string: "\(baseURL)\(path)") else {
|
||||
return APIResult(data: nil, statusCode: 0, errorBody: "Invalid URL: \(baseURL)\(path)")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 15
|
||||
|
||||
if let token = token {
|
||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
if let body = body {
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result = APIResult<T>(data: nil, statusCode: 0, errorBody: "No response")
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
defer { semaphore.signal() }
|
||||
|
||||
if let error = error {
|
||||
print("[TestAPI] \(method) \(path) error: \(error.localizedDescription)")
|
||||
result = APIResult(data: nil, statusCode: 0, errorBody: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
|
||||
guard let data = data else {
|
||||
print("[TestAPI] \(method) \(path) no data (status \(statusCode))")
|
||||
result = APIResult(data: nil, statusCode: statusCode, errorBody: "No data")
|
||||
return
|
||||
}
|
||||
|
||||
let bodyStr = String(data: data, encoding: .utf8) ?? "<binary>"
|
||||
|
||||
guard (200...299).contains(statusCode) else {
|
||||
print("[TestAPI] \(method) \(path) status \(statusCode): \(bodyStr)")
|
||||
result = APIResult(data: nil, statusCode: statusCode, errorBody: bodyStr)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(T.self, from: data)
|
||||
result = APIResult(data: decoded, statusCode: statusCode, errorBody: nil)
|
||||
} catch {
|
||||
print("[TestAPI] \(method) \(path) decode error: \(error)\nBody: \(bodyStr)")
|
||||
result = APIResult(data: nil, statusCode: statusCode, errorBody: "Decode error: \(error)")
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
127
iosApp/HoneyDueUITests/Framework/TestAccountManager.swift
Normal file
127
iosApp/HoneyDueUITests/Framework/TestAccountManager.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// High-level account lifecycle management for UI tests.
|
||||
enum TestAccountManager {
|
||||
|
||||
// MARK: - Credential Generation
|
||||
|
||||
/// Generate unique credentials with a timestamp + random suffix to avoid collisions.
|
||||
static func uniqueCredentials(prefix: String = "uit") -> (username: String, email: String, password: String) {
|
||||
let stamp = Int(Date().timeIntervalSince1970)
|
||||
let random = Int.random(in: 1000...9999)
|
||||
let username = "\(prefix)_\(stamp)_\(random)"
|
||||
let email = "\(username)@test.example.com"
|
||||
let password = "Pass\(stamp)!"
|
||||
return (username, email, password)
|
||||
}
|
||||
|
||||
// MARK: - Account Creation
|
||||
|
||||
/// Create a verified account via the backend API. Returns a ready-to-use session.
|
||||
/// Calls `XCTFail` and returns nil if any step fails.
|
||||
static func createVerifiedAccount(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
let creds = uniqueCredentials()
|
||||
|
||||
guard let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: creds.username,
|
||||
email: creds.email,
|
||||
password: creds.password
|
||||
) else {
|
||||
XCTFail("Failed to create verified account for \(creds.username)", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/// Create an unverified account (register only, no email verification).
|
||||
/// Useful for testing the verification gate.
|
||||
static func createUnverifiedAccount(
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
let creds = uniqueCredentials()
|
||||
|
||||
guard let response = TestAccountAPIClient.register(
|
||||
username: creds.username,
|
||||
email: creds.email,
|
||||
password: creds.password
|
||||
) else {
|
||||
XCTFail("Failed to register unverified account for \(creds.username)", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return TestSession(
|
||||
token: response.token,
|
||||
user: response.user,
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Seeded Accounts
|
||||
|
||||
/// Login with a pre-seeded account that already exists in the database.
|
||||
static func loginSeededAccount(
|
||||
username: String = "admin",
|
||||
password: String = "test1234",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestSession? {
|
||||
guard let response = TestAccountAPIClient.login(username: username, password: password) else {
|
||||
XCTFail("Failed to login seeded account '\(username)'", file: file, line: line)
|
||||
return nil
|
||||
}
|
||||
|
||||
return TestSession(
|
||||
token: response.token,
|
||||
user: response.user,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Password Reset
|
||||
|
||||
/// Execute the full forgot→verify→reset cycle via the backend API.
|
||||
static func resetPassword(
|
||||
email: String,
|
||||
newPassword: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> Bool {
|
||||
guard TestAccountAPIClient.forgotPassword(email: email) != nil else {
|
||||
XCTFail("Forgot password request failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
guard let verifyResponse = TestAccountAPIClient.verifyResetCode(email: email) else {
|
||||
XCTFail("Verify reset code failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
guard TestAccountAPIClient.resetPassword(resetToken: verifyResponse.resetToken, newPassword: newPassword) != nil else {
|
||||
XCTFail("Reset password failed for \(email)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Token Management
|
||||
|
||||
/// Invalidate a session token via the logout API.
|
||||
static func invalidateToken(
|
||||
_ session: TestSession,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if TestAccountAPIClient.logout(token: session.token) == nil {
|
||||
XCTFail("Failed to invalidate token for \(session.username)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
130
iosApp/HoneyDueUITests/Framework/TestDataCleaner.swift
Normal file
130
iosApp/HoneyDueUITests/Framework/TestDataCleaner.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Tracks and cleans up resources created during integration tests.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```
|
||||
/// let cleaner = TestDataCleaner(token: session.token)
|
||||
/// let residence = TestDataSeeder.createResidence(token: session.token)
|
||||
/// cleaner.trackResidence(residence.id)
|
||||
/// // ... test runs ...
|
||||
/// cleaner.cleanAll() // called in tearDown
|
||||
/// ```
|
||||
class TestDataCleaner {
|
||||
private let token: String
|
||||
private var residenceIds: [Int] = []
|
||||
private var taskIds: [Int] = []
|
||||
private var contractorIds: [Int] = []
|
||||
private var documentIds: [Int] = []
|
||||
|
||||
init(token: String) {
|
||||
self.token = token
|
||||
}
|
||||
|
||||
// MARK: - Track Resources
|
||||
|
||||
func trackResidence(_ id: Int) {
|
||||
residenceIds.append(id)
|
||||
}
|
||||
|
||||
func trackTask(_ id: Int) {
|
||||
taskIds.append(id)
|
||||
}
|
||||
|
||||
func trackContractor(_ id: Int) {
|
||||
contractorIds.append(id)
|
||||
}
|
||||
|
||||
func trackDocument(_ id: Int) {
|
||||
documentIds.append(id)
|
||||
}
|
||||
|
||||
// MARK: - Seed + Track (Convenience)
|
||||
|
||||
/// Create a residence and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedResidence(name: String? = nil) -> TestResidence {
|
||||
let residence = TestDataSeeder.createResidence(token: token, name: name)
|
||||
trackResidence(residence.id)
|
||||
return residence
|
||||
}
|
||||
|
||||
/// Create a task and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedTask(residenceId: Int, title: String? = nil, fields: [String: Any] = [:]) -> TestTask {
|
||||
let task = TestDataSeeder.createTask(token: token, residenceId: residenceId, title: title, fields: fields)
|
||||
trackTask(task.id)
|
||||
return task
|
||||
}
|
||||
|
||||
/// Create a contractor and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedContractor(name: String? = nil, fields: [String: Any] = [:]) -> TestContractor {
|
||||
let contractor = TestDataSeeder.createContractor(token: token, name: name, fields: fields)
|
||||
trackContractor(contractor.id)
|
||||
return contractor
|
||||
}
|
||||
|
||||
/// Create a document and automatically track it for cleanup.
|
||||
@discardableResult
|
||||
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "Other") -> TestDocument {
|
||||
let document = TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
|
||||
trackDocument(document.id)
|
||||
return document
|
||||
}
|
||||
|
||||
/// Create a residence with tasks, all tracked for cleanup.
|
||||
func seedResidenceWithTasks(residenceName: String? = nil, taskCount: Int = 3) -> (residence: TestResidence, tasks: [TestTask]) {
|
||||
let result = TestDataSeeder.createResidenceWithTasks(token: token, residenceName: residenceName, taskCount: taskCount)
|
||||
trackResidence(result.residence.id)
|
||||
result.tasks.forEach { trackTask($0.id) }
|
||||
return result
|
||||
}
|
||||
|
||||
/// Create a full residence with task, contractor, and document, all tracked.
|
||||
func seedFullResidence() -> (residence: TestResidence, task: TestTask, contractor: TestContractor, document: TestDocument) {
|
||||
let result = TestDataSeeder.createFullResidence(token: token)
|
||||
trackResidence(result.residence.id)
|
||||
trackTask(result.task.id)
|
||||
trackContractor(result.contractor.id)
|
||||
trackDocument(result.document.id)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Delete all tracked resources in reverse dependency order.
|
||||
/// Documents and tasks first (they depend on residences), then contractors, then residences.
|
||||
/// Failures are logged but don't fail the test — cleanup is best-effort.
|
||||
func cleanAll() {
|
||||
// Delete documents first (depend on residences)
|
||||
for id in documentIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteDocument(token: token, id: id)
|
||||
}
|
||||
documentIds.removeAll()
|
||||
|
||||
// Delete tasks (depend on residences)
|
||||
for id in taskIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteTask(token: token, id: id)
|
||||
}
|
||||
taskIds.removeAll()
|
||||
|
||||
// Delete contractors (independent, but clean before residences)
|
||||
for id in contractorIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteContractor(token: token, id: id)
|
||||
}
|
||||
contractorIds.removeAll()
|
||||
|
||||
// Delete residences last
|
||||
for id in residenceIds.reversed() {
|
||||
_ = TestAccountAPIClient.deleteResidence(token: token, id: id)
|
||||
}
|
||||
residenceIds.removeAll()
|
||||
}
|
||||
|
||||
/// Number of tracked resources (for debugging).
|
||||
var trackedCount: Int {
|
||||
residenceIds.count + taskIds.count + contractorIds.count + documentIds.count
|
||||
}
|
||||
}
|
||||
235
iosApp/HoneyDueUITests/Framework/TestDataSeeder.swift
Normal file
235
iosApp/HoneyDueUITests/Framework/TestDataSeeder.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
/// Seeds backend data for integration tests via API calls.
|
||||
///
|
||||
/// All methods require a valid auth token from a `TestSession`.
|
||||
/// Created resources are tracked so `TestDataCleaner` can remove them in teardown.
|
||||
enum TestDataSeeder {
|
||||
|
||||
// MARK: - Residence Seeding
|
||||
|
||||
/// Create a residence with just a name. Returns the residence or fails the test.
|
||||
@discardableResult
|
||||
static func createResidence(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestResidence {
|
||||
let residenceName = name ?? "Test Residence \(uniqueSuffix())"
|
||||
guard let residence = TestAccountAPIClient.createResidence(token: token, name: residenceName) else {
|
||||
XCTFail("Failed to seed residence '\(residenceName)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return residence
|
||||
}
|
||||
|
||||
/// Create a residence with address fields populated.
|
||||
@discardableResult
|
||||
static func createResidenceWithAddress(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
street: String = "123 Test St",
|
||||
city: String = "Testville",
|
||||
state: String = "TX",
|
||||
postalCode: String = "78701",
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestResidence {
|
||||
let residenceName = name ?? "Addressed Residence \(uniqueSuffix())"
|
||||
guard let residence = TestAccountAPIClient.createResidence(
|
||||
token: token,
|
||||
name: residenceName,
|
||||
fields: [
|
||||
"street_address": street,
|
||||
"city": city,
|
||||
"state_province": state,
|
||||
"postal_code": postalCode
|
||||
]
|
||||
) else {
|
||||
XCTFail("Failed to seed residence with address '\(residenceName)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return residence
|
||||
}
|
||||
|
||||
// MARK: - Task Seeding
|
||||
|
||||
/// Create a task in a residence. Returns the task or fails the test.
|
||||
@discardableResult
|
||||
static func createTask(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestTask {
|
||||
let taskTitle = title ?? "Test Task \(uniqueSuffix())"
|
||||
guard let task = TestAccountAPIClient.createTask(
|
||||
token: token,
|
||||
residenceId: residenceId,
|
||||
title: taskTitle,
|
||||
fields: fields
|
||||
) else {
|
||||
XCTFail("Failed to seed task '\(taskTitle)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
/// Create a task with a due date.
|
||||
@discardableResult
|
||||
static func createTaskWithDueDate(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
daysFromNow: Int = 7,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestTask {
|
||||
let dueDate = Calendar.current.date(byAdding: .day, value: daysFromNow, to: Date())!
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withFullDate]
|
||||
let dueDateStr = formatter.string(from: dueDate)
|
||||
|
||||
return createTask(
|
||||
token: token,
|
||||
residenceId: residenceId,
|
||||
title: title ?? "Due Task \(uniqueSuffix())",
|
||||
fields: ["due_date": dueDateStr],
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a cancelled task (create then cancel via API).
|
||||
@discardableResult
|
||||
static func createCancelledTask(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestTask {
|
||||
let task = createTask(token: token, residenceId: residenceId, title: title ?? "Cancelled Task \(uniqueSuffix())", file: file, line: line)
|
||||
guard let cancelled = TestAccountAPIClient.cancelTask(token: token, id: task.id) else {
|
||||
XCTFail("Failed to cancel seeded task \(task.id)", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// MARK: - Contractor Seeding
|
||||
|
||||
/// Create a contractor. Returns the contractor or fails the test.
|
||||
@discardableResult
|
||||
static func createContractor(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestContractor {
|
||||
let contractorName = name ?? "Test Contractor \(uniqueSuffix())"
|
||||
guard let contractor = TestAccountAPIClient.createContractor(
|
||||
token: token,
|
||||
name: contractorName,
|
||||
fields: fields
|
||||
) else {
|
||||
XCTFail("Failed to seed contractor '\(contractorName)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return contractor
|
||||
}
|
||||
|
||||
/// Create a contractor with contact info.
|
||||
@discardableResult
|
||||
static func createContractorWithContact(
|
||||
token: String,
|
||||
name: String? = nil,
|
||||
company: String = "Test Co",
|
||||
phone: String = "555-0100",
|
||||
email: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestContractor {
|
||||
let contractorName = name ?? "Contact Contractor \(uniqueSuffix())"
|
||||
let contactEmail = email ?? "\(uniqueSuffix())@contractor.test"
|
||||
return createContractor(
|
||||
token: token,
|
||||
name: contractorName,
|
||||
fields: ["company": company, "phone": phone, "email": contactEmail],
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Document Seeding
|
||||
|
||||
/// Create a document in a residence. Returns the document or fails the test.
|
||||
@discardableResult
|
||||
static func createDocument(
|
||||
token: String,
|
||||
residenceId: Int,
|
||||
title: String? = nil,
|
||||
documentType: String = "Other",
|
||||
fields: [String: Any] = [:],
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> TestDocument {
|
||||
let docTitle = title ?? "Test Doc \(uniqueSuffix())"
|
||||
guard let document = TestAccountAPIClient.createDocument(
|
||||
token: token,
|
||||
residenceId: residenceId,
|
||||
title: docTitle,
|
||||
documentType: documentType,
|
||||
fields: fields
|
||||
) else {
|
||||
XCTFail("Failed to seed document '\(docTitle)'", file: file, line: line)
|
||||
preconditionFailure("seeding failed")
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
// MARK: - Composite Scenarios
|
||||
|
||||
/// Create a residence with N tasks already in it. Returns (residence, [tasks]).
|
||||
static func createResidenceWithTasks(
|
||||
token: String,
|
||||
residenceName: String? = nil,
|
||||
taskCount: Int = 3,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> (residence: TestResidence, tasks: [TestTask]) {
|
||||
let residence = createResidence(token: token, name: residenceName, file: file, line: line)
|
||||
var tasks: [TestTask] = []
|
||||
for i in 1...taskCount {
|
||||
let task = createTask(token: token, residenceId: residence.id, title: "Task \(i) \(uniqueSuffix())", file: file, line: line)
|
||||
tasks.append(task)
|
||||
}
|
||||
return (residence, tasks)
|
||||
}
|
||||
|
||||
/// Create a residence with a contractor and a document. Returns all three.
|
||||
static func createFullResidence(
|
||||
token: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> (residence: TestResidence, task: TestTask, contractor: TestContractor, document: TestDocument) {
|
||||
let residence = createResidence(token: token, file: file, line: line)
|
||||
let task = createTask(token: token, residenceId: residence.id, file: file, line: line)
|
||||
let contractor = createContractor(token: token, file: file, line: line)
|
||||
let document = createDocument(token: token, residenceId: residence.id, file: file, line: line)
|
||||
return (residence, task, contractor, document)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func uniqueSuffix() -> String {
|
||||
let stamp = Int(Date().timeIntervalSince1970) % 100000
|
||||
let random = Int.random(in: 100...999)
|
||||
return "\(stamp)_\(random)"
|
||||
}
|
||||
}
|
||||
95
iosApp/HoneyDueUITests/Framework/TestFlows.swift
Normal file
95
iosApp/HoneyDueUITests/Framework/TestFlows.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
|
||||
enum TestFlows {
|
||||
@discardableResult
|
||||
static func navigateToLoginFromOnboarding(app: XCUIApplication) -> LoginScreenObject {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapAlreadyHaveAccount()
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad()
|
||||
return login
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func navigateStartFreshToCreateAccount(
|
||||
app: XCUIApplication,
|
||||
residenceName: String = "UI Test Residence"
|
||||
) -> OnboardingCreateAccountScreen {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad()
|
||||
valueProps.tapContinue()
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad()
|
||||
nameResidence.enterResidenceName(residenceName)
|
||||
nameResidence.tapContinue()
|
||||
|
||||
let createAccount = OnboardingCreateAccountScreen(app: app)
|
||||
createAccount.waitForLoad()
|
||||
return createAccount
|
||||
}
|
||||
|
||||
/// Type credentials into the login screen and tap login.
|
||||
/// Assumes the app is already showing the login screen.
|
||||
static func loginWithCredentials(app: XCUIApplication, username: String, password: String) {
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad()
|
||||
login.enterUsername(username)
|
||||
login.enterPassword(password)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: 10).tap()
|
||||
}
|
||||
|
||||
/// Drive the full forgot password → verify code → reset password flow using the debug code.
|
||||
static func completeForgotPasswordFlow(
|
||||
app: XCUIApplication,
|
||||
email: String,
|
||||
newPassword: String,
|
||||
confirmPassword: String? = nil
|
||||
) {
|
||||
let confirm = confirmPassword ?? newPassword
|
||||
|
||||
// Step 1: Enter email on forgot password screen
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Step 2: Enter debug verification code
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Step 3: Enter new password
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
resetScreen.waitForLoad()
|
||||
resetScreen.enterNewPassword(newPassword)
|
||||
resetScreen.enterConfirmPassword(confirm)
|
||||
resetScreen.tapReset()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func openRegisterFromLogin(app: XCUIApplication) -> RegisterScreenObject {
|
||||
let login: LoginScreenObject
|
||||
let loginRoot = app.otherElements[UITestID.Root.login]
|
||||
if loginRoot.exists || app.textFields[UITestID.Auth.usernameField].exists {
|
||||
login = LoginScreenObject(app: app)
|
||||
login.waitForLoad()
|
||||
} else {
|
||||
login = navigateToLoginFromOnboarding(app: app)
|
||||
}
|
||||
login.tapSignUp()
|
||||
|
||||
let register = RegisterScreenObject(app: app)
|
||||
register.waitForLoad()
|
||||
return register
|
||||
}
|
||||
}
|
||||
97
iosApp/HoneyDueUITests/PageObjects/BaseScreen.swift
Normal file
97
iosApp/HoneyDueUITests/PageObjects/BaseScreen.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import XCTest
|
||||
|
||||
/// Base class for all page objects providing common waiting and assertion utilities.
|
||||
///
|
||||
/// Replaces ad-hoc `sleep()` calls with condition-based waits for reliable,
|
||||
/// non-flaky UI tests. All screen page objects should inherit from this class.
|
||||
class BaseScreen {
|
||||
let app: XCUIApplication
|
||||
let timeout: TimeInterval
|
||||
|
||||
init(app: XCUIApplication, timeout: TimeInterval = 10) {
|
||||
self.app = app
|
||||
self.timeout = timeout
|
||||
}
|
||||
|
||||
// MARK: - Wait Helpers (replaces fixed sleeps)
|
||||
|
||||
/// Waits for an element to exist within the timeout period.
|
||||
/// Fails the test with a descriptive message if the element does not appear.
|
||||
@discardableResult
|
||||
func waitForElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
|
||||
let t = timeout ?? self.timeout
|
||||
XCTAssertTrue(element.waitForExistence(timeout: t), "Element \(element) did not appear within \(t)s")
|
||||
return element
|
||||
}
|
||||
|
||||
/// Waits for an element to disappear within the timeout period.
|
||||
/// Fails the test if the element is still present after the timeout.
|
||||
func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval? = nil) {
|
||||
let t = timeout ?? self.timeout
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
let result = XCTWaiter().wait(for: [expectation], timeout: t)
|
||||
XCTAssertEqual(result, .completed, "Element \(element) did not disappear within \(t)s")
|
||||
}
|
||||
|
||||
/// Waits for an element to become hittable (visible and interactable).
|
||||
/// Returns the element for chaining.
|
||||
@discardableResult
|
||||
func waitForHittable(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
|
||||
let t = timeout ?? self.timeout
|
||||
let predicate = NSPredicate(format: "isHittable == true")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
_ = XCTWaiter().wait(for: [expectation], timeout: t)
|
||||
return element
|
||||
}
|
||||
|
||||
/// Waits until a condition evaluates to true, polling every 0.5s.
|
||||
/// More flexible than element-based waits for complex state checks.
|
||||
func waitForCondition(
|
||||
_ description: String,
|
||||
timeout: TimeInterval? = nil,
|
||||
condition: () -> Bool
|
||||
) -> Bool {
|
||||
let t = timeout ?? self.timeout
|
||||
let deadline = Date().addingTimeInterval(t)
|
||||
while Date() < deadline {
|
||||
if condition() { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Waits for an element to exist, then taps it. Convenience for the common wait+tap pattern.
|
||||
@discardableResult
|
||||
func tapElement(_ element: XCUIElement, timeout: TimeInterval? = nil) -> XCUIElement {
|
||||
waitForElement(element, timeout: timeout)
|
||||
element.tap()
|
||||
return element
|
||||
}
|
||||
|
||||
// MARK: - State Assertions
|
||||
|
||||
/// Asserts that an element with the given accessibility identifier exists.
|
||||
func assertExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) {
|
||||
let element = app.descendants(matching: .any)[identifier]
|
||||
XCTAssertTrue(element.waitForExistence(timeout: timeout), "Element '\(identifier)' not found", file: file, line: line)
|
||||
}
|
||||
|
||||
/// Asserts that an element with the given accessibility identifier does not exist.
|
||||
func assertNotExists(_ identifier: String, file: StaticString = #file, line: UInt = #line) {
|
||||
let element = app.descendants(matching: .any)[identifier]
|
||||
XCTAssertFalse(element.exists, "Element '\(identifier)' should not exist", file: file, line: line)
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
/// Taps the first button in the navigation bar (typically the back button).
|
||||
func tapBackButton() {
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
|
||||
/// Subclasses must override this property to indicate whether the screen is currently displayed.
|
||||
var isDisplayed: Bool {
|
||||
fatalError("Subclasses must override isDisplayed")
|
||||
}
|
||||
}
|
||||
86
iosApp/HoneyDueUITests/PageObjects/LoginScreen.swift
Normal file
86
iosApp/HoneyDueUITests/PageObjects/LoginScreen.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import XCTest
|
||||
|
||||
/// Page object for the login screen.
|
||||
///
|
||||
/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication`
|
||||
/// to locate elements. Provides typed actions for login flow interactions.
|
||||
class LoginScreen: BaseScreen {
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var emailField: XCUIElement {
|
||||
app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
}
|
||||
|
||||
var passwordField: XCUIElement {
|
||||
// Password field may be a SecureTextField or regular TextField depending on visibility toggle
|
||||
let secure = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
if secure.exists { return secure }
|
||||
return app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
|
||||
}
|
||||
|
||||
var loginButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
|
||||
}
|
||||
|
||||
var appleSignInButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.appleSignInButton]
|
||||
}
|
||||
|
||||
var signUpButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.signUpButton]
|
||||
}
|
||||
|
||||
var forgotPasswordButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton]
|
||||
}
|
||||
|
||||
var passwordVisibilityToggle: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.passwordVisibilityToggle]
|
||||
}
|
||||
|
||||
var welcomeText: XCUIElement {
|
||||
app.staticTexts["Welcome Back"]
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
emailField.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Logs in with the provided credentials and returns a MainTabScreen.
|
||||
/// Waits for the email field to appear before typing.
|
||||
@discardableResult
|
||||
func login(email: String, password: String) -> MainTabScreen {
|
||||
waitForElement(emailField).tap()
|
||||
emailField.typeText(email)
|
||||
|
||||
let pwField = passwordField
|
||||
pwField.tap()
|
||||
pwField.typeText(password)
|
||||
|
||||
loginButton.tap()
|
||||
return MainTabScreen(app: app)
|
||||
}
|
||||
|
||||
/// Taps the sign up / register link and returns a RegisterScreen.
|
||||
@discardableResult
|
||||
func tapSignUp() -> RegisterScreen {
|
||||
waitForElement(signUpButton).tap()
|
||||
return RegisterScreen(app: app)
|
||||
}
|
||||
|
||||
/// Taps the forgot password link.
|
||||
func tapForgotPassword() {
|
||||
waitForElement(forgotPasswordButton).tap()
|
||||
}
|
||||
|
||||
/// Toggles password visibility and returns whether the password is now visible.
|
||||
@discardableResult
|
||||
func togglePasswordVisibility() -> Bool {
|
||||
waitForElement(passwordVisibilityToggle).tap()
|
||||
// If a regular text field with the password identifier exists, password is visible
|
||||
return app.textFields[AccessibilityIdentifiers.Authentication.passwordField].exists
|
||||
}
|
||||
}
|
||||
92
iosApp/HoneyDueUITests/PageObjects/MainTabScreen.swift
Normal file
92
iosApp/HoneyDueUITests/PageObjects/MainTabScreen.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import XCTest
|
||||
|
||||
/// Page object for the main tab view that appears after login.
|
||||
///
|
||||
/// The app has 4 tabs: Residences, Tasks, Contractors, Documents.
|
||||
/// Profile is accessed via the settings button on the Residences screen.
|
||||
/// Uses accessibility identifiers for reliable element lookup.
|
||||
class MainTabScreen: BaseScreen {
|
||||
|
||||
// MARK: - Tab Elements
|
||||
|
||||
var residencesTab: XCUIElement {
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.residencesTab]
|
||||
}
|
||||
|
||||
var tasksTab: XCUIElement {
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.tasksTab]
|
||||
}
|
||||
|
||||
var contractorsTab: XCUIElement {
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.contractorsTab]
|
||||
}
|
||||
|
||||
var documentsTab: XCUIElement {
|
||||
app.tabBars.buttons[AccessibilityIdentifiers.Navigation.documentsTab]
|
||||
}
|
||||
|
||||
/// Settings button on the Residences tab (leads to profile/settings).
|
||||
var settingsButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
residencesTab.waitForExistence(timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
@discardableResult
|
||||
func goToResidences() -> Self {
|
||||
waitForElement(residencesTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToTasks() -> Self {
|
||||
waitForElement(tasksTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToContractors() -> Self {
|
||||
waitForElement(contractorsTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func goToDocuments() -> Self {
|
||||
waitForElement(documentsTab).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
/// Navigates to settings/profile via the settings button on Residences tab.
|
||||
@discardableResult
|
||||
func goToSettings() -> Self {
|
||||
goToResidences()
|
||||
waitForElement(settingsButton).tap()
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: - Logout
|
||||
|
||||
/// Logs out by navigating to settings and tapping the logout button.
|
||||
/// Handles the confirmation alert automatically.
|
||||
func logout() {
|
||||
goToSettings()
|
||||
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||
if logoutButton.waitForExistence(timeout: 5) {
|
||||
waitForHittable(logoutButton).tap()
|
||||
|
||||
// Handle confirmation alert
|
||||
let alert = app.alerts.firstMatch
|
||||
if alert.waitForExistence(timeout: 3) {
|
||||
let confirmLogout = alert.buttons["Log Out"]
|
||||
if confirmLogout.exists {
|
||||
confirmLogout.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
iosApp/HoneyDueUITests/PageObjects/RegisterScreen.swift
Normal file
86
iosApp/HoneyDueUITests/PageObjects/RegisterScreen.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import XCTest
|
||||
|
||||
/// Page object for the registration screen.
|
||||
///
|
||||
/// Uses accessibility identifiers from `AccessibilityIdentifiers.Authentication`
|
||||
/// to locate registration form elements and perform sign-up actions.
|
||||
class RegisterScreen: BaseScreen {
|
||||
|
||||
// MARK: - Elements
|
||||
|
||||
var usernameField: XCUIElement {
|
||||
app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
|
||||
}
|
||||
|
||||
var emailField: XCUIElement {
|
||||
app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
|
||||
}
|
||||
|
||||
var passwordField: XCUIElement {
|
||||
app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
|
||||
}
|
||||
|
||||
var confirmPasswordField: XCUIElement {
|
||||
app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
|
||||
}
|
||||
|
||||
var registerButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
|
||||
}
|
||||
|
||||
var cancelButton: XCUIElement {
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
|
||||
}
|
||||
|
||||
/// Fallback element lookup for the register/create account button using predicate
|
||||
var registerButtonByLabel: XCUIElement {
|
||||
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Register' OR label CONTAINS[c] 'Create Account'")).firstMatch
|
||||
}
|
||||
|
||||
override var isDisplayed: Bool {
|
||||
// Registration screen is visible if any of the register-specific fields exist
|
||||
let usernameExists = usernameField.waitForExistence(timeout: timeout)
|
||||
let emailExists = emailField.exists
|
||||
return usernameExists || emailExists
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Fills in the registration form and submits it.
|
||||
/// Returns a MainTabScreen assuming successful registration leads to the main app.
|
||||
@discardableResult
|
||||
func register(username: String, email: String, password: String) -> MainTabScreen {
|
||||
waitForElement(usernameField).tap()
|
||||
usernameField.typeText(username)
|
||||
|
||||
emailField.tap()
|
||||
emailField.typeText(email)
|
||||
|
||||
passwordField.tap()
|
||||
passwordField.typeText(password)
|
||||
|
||||
confirmPasswordField.tap()
|
||||
confirmPasswordField.typeText(password)
|
||||
|
||||
// Try accessibility identifier first, fall back to label search
|
||||
if registerButton.exists {
|
||||
registerButton.tap()
|
||||
} else {
|
||||
registerButtonByLabel.tap()
|
||||
}
|
||||
|
||||
return MainTabScreen(app: app)
|
||||
}
|
||||
|
||||
/// Taps cancel to return to the login screen.
|
||||
@discardableResult
|
||||
func tapCancel() -> LoginScreen {
|
||||
if cancelButton.exists {
|
||||
cancelButton.tap()
|
||||
} else {
|
||||
// Fall back to navigation back button
|
||||
tapBackButton()
|
||||
}
|
||||
return LoginScreen(app: app)
|
||||
}
|
||||
}
|
||||
83
iosApp/HoneyDueUITests/README.md
Normal file
83
iosApp/HoneyDueUITests/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# honeyDue iOS UI Testing Architecture
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
HoneyDueUITests/
|
||||
├── PageObjects/ # Screen abstractions (Page Object pattern)
|
||||
│ ├── BaseScreen.swift # Common wait/assert utilities
|
||||
│ ├── LoginScreen.swift # Login screen elements and actions
|
||||
│ ├── RegisterScreen.swift # Registration screen
|
||||
│ └── MainTabScreen.swift # Main tab navigation + settings + logout
|
||||
├── TestConfiguration/ # Launch config, environment setup
|
||||
│ └── TestLaunchConfig.swift
|
||||
├── Fixtures/ # Test data builders
|
||||
│ └── TestFixtures.swift
|
||||
├── CriticalPath/ # Must-pass tests for CI gating
|
||||
│ ├── SmokeTests.swift # Fast smoke suite (<2 min)
|
||||
│ ├── AuthCriticalPathTests.swift # Auth flow validation
|
||||
│ └── NavigationCriticalPathTests.swift # Tab + navigation validation
|
||||
├── UITestHelpers.swift # Shared login/logout/navigation helpers
|
||||
├── AccessibilityIdentifiers.swift # UI element IDs (synced with app-side copy)
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Test Suites
|
||||
|
||||
| Suite | Purpose | CI Gate | Target Time |
|
||||
|-------|---------|---------|-------------|
|
||||
| SmokeTests | App launches, basic auth, tab existence | Every PR | <2 min |
|
||||
| AuthCriticalPathTests | Login, logout, registration entry, forgot password | Every PR | <3 min |
|
||||
| NavigationCriticalPathTests | Tab navigation, settings, add buttons | Every PR | <3 min |
|
||||
|
||||
## Patterns
|
||||
|
||||
### Page Object Pattern
|
||||
Every screen has a corresponding PageObject in `PageObjects/`. Use these instead of raw XCUIElement queries in tests. Page objects encapsulate element lookups and common actions, making tests more readable and easier to maintain when the UI changes.
|
||||
|
||||
### Wait Helpers
|
||||
NEVER use `sleep()` or `Thread.sleep()`. Use `waitForElement()`, `waitForElementToDisappear()`, `waitForHittable()`, or `waitForCondition()` from BaseScreen. These are condition-based waits that return as soon as the condition is met, making tests both faster and more reliable.
|
||||
|
||||
### Test Data
|
||||
Use `TestFixtures` builders for consistent, unique test data. Random numbers and UUIDs ensure test isolation so tests can run in any order without interfering with each other.
|
||||
|
||||
### Launch Configuration
|
||||
Use `TestLaunchConfig.launchApp()` for standard launches. Use `launchAuthenticated()` to skip login when the app supports test authentication bypass. The standard configuration disables animations and forces English locale.
|
||||
|
||||
### Accessibility Identifiers
|
||||
All interactive elements must have identifiers defined in `AccessibilityIdentifiers.swift`. Use `.accessibilityIdentifier()` in SwiftUI views. Page objects reference these identifiers for element lookup. The test-side copy must stay in sync with the app-side copy at `iosApp/Helpers/AccessibilityIdentifiers.swift`.
|
||||
|
||||
## CI Configuration
|
||||
|
||||
### Critical Path (every PR)
|
||||
```bash
|
||||
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
||||
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:HoneyDueUITests/SmokeTests \
|
||||
-only-testing:HoneyDueUITests/AuthCriticalPathTests \
|
||||
-only-testing:HoneyDueUITests/NavigationCriticalPathTests
|
||||
```
|
||||
|
||||
### Full Regression (nightly)
|
||||
```bash
|
||||
xcodebuild test -project iosApp.xcodeproj -scheme iosApp \
|
||||
-sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||
-only-testing:HoneyDueUITests
|
||||
```
|
||||
|
||||
## Flake Reduction
|
||||
|
||||
- Target: <2% flake rate on critical-path suite
|
||||
- All waits use condition-based predicates (zero fixed sleeps)
|
||||
- Test data uses unique identifiers to prevent cross-test interference
|
||||
- UI animations disabled via launch arguments
|
||||
- Element lookups use accessibility identifiers exclusively
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
1. If the screen does not have a page object yet, create one in `PageObjects/` that extends `BaseScreen`.
|
||||
2. Define accessibility identifiers in `AccessibilityIdentifiers.swift` for any new UI elements.
|
||||
3. Sync the app-side copy of `AccessibilityIdentifiers.swift` with matching identifiers.
|
||||
4. Add test data builders to `TestFixtures.swift` if needed.
|
||||
5. Write the test in `CriticalPath/` for must-pass CI tests.
|
||||
6. Verify zero `sleep()` calls before merging.
|
||||
36
iosApp/HoneyDueUITests/Scripts/cleanup_test_users.sh
Executable file
36
iosApp/HoneyDueUITests/Scripts/cleanup_test_users.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Script to clean up test users from Django database
|
||||
# Usage: ./cleanup_test_users.sh [email]
|
||||
# If no email provided, cleans up all users with email starting with 'test_'
|
||||
|
||||
EMAIL="$1"
|
||||
|
||||
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI
|
||||
|
||||
if [ -n "$EMAIL" ]; then
|
||||
FILTER="email='$EMAIL'"
|
||||
else
|
||||
FILTER="email__startswith='test_'"
|
||||
fi
|
||||
|
||||
# Try docker exec first (if running in Docker)
|
||||
if docker ps --format '{{.Names}}' | grep -q 'mycrib-web\|myCrib-web'; then
|
||||
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -E 'mycrib-web|myCrib-web' | head -1)
|
||||
docker exec "$CONTAINER_NAME" python manage.py shell -c "
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
deleted = User.objects.filter($FILTER).delete()
|
||||
print(f'Deleted: {deleted}')
|
||||
" 2>/dev/null
|
||||
else
|
||||
# Fallback to local Python
|
||||
export DJANGO_SETTINGS_MODULE=myCrib.settings
|
||||
python manage.py shell -c "
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
deleted = User.objects.filter($FILTER).delete()
|
||||
print(f'Deleted: {deleted}')
|
||||
" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "Test users cleanup complete"
|
||||
57
iosApp/HoneyDueUITests/Scripts/get_verification_code.sh
Executable file
57
iosApp/HoneyDueUITests/Scripts/get_verification_code.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# Script to fetch verification code from Django database
|
||||
# Usage: ./get_verification_code.sh <email>
|
||||
# Output: Writes the verification code to /tmp/mycrib_verification_code_<sanitized_email>.txt
|
||||
|
||||
EMAIL="$1"
|
||||
|
||||
if [ -z "$EMAIL" ]; then
|
||||
echo "Usage: $0 <email>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanitize email for filename
|
||||
SANITIZED_EMAIL=$(echo "$EMAIL" | sed 's/@/_at_/g' | sed 's/\./_dot_/g')
|
||||
OUTPUT_FILE="/tmp/mycrib_verification_code_${SANITIZED_EMAIL}.txt"
|
||||
|
||||
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI
|
||||
|
||||
# Try docker exec first (if running in Docker)
|
||||
if docker ps --format '{{.Names}}' | grep -q 'mycrib-web\|myCrib-web'; then
|
||||
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -E 'mycrib-web|myCrib-web' | head -1)
|
||||
CODE=$(docker exec "$CONTAINER_NAME" python manage.py shell -c "
|
||||
from user.models import ConfirmationCode
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(email='$EMAIL')
|
||||
code = ConfirmationCode.objects.filter(user=user, is_used=False).latest('created_at')
|
||||
print(code.code)
|
||||
except Exception as e:
|
||||
print('ERROR:', e)
|
||||
" 2>/dev/null)
|
||||
else
|
||||
# Fallback to local Python
|
||||
export DJANGO_SETTINGS_MODULE=myCrib.settings
|
||||
CODE=$(python manage.py shell -c "
|
||||
from user.models import ConfirmationCode
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(email='$EMAIL')
|
||||
code = ConfirmationCode.objects.filter(user=user, is_used=False).latest('created_at')
|
||||
print(code.code)
|
||||
except Exception as e:
|
||||
print('ERROR:', e)
|
||||
" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Check if we got a valid 6-digit code
|
||||
if [[ "$CODE" =~ ^[0-9]{6}$ ]]; then
|
||||
echo "$CODE" > "$OUTPUT_FILE"
|
||||
echo "Verification code saved to $OUTPUT_FILE: $CODE"
|
||||
exit 0
|
||||
else
|
||||
echo "Failed to get verification code: $CODE"
|
||||
exit 1
|
||||
fi
|
||||
62
iosApp/HoneyDueUITests/SimpleLoginTest.swift
Normal file
62
iosApp/HoneyDueUITests/SimpleLoginTest.swift
Normal file
@@ -0,0 +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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
247
iosApp/HoneyDueUITests/Suite0_OnboardingTests.swift
Normal file
247
iosApp/HoneyDueUITests/Suite0_OnboardingTests.swift
Normal file
@@ -0,0 +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 honeyDueUITests scheme (Edit Scheme → Test → Pre-actions):
|
||||
/// /usr/bin/xcrun simctl uninstall booted com.tt.honeyDue.HoneyDueDev
|
||||
/// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
683
iosApp/HoneyDueUITests/Suite10_ComprehensiveE2ETests.swift
Normal file
683
iosApp/HoneyDueUITests/Suite10_ComprehensiveE2ETests.swift
Normal file
@@ -0,0 +1,683 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive End-to-End Test Suite
|
||||
/// Closely mirrors TestIntegration_ComprehensiveE2E from honeyDueAPI-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 {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
// 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..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if (button.label == "plus" || button.label.contains("Add")) && button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Empty state button
|
||||
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
|
||||
if emptyStateButton.exists && emptyStateButton.isEnabled {
|
||||
return emptyStateButton
|
||||
}
|
||||
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
// MARK: - Test 1: Create Multiple Residences
|
||||
// Phase 2 of TestIntegration_ComprehensiveE2E
|
||||
|
||||
func test01_createMultipleResidences() {
|
||||
let residenceNames = [
|
||||
"E2E Main House \(Self.testRunId)",
|
||||
"E2E Beach House \(Self.testRunId)",
|
||||
"E2E Mountain Cabin \(Self.testRunId)"
|
||||
]
|
||||
|
||||
for (index, name) in residenceNames.enumerated() {
|
||||
let streetAddress = "\(100 * (index + 1)) Test St"
|
||||
let success = createResidence(name: name, streetAddress: streetAddress)
|
||||
XCTAssertTrue(success, "Should create residence: \(name)")
|
||||
}
|
||||
|
||||
// Verify all residences exist
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
for name in residenceNames {
|
||||
let residenceCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
|
||||
XCTAssertTrue(residenceCard.waitForExistence(timeout: 5), "Residence '\(name)' should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 2: Create Tasks with Various States
|
||||
// Phase 3 of TestIntegration_ComprehensiveE2E
|
||||
|
||||
func test02_createTasksWithVariousStates() {
|
||||
// Ensure at least one residence exists
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let emptyState = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyState.exists {
|
||||
createResidence(name: "Task Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
// Create tasks with different purposes
|
||||
let tasks = [
|
||||
("E2E Active Task \(Self.testRunId)", "Task that remains active"),
|
||||
("E2E Progress Task \(Self.testRunId)", "Task to mark in-progress"),
|
||||
("E2E Complete Task \(Self.testRunId)", "Task to complete"),
|
||||
("E2E Cancel Task \(Self.testRunId)", "Task to cancel")
|
||||
]
|
||||
|
||||
for (title, description) in tasks {
|
||||
let success = createTask(title: title, description: description)
|
||||
XCTAssertTrue(success, "Should create task: \(title)")
|
||||
}
|
||||
|
||||
// Verify all tasks exist
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
for (title, _) in tasks {
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
|
||||
XCTAssertTrue(taskCard.waitForExistence(timeout: 5), "Task '\(title)' should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 3: Task State Transitions
|
||||
// Mirrors task operations from TestIntegration_TaskFlow
|
||||
|
||||
func test03_taskStateTransitions() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
// Find a task to transition (create one if needed)
|
||||
let testTaskTitle = "E2E State Test \(Self.testRunId)"
|
||||
|
||||
var taskExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists
|
||||
if !taskExists {
|
||||
// Check if any residence exists first
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyResidences.exists {
|
||||
createResidence(name: "State Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
createTask(title: testTaskTitle, description: "Testing state transitions")
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Find and tap the task
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
||||
if taskCard.waitForExistence(timeout: 5) {
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Try to mark in progress
|
||||
let inProgressButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'Start'")).firstMatch
|
||||
if inProgressButton.exists && inProgressButton.isEnabled {
|
||||
inProgressButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Try to complete
|
||||
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark Complete'")).firstMatch
|
||||
if completeButton.exists && completeButton.isEnabled {
|
||||
completeButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Handle completion form if shown
|
||||
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Submit' OR label CONTAINS[c] 'Save'")).firstMatch
|
||||
if submitButton.waitForExistence(timeout: 2) {
|
||||
submitButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 4: Task Cancel Operation
|
||||
|
||||
func test04_taskCancelOperation() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let testTaskTitle = "E2E Cancel Test \(Self.testRunId)"
|
||||
|
||||
// Create task if doesn't exist
|
||||
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
|
||||
navigateToTab("Residences")
|
||||
sleep(1)
|
||||
|
||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyResidences.exists {
|
||||
createResidence(name: "Cancel Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
createTask(title: testTaskTitle, description: "Task to be cancelled")
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Find and tap task
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
||||
if taskCard.waitForExistence(timeout: 5) {
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for cancel button
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel Task' OR label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if cancelButton.exists && cancelButton.isEnabled {
|
||||
cancelButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm cancellation if alert shown
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 5: Task Archive Operation
|
||||
|
||||
func test05_taskArchiveOperation() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let testTaskTitle = "E2E Archive Test \(Self.testRunId)"
|
||||
|
||||
// Create task if doesn't exist
|
||||
if !app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch.exists {
|
||||
navigateToTab("Residences")
|
||||
sleep(1)
|
||||
|
||||
let emptyResidences = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties'")).firstMatch
|
||||
if emptyResidences.exists {
|
||||
createResidence(name: "Archive Test Residence \(Self.testRunId)")
|
||||
}
|
||||
|
||||
createTask(title: testTaskTitle, description: "Task to be archived")
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Find and tap task
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(testTaskTitle)'")).firstMatch
|
||||
if taskCard.waitForExistence(timeout: 5) {
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for archive button
|
||||
let archiveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive'")).firstMatch
|
||||
if archiveButton.exists && archiveButton.isEnabled {
|
||||
archiveButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm archive if alert shown
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 6: Verify Kanban Column Structure
|
||||
// Phase 6 of TestIntegration_ComprehensiveE2E
|
||||
|
||||
func test06_verifyKanbanStructure() {
|
||||
navigateToTab("Tasks")
|
||||
sleep(3)
|
||||
|
||||
// Expected kanban column names (may vary by implementation)
|
||||
let expectedColumns = [
|
||||
"Overdue",
|
||||
"In Progress",
|
||||
"Due Soon",
|
||||
"Upcoming",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
]
|
||||
|
||||
var foundColumns: [String] = []
|
||||
|
||||
for column in expectedColumns {
|
||||
let columnHeader = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(column)'")).firstMatch
|
||||
if columnHeader.exists {
|
||||
foundColumns.append(column)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have at least some kanban columns OR be in list view
|
||||
let hasKanbanView = foundColumns.count >= 2
|
||||
let hasListView = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'All Tasks'")).firstMatch.exists
|
||||
|
||||
XCTAssertTrue(hasKanbanView || hasListView, "Should display tasks in kanban or list view. Found columns: \(foundColumns)")
|
||||
}
|
||||
|
||||
// MARK: - Test 7: Residence Details Show Tasks
|
||||
// Verifies that residence detail screen shows associated tasks
|
||||
|
||||
func test07_residenceDetailsShowTasks() {
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
// Find any residence
|
||||
let residenceCard = app.cells.firstMatch
|
||||
guard residenceCard.waitForExistence(timeout: 5) else {
|
||||
// No residences - create one with a task
|
||||
createResidence(name: "Detail Test Residence \(Self.testRunId)")
|
||||
createTask(title: "Detail Test Task \(Self.testRunId)")
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let newResidenceCard = app.cells.firstMatch
|
||||
guard newResidenceCard.waitForExistence(timeout: 5) else {
|
||||
XCTFail("Could not find any residence")
|
||||
return
|
||||
}
|
||||
newResidenceCard.tap()
|
||||
sleep(2)
|
||||
return
|
||||
}
|
||||
|
||||
residenceCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Look for tasks section in residence details
|
||||
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
|
||||
let taskCount = app.staticTexts.containing(NSPredicate(format: "label MATCHES '\\\\d+ tasks?' OR label MATCHES '\\\\d+ Tasks?'")).firstMatch
|
||||
|
||||
// Either tasks section header or task count should be visible
|
||||
let hasTasksInfo = tasksSection.exists || taskCount.exists
|
||||
|
||||
// Navigate back
|
||||
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||
if backButton.exists && backButton.isHittable {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Note: Not asserting because task section visibility depends on UI design
|
||||
}
|
||||
|
||||
// MARK: - Test 8: Contractor CRUD (Mirrors backend contractor tests)
|
||||
|
||||
func test08_contractorCRUD() {
|
||||
navigateToTab("Contractors")
|
||||
sleep(2)
|
||||
|
||||
let contractorName = "E2E Test Contractor \(Self.testRunId)"
|
||||
|
||||
// Check if Contractors tab exists
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
guard contractorsTab.exists else {
|
||||
// Contractors may not be a main tab - skip this test
|
||||
return
|
||||
}
|
||||
|
||||
// Try to add contractor
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
|
||||
guard addButton.waitForExistence(timeout: 5) else {
|
||||
// May need residence first
|
||||
return
|
||||
}
|
||||
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill contractor form
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
if nameField.exists {
|
||||
nameField.tap()
|
||||
nameField.typeText(contractorName)
|
||||
|
||||
let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch
|
||||
if companyField.exists {
|
||||
companyField.tap()
|
||||
companyField.typeText("Test Company Inc")
|
||||
}
|
||||
|
||||
let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch
|
||||
if phoneField.exists {
|
||||
phoneField.tap()
|
||||
phoneField.typeText("555-123-4567")
|
||||
}
|
||||
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify contractor was created
|
||||
let contractorCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(contractorName)'")).firstMatch
|
||||
XCTAssertTrue(contractorCard.waitForExistence(timeout: 10), "Contractor '\(contractorName)' should be created")
|
||||
}
|
||||
} else {
|
||||
// Cancel if form didn't load properly
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if cancelButton.exists {
|
||||
cancelButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 9: Full Flow Summary
|
||||
|
||||
func test09_fullFlowSummary() {
|
||||
// This test verifies the overall app state after running previous tests
|
||||
|
||||
// Check Residences tab
|
||||
navigateToTab("Residences")
|
||||
sleep(2)
|
||||
|
||||
let residencesList = app.cells
|
||||
let residenceCount = residencesList.count
|
||||
|
||||
// Check Tasks tab
|
||||
navigateToTab("Tasks")
|
||||
sleep(2)
|
||||
|
||||
let tasksScreen = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksScreen.exists, "Tasks screen should be accessible")
|
||||
|
||||
// Check Profile tab
|
||||
navigateToTab("Profile")
|
||||
sleep(2)
|
||||
|
||||
let logoutButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
|
||||
XCTAssertTrue(logoutButton.exists, "User should be logged in with logout option available")
|
||||
|
||||
print("=== E2E Test Summary ===")
|
||||
print("Residences found: \(residenceCount)")
|
||||
print("Tasks screen accessible: true")
|
||||
print("User logged in: true")
|
||||
print("========================")
|
||||
}
|
||||
}
|
||||
654
iosApp/HoneyDueUITests/Suite1_RegistrationTests.swift
Normal file
654
iosApp/HoneyDueUITests/Suite1_RegistrationTests.swift
Normal file
@@ -0,0 +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 {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
140
iosApp/HoneyDueUITests/Suite2_AuthenticationTests.swift
Normal file
140
iosApp/HoneyDueUITests/Suite2_AuthenticationTests.swift
Normal file
@@ -0,0 +1,140 @@
|
||||
import XCTest
|
||||
|
||||
/// Authentication flow tests
|
||||
/// Based on working SimpleLoginTest pattern
|
||||
final class Suite2_AuthenticationTests: BaseUITestCase {
|
||||
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)
|
||||
}
|
||||
}
|
||||
238
iosApp/HoneyDueUITests/Suite3_ResidenceTests.swift
Normal file
238
iosApp/HoneyDueUITests/Suite3_ResidenceTests.swift
Normal file
@@ -0,0 +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 {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
670
iosApp/HoneyDueUITests/Suite4_ComprehensiveResidenceTests.swift
Normal file
670
iosApp/HoneyDueUITests/Suite4_ComprehensiveResidenceTests.swift
Normal file
@@ -0,0 +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 {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
// Test data tracking
|
||||
var createdResidenceNames: [String] = []
|
||||
|
||||
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..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if button.label == "plus" || button.label.contains("Add") {
|
||||
if button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
private func fillTextField(placeholder: String, text: String) {
|
||||
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
|
||||
if field.exists {
|
||||
field.tap()
|
||||
field.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectPropertyType(type: String) {
|
||||
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
|
||||
if propertyTypePicker.exists {
|
||||
propertyTypePicker.tap()
|
||||
sleep(1)
|
||||
|
||||
// Try to find and tap the type option
|
||||
let typeButton = app.buttons[type]
|
||||
if typeButton.exists {
|
||||
typeButton.tap()
|
||||
sleep(1)
|
||||
} else {
|
||||
// Try cells if it's a navigation style picker
|
||||
let cells = app.cells
|
||||
for i in 0..<cells.count {
|
||||
let cell = cells.element(boundBy: i)
|
||||
if cell.staticTexts[type].exists {
|
||||
cell.tap()
|
||||
sleep(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createResidence(
|
||||
name: String,
|
||||
propertyType: String = "House",
|
||||
street: String = "123 Test St",
|
||||
city: String = "TestCity",
|
||||
state: String = "TS",
|
||||
postal: String = "12345",
|
||||
scrollBeforeAddress: Bool = true
|
||||
) -> Bool {
|
||||
guard openResidenceForm() else { return false }
|
||||
|
||||
// Fill name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
nameField.tap()
|
||||
nameField.typeText(name)
|
||||
|
||||
// Select property type
|
||||
selectPropertyType(type: propertyType)
|
||||
|
||||
// Scroll to address section
|
||||
if scrollBeforeAddress {
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Fill address fields
|
||||
fillTextField(placeholder: "Street", text: street)
|
||||
fillTextField(placeholder: "City", text: city)
|
||||
fillTextField(placeholder: "State", text: state)
|
||||
fillTextField(placeholder: "Postal", text: postal)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
guard saveButton.exists else { return false }
|
||||
saveButton.tap()
|
||||
|
||||
sleep(4) // Wait for API call
|
||||
|
||||
// Track created residence
|
||||
createdResidenceNames.append(name)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func findResidence(name: String) -> XCUIElement {
|
||||
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(name)'")).firstMatch
|
||||
}
|
||||
|
||||
// MARK: - 1. Error/Validation Tests
|
||||
|
||||
func test01_cannotCreateResidenceWithEmptyName() {
|
||||
guard openResidenceForm() else {
|
||||
XCTFail("Failed to open residence form")
|
||||
return
|
||||
}
|
||||
|
||||
// Leave name empty, fill only address
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
fillTextField(placeholder: "Street", text: "123 Test St")
|
||||
fillTextField(placeholder: "City", text: "TestCity")
|
||||
fillTextField(placeholder: "State", text: "TS")
|
||||
fillTextField(placeholder: "Postal", text: "12345")
|
||||
|
||||
// Scroll to save button if needed
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save button should be disabled when name is empty
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||
XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when name is empty")
|
||||
}
|
||||
|
||||
func test02_cancelResidenceCreation() {
|
||||
guard openResidenceForm() else {
|
||||
XCTFail("Failed to open residence form")
|
||||
return
|
||||
}
|
||||
|
||||
// Fill some data
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
nameField.tap()
|
||||
nameField.typeText("This will be canceled")
|
||||
|
||||
// Tap cancel
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
|
||||
cancelButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Should be back on residences list
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesTab.exists, "Should be back on residences list")
|
||||
|
||||
// Residence should not exist
|
||||
let residence = findResidence(name: "This will be canceled")
|
||||
XCTAssertFalse(residence.exists, "Canceled residence should not exist")
|
||||
}
|
||||
|
||||
// MARK: - 2. Creation Tests
|
||||
|
||||
func test03_createResidenceWithMinimalData() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let residenceName = "Minimal Home \(timestamp)"
|
||||
|
||||
let success = createResidence(name: residenceName)
|
||||
XCTAssertTrue(success, "Should successfully create residence with minimal data")
|
||||
|
||||
let residenceInList = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list")
|
||||
}
|
||||
|
||||
func test04_createResidenceWithAllPropertyTypes() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let propertyTypes = ["House", "Apartment", "Condo"]
|
||||
|
||||
for (index, type) in propertyTypes.enumerated() {
|
||||
let residenceName = "\(type) Test \(timestamp)_\(index)"
|
||||
let success = createResidence(name: residenceName, propertyType: type)
|
||||
XCTAssertTrue(success, "Should create \(type) residence")
|
||||
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify all residences exist
|
||||
for (index, type) in propertyTypes.enumerated() {
|
||||
let residenceName = "\(type) Test \(timestamp)_\(index)"
|
||||
let residence = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residence.exists, "\(type) residence should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
func test05_createMultipleResidencesInSequence() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
for i in 1...3 {
|
||||
let residenceName = "Sequential Home \(i) - \(timestamp)"
|
||||
let success = createResidence(name: residenceName)
|
||||
XCTAssertTrue(success, "Should create residence \(i)")
|
||||
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify all residences exist
|
||||
for i in 1...3 {
|
||||
let residenceName = "Sequential Home \(i) - \(timestamp)"
|
||||
let residence = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residence.exists, "Residence \(i) should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
func test06_createResidenceWithVeryLongName() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let longName = "This is an extremely long residence name that goes on and on and on to test how the system handles very long text input in the name field \(timestamp)"
|
||||
|
||||
let success = createResidence(name: longName)
|
||||
XCTAssertTrue(success, "Should handle very long names")
|
||||
|
||||
// Verify it appears (may be truncated in display)
|
||||
let residence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long residence'")).firstMatch
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Long name residence should exist")
|
||||
}
|
||||
|
||||
func test07_createResidenceWithSpecialCharacters() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let specialName = "Special !@#$%^&*() Home \(timestamp)"
|
||||
|
||||
let success = createResidence(name: specialName)
|
||||
XCTAssertTrue(success, "Should handle special characters")
|
||||
|
||||
let residence = findResidence(name: "Special")
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with special chars should exist")
|
||||
}
|
||||
|
||||
func test08_createResidenceWithEmojis() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let emojiName = "Beach House \(timestamp)"
|
||||
|
||||
let success = createResidence(name: emojiName)
|
||||
XCTAssertTrue(success, "Should handle emojis")
|
||||
|
||||
let residence = findResidence(name: "Beach House")
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with emojis should exist")
|
||||
}
|
||||
|
||||
func test09_createResidenceWithInternationalCharacters() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let internationalName = "Chateau Montreal \(timestamp)"
|
||||
|
||||
let success = createResidence(name: internationalName)
|
||||
XCTAssertTrue(success, "Should handle international characters")
|
||||
|
||||
let residence = findResidence(name: "Chateau")
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with international chars should exist")
|
||||
}
|
||||
|
||||
func test10_createResidenceWithVeryLongAddress() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let residenceName = "Long Address Home \(timestamp)"
|
||||
|
||||
let success = createResidence(
|
||||
name: residenceName,
|
||||
street: "123456789 Very Long Street Name That Goes On And On Boulevard Apartment Complex Unit 42B",
|
||||
city: "VeryLongCityNameThatTestsTheLimit",
|
||||
state: "CA",
|
||||
postal: "12345-6789"
|
||||
)
|
||||
XCTAssertTrue(success, "Should handle very long addresses")
|
||||
|
||||
let residence = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist")
|
||||
}
|
||||
|
||||
// MARK: - 3. Edit/Update Tests
|
||||
|
||||
func test11_editResidenceName() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let originalName = "Original Name \(timestamp)"
|
||||
let newName = "Edited Name \(timestamp)"
|
||||
|
||||
// Create residence
|
||||
guard createResidence(name: originalName) else {
|
||||
XCTFail("Failed to create residence")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap residence
|
||||
let residence = findResidence(name: originalName)
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist")
|
||||
residence.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
if editButton.exists {
|
||||
editButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Edit name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
if nameField.exists {
|
||||
let element = app/*@START_MENU_TOKEN@*/.textFields["ResidenceForm.NameField"]/*[[".otherElements",".textFields[\"Original Name 1764809003\"]",".textFields[\"Property Name\"]",".textFields[\"ResidenceForm.NameField\"]"],[[[-1,3],[-1,2],[-1,1],[-1,0,1]],[[-1,3],[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch
|
||||
element.tap()
|
||||
element.tap()
|
||||
app/*@START_MENU_TOKEN@*/.menuItems["Select All"]/*[[".menuItems.containing(.staticText, identifier: \"Select All\")",".collectionViews.menuItems[\"Select All\"]",".menuItems[\"Select All\"]"],[[[-1,2],[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
nameField.typeText(newName)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Track new name
|
||||
createdResidenceNames.append(newName)
|
||||
|
||||
// Verify new name appears
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
let updatedResidence = findResidence(name: newName)
|
||||
XCTAssertTrue(updatedResidence.exists, "Residence should show updated name")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test12_updateAllResidenceFields() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let originalName = "Update All Fields \(timestamp)"
|
||||
let newName = "All Fields Updated \(timestamp)"
|
||||
let newStreet = "999 Updated Avenue"
|
||||
let newCity = "NewCity"
|
||||
let newState = "NC"
|
||||
let newPostal = "99999"
|
||||
|
||||
// Create residence with initial values
|
||||
guard createResidence(name: originalName, street: "123 Old St", city: "OldCity", state: "OC", postal: "11111") else {
|
||||
XCTFail("Failed to create residence")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap residence
|
||||
let residence = findResidence(name: originalName)
|
||||
XCTAssertTrue(residence.waitForExistence(timeout: 5), "Residence should exist")
|
||||
residence.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
XCTAssertTrue(editButton.exists, "Edit button should exist")
|
||||
editButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Update name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
XCTAssertTrue(nameField.exists, "Name field should exist")
|
||||
nameField.tap()
|
||||
nameField.doubleTap()
|
||||
sleep(1)
|
||||
if app.buttons["Select All"].exists {
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
nameField.typeText(newName)
|
||||
|
||||
// Update property type (if available)
|
||||
let propertyTypePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Property Type'")).firstMatch
|
||||
if propertyTypePicker.exists {
|
||||
propertyTypePicker.tap()
|
||||
sleep(1)
|
||||
// Select Condo
|
||||
let condoOption = app.buttons["Condo"]
|
||||
if condoOption.exists {
|
||||
condoOption.tap()
|
||||
sleep(1)
|
||||
} else {
|
||||
// Try cells navigation
|
||||
let cells = app.cells
|
||||
for i in 0..<cells.count {
|
||||
let cell = cells.element(boundBy: i)
|
||||
if cell.staticTexts["Condo"].exists {
|
||||
cell.tap()
|
||||
sleep(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to address fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Update street
|
||||
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
||||
if streetField.exists {
|
||||
streetField.tap()
|
||||
streetField.doubleTap()
|
||||
sleep(1)
|
||||
if app.buttons["Select All"].exists {
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
streetField.typeText(newStreet)
|
||||
}
|
||||
|
||||
// Update city
|
||||
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
|
||||
if cityField.exists {
|
||||
cityField.tap()
|
||||
cityField.doubleTap()
|
||||
sleep(1)
|
||||
if app.buttons["Select All"].exists {
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
cityField.typeText(newCity)
|
||||
}
|
||||
|
||||
// Update state
|
||||
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
|
||||
if stateField.exists {
|
||||
stateField.tap()
|
||||
stateField.doubleTap()
|
||||
sleep(1)
|
||||
if app.buttons["Select All"].exists {
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
stateField.typeText(newState)
|
||||
}
|
||||
|
||||
// Update postal code
|
||||
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
|
||||
if postalField.exists {
|
||||
postalField.tap()
|
||||
postalField.doubleTap()
|
||||
sleep(1)
|
||||
if app.buttons["Select All"].exists {
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
postalField.typeText(newPostal)
|
||||
}
|
||||
|
||||
// Scroll to save button
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||
saveButton.tap()
|
||||
sleep(4)
|
||||
|
||||
// Track new name
|
||||
createdResidenceNames.append(newName)
|
||||
|
||||
// Verify updated residence appears in list with new name
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
let updatedResidence = findResidence(name: newName)
|
||||
XCTAssertTrue(updatedResidence.exists, "Residence should show updated name in list")
|
||||
|
||||
// Tap on residence to verify details were updated
|
||||
updatedResidence.tap()
|
||||
sleep(2)
|
||||
|
||||
// Verify updated address appears in detail view
|
||||
let streetText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newStreet)'")).firstMatch
|
||||
XCTAssertTrue(streetText.exists || true, "Updated street should be visible in detail view")
|
||||
|
||||
let cityText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newCity)'")).firstMatch
|
||||
XCTAssertTrue(cityText.exists || true, "Updated city should be visible in detail view")
|
||||
|
||||
let postalText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newPostal)'")).firstMatch
|
||||
XCTAssertTrue(postalText.exists || true, "Updated postal code should be visible in detail view")
|
||||
|
||||
// Verify property type was updated to Condo
|
||||
let condoBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Condo'")).firstMatch
|
||||
XCTAssertTrue(condoBadge.exists || true, "Updated property type should be visible (if shown in detail)")
|
||||
}
|
||||
|
||||
// MARK: - 4. View/Navigation Tests
|
||||
|
||||
func test13_viewResidenceDetails() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let residenceName = "Detail View Test \(timestamp)"
|
||||
|
||||
// Create residence
|
||||
guard createResidence(name: residenceName) else {
|
||||
XCTFail("Failed to create residence")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
|
||||
// Tap on residence
|
||||
let residence = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residence.exists, "Residence should exist")
|
||||
residence.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify detail view appears with edit button or tasks section
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
|
||||
|
||||
XCTAssertTrue(editButton.exists || tasksSection.exists, "Detail view should show with edit button or tasks section")
|
||||
}
|
||||
|
||||
func test14_navigateFromResidencesToOtherTabs() {
|
||||
// From Residences tab
|
||||
navigateToResidencesTab()
|
||||
|
||||
// Navigate to Tasks
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
|
||||
tasksTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
|
||||
|
||||
// Navigate back to Residences
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab")
|
||||
|
||||
// Navigate to Contractors
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
|
||||
contractorsTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
|
||||
|
||||
// Back to Residences
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab again")
|
||||
}
|
||||
|
||||
func test15_refreshResidencesList() {
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
|
||||
// Pull to refresh (if implemented) or use refresh button
|
||||
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
|
||||
if refreshButton.exists {
|
||||
refreshButton.tap()
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
// Verify we're still on residences tab
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesTab.isSelected, "Should still be on Residences tab after refresh")
|
||||
}
|
||||
|
||||
// MARK: - 5. Persistence Tests
|
||||
|
||||
func test16_residencePersistsAfterBackgroundingApp() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let residenceName = "Persistence Test \(timestamp)"
|
||||
|
||||
// Create residence
|
||||
guard createResidence(name: residenceName) else {
|
||||
XCTFail("Failed to create residence")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
|
||||
// Verify residence exists
|
||||
var residence = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residence.exists, "Residence should exist before backgrounding")
|
||||
|
||||
// Background and reactivate app
|
||||
XCUIDevice.shared.press(.home)
|
||||
sleep(2)
|
||||
app.activate()
|
||||
sleep(3)
|
||||
|
||||
// Navigate back to residences
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
|
||||
// Verify residence still exists
|
||||
residence = findResidence(name: residenceName)
|
||||
XCTAssertTrue(residence.exists, "Residence should persist after backgrounding app")
|
||||
}
|
||||
|
||||
// MARK: - 6. Performance Tests
|
||||
|
||||
func test17_residenceListPerformance() {
|
||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
||||
navigateToResidencesTab()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
func test18_residenceCreationPerformance() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
measure(metrics: [XCTClockMetric()]) {
|
||||
let residenceName = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
|
||||
_ = createResidence(name: residenceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
375
iosApp/HoneyDueUITests/Suite5_TaskTests.swift
Normal file
375
iosApp/HoneyDueUITests/Suite5_TaskTests.swift
Normal file
@@ -0,0 +1,375 @@
|
||||
import XCTest
|
||||
|
||||
/// Task management tests
|
||||
/// Uses UITestHelpers for consistent login/logout behavior
|
||||
/// IMPORTANT: Tasks require at least one residence to exist
|
||||
///
|
||||
/// Test Order (least to most complex):
|
||||
/// 1. Error/incomplete data tests
|
||||
/// 2. Creation tests
|
||||
/// 3. Edit/update tests
|
||||
/// 4. Delete/remove tests (none currently)
|
||||
/// 5. Navigation/view tests
|
||||
final class Suite5_TaskTests: BaseUITestCase {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Ensure user is logged in
|
||||
UITestHelpers.ensureLoggedIn(app: app)
|
||||
|
||||
// CRITICAL: Ensure at least one residence exists
|
||||
// Tasks are disabled if no residences exist
|
||||
ensureResidenceExists()
|
||||
|
||||
// Now navigate to Tasks tab
|
||||
navigateToTasksTab()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Ensures at least one residence exists (required for tasks to work)
|
||||
private func ensureResidenceExists() {
|
||||
// Navigate to Residences tab
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
if residencesTab.waitForExistence(timeout: 5) {
|
||||
residencesTab.tap()
|
||||
sleep(2)
|
||||
|
||||
// Check if we have any residences
|
||||
// Look for the add button - if we see "Add a property" text or empty state, create one
|
||||
let emptyStateText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties' OR label CONTAINS[c] 'No residences'")).firstMatch
|
||||
|
||||
if emptyStateText.exists {
|
||||
// No residences exist, create a quick one
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
if addButton.waitForExistence(timeout: 5) {
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Fill minimal required fields
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
if nameField.waitForExistence(timeout: 5) {
|
||||
nameField.tap()
|
||||
nameField.typeText("Test Home for Tasks")
|
||||
|
||||
// Scroll to address fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Fill required address fields
|
||||
let streetField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Street'")).firstMatch
|
||||
if streetField.exists {
|
||||
streetField.tap()
|
||||
streetField.typeText("123 Test St")
|
||||
}
|
||||
|
||||
let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'City'")).firstMatch
|
||||
if cityField.exists {
|
||||
cityField.tap()
|
||||
cityField.typeText("TestCity")
|
||||
}
|
||||
|
||||
let stateField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'State'")).firstMatch
|
||||
if stateField.exists {
|
||||
stateField.tap()
|
||||
stateField.typeText("TS")
|
||||
}
|
||||
|
||||
let postalField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Postal' OR placeholderValue CONTAINS[c] 'Zip'")).firstMatch
|
||||
if postalField.exists {
|
||||
postalField.tap()
|
||||
postalField.typeText("12345")
|
||||
}
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3) // Wait for save to complete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateToTasksTab() {
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
if tasksTab.waitForExistence(timeout: 5) {
|
||||
if !tasksTab.isSelected {
|
||||
tasksTab.tap()
|
||||
sleep(3) // Give it time to load
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the Add Task button using multiple strategies
|
||||
/// The button exists in two places:
|
||||
/// 1. Toolbar (always visible when residences exist)
|
||||
/// 2. Empty state (visible when no tasks exist)
|
||||
private func findAddTaskButton() -> XCUIElement {
|
||||
sleep(2) // Wait for screen to fully render
|
||||
|
||||
// Strategy 1: Try accessibility identifier
|
||||
let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
if addButtonById.exists && addButtonById.isEnabled {
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
// Strategy 2: Look for toolbar add button (navigation bar plus button)
|
||||
let navBarButtons = app.navigationBars.buttons
|
||||
for i in 0..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if button.label == "plus" || button.label.contains("Add") {
|
||||
if button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try finding "Add Task" button in empty state by text
|
||||
let emptyStateButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch
|
||||
if emptyStateButton.exists && emptyStateButton.isEnabled {
|
||||
return emptyStateButton
|
||||
}
|
||||
|
||||
// Strategy 4: Look for any enabled button with a plus icon
|
||||
let allButtons = app.buttons
|
||||
for i in 0..<min(allButtons.count, 20) { // Check first 20 buttons
|
||||
let button = allButtons.element(boundBy: i)
|
||||
if button.isEnabled && (button.label.contains("plus") || button.label.contains("Add")) {
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
// Return the identifier one as fallback (will fail assertion if doesn't exist)
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
// MARK: - 1. Error/Validation Tests
|
||||
|
||||
func test01_cancelTaskCreation() {
|
||||
// Given: User is on add task form
|
||||
navigateToTasksTab()
|
||||
sleep(3)
|
||||
|
||||
let addButton = findAddTaskButton()
|
||||
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add task button should exist and be enabled")
|
||||
addButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify form opened
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task form should open")
|
||||
|
||||
// When: User taps cancel
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
XCTAssertTrue(cancelButton.exists, "Cancel button should exist in task form")
|
||||
cancelButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Then: Should return to tasks list
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list after cancel")
|
||||
}
|
||||
|
||||
// MARK: - 2. View/List Tests
|
||||
|
||||
func test02_tasksTabExists() {
|
||||
// Given: User is logged in
|
||||
// When: User looks for Tasks tab
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
|
||||
// Then: Tasks tab should exist
|
||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist in main tab bar")
|
||||
XCTAssertTrue(tasksTab.isSelected, "Tasks tab should be selected after navigation")
|
||||
}
|
||||
|
||||
func test03_viewTasksList() {
|
||||
// Given: User is on Tasks tab
|
||||
navigateToTasksTab()
|
||||
sleep(3)
|
||||
|
||||
// Then: Tasks screen should be visible
|
||||
// Verify we're on the right screen by checking for the navigation title
|
||||
let tasksTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'All Tasks' OR label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTitle.waitForExistence(timeout: 5), "Tasks screen title should be visible")
|
||||
}
|
||||
|
||||
func test04_addTaskButtonExists() {
|
||||
// Given: User is on Tasks tab with at least one residence
|
||||
navigateToTasksTab()
|
||||
sleep(3)
|
||||
|
||||
// Then: Add task button should exist and be enabled
|
||||
let addButton = findAddTaskButton()
|
||||
XCTAssertTrue(addButton.exists, "Add task button should exist on Tasks screen")
|
||||
XCTAssertTrue(addButton.isEnabled, "Add task button should be enabled when residence exists")
|
||||
}
|
||||
|
||||
func test05_navigateToAddTask() {
|
||||
// Given: User is on Tasks tab
|
||||
navigateToTasksTab()
|
||||
sleep(3)
|
||||
|
||||
// When: User taps add task button
|
||||
let addButton = findAddTaskButton()
|
||||
XCTAssertTrue(addButton.exists, "Add task button should exist")
|
||||
XCTAssertTrue(addButton.isEnabled, "Add task button should be enabled")
|
||||
|
||||
addButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Then: Should show add task form with required fields
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title' OR placeholderValue CONTAINS[c] 'Task'")).firstMatch
|
||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should appear in add form")
|
||||
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
|
||||
}
|
||||
|
||||
// MARK: - 3. Creation Tests
|
||||
|
||||
func test06_createBasicTask() {
|
||||
// Given: User is on Tasks tab
|
||||
navigateToTasksTab()
|
||||
sleep(3)
|
||||
|
||||
// When: User taps add task button
|
||||
let addButton = findAddTaskButton()
|
||||
XCTAssertTrue(addButton.exists && addButton.isEnabled, "Add task button should exist and be enabled")
|
||||
addButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify task form loaded
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
XCTAssertTrue(titleField.waitForExistence(timeout: 5), "Task title field should appear")
|
||||
|
||||
// Fill in task title with unique timestamp
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let taskTitle = "UITest Task \(timestamp)"
|
||||
titleField.tap()
|
||||
titleField.typeText(taskTitle)
|
||||
|
||||
// Scroll down to find and fill description
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
|
||||
if descField.exists {
|
||||
descField.tap()
|
||||
descField.typeText("Test task")
|
||||
}
|
||||
|
||||
// Scroll to find Save button
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// When: User taps save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||
saveButton.tap()
|
||||
|
||||
// Then: Should return to tasks list
|
||||
sleep(5) // Wait for API call to complete
|
||||
|
||||
// Verify we're back on tasks list by checking tab exists
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list after saving")
|
||||
|
||||
// Verify task appears in the list (may be in kanban columns)
|
||||
let newTask = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(taskTitle)'")).firstMatch
|
||||
XCTAssertTrue(newTask.waitForExistence(timeout: 10), "New task '\(taskTitle)' should appear in the list")
|
||||
}
|
||||
|
||||
// MARK: - 4. View Details Tests
|
||||
|
||||
func test07_viewTaskDetails() {
|
||||
// Given: User is on Tasks tab and at least one task exists
|
||||
navigateToTasksTab()
|
||||
sleep(3)
|
||||
|
||||
// Look for any task in the list
|
||||
let taskCard = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'UITest Task' OR label CONTAINS 'Test'")).firstMatch
|
||||
|
||||
if !taskCard.waitForExistence(timeout: 5) {
|
||||
// No task found - skip this test
|
||||
print("No tasks found - run testCreateBasicTask first")
|
||||
return
|
||||
}
|
||||
|
||||
// When: User taps on a task
|
||||
taskCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Then: Should show task details screen
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete' OR label CONTAINS[c] 'Mark'")).firstMatch
|
||||
let backButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Back' OR label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
|
||||
let detailScreenVisible = editButton.exists || completeButton.exists || backButton.exists
|
||||
XCTAssertTrue(detailScreenVisible, "Task details screen should show with action buttons")
|
||||
}
|
||||
|
||||
// MARK: - 5. Navigation Tests
|
||||
|
||||
func test08_navigateToContractors() {
|
||||
// Given: User is on Tasks tab
|
||||
navigateToTasksTab()
|
||||
sleep(1)
|
||||
|
||||
// When: User taps Contractors tab
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
XCTAssertTrue(contractorsTab.waitForExistence(timeout: 5), "Contractors tab should exist")
|
||||
contractorsTab.tap()
|
||||
sleep(1)
|
||||
|
||||
// Then: Should be on Contractors tab
|
||||
XCTAssertTrue(contractorsTab.isSelected, "Contractors tab should be selected")
|
||||
}
|
||||
|
||||
func test09_navigateToDocuments() {
|
||||
// Given: User is on Tasks tab
|
||||
navigateToTasksTab()
|
||||
sleep(1)
|
||||
|
||||
// When: User taps Documents tab
|
||||
let documentsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents'")).firstMatch
|
||||
XCTAssertTrue(documentsTab.waitForExistence(timeout: 5), "Documents tab should exist")
|
||||
documentsTab.tap()
|
||||
sleep(1)
|
||||
|
||||
// Then: Should be on Documents tab
|
||||
XCTAssertTrue(documentsTab.isSelected, "Documents tab should be selected")
|
||||
}
|
||||
|
||||
func test10_navigateBetweenTabs() {
|
||||
// Given: User is on Tasks tab
|
||||
navigateToTasksTab()
|
||||
sleep(1)
|
||||
|
||||
// When: User navigates to Residences tab
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
|
||||
// Then: Should be on Residences tab
|
||||
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
|
||||
|
||||
// When: User navigates back to Tasks
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
tasksTab.tap()
|
||||
sleep(2)
|
||||
|
||||
// Then: Should be back on Tasks tab
|
||||
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab")
|
||||
}
|
||||
}
|
||||
655
iosApp/HoneyDueUITests/Suite6_ComprehensiveTaskTests.swift
Normal file
655
iosApp/HoneyDueUITests/Suite6_ComprehensiveTaskTests.swift
Normal file
@@ -0,0 +1,655 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive task testing suite covering all scenarios, edge cases, and variations
|
||||
/// This test suite is designed to be bulletproof and catch regressions early
|
||||
///
|
||||
/// Test Order (least to most complex):
|
||||
/// 1. Error/incomplete data tests
|
||||
/// 2. Creation tests
|
||||
/// 3. Edit/update tests
|
||||
/// 4. Delete/remove tests (none currently)
|
||||
/// 5. Navigation/view tests
|
||||
/// 6. Performance tests
|
||||
final class Suite6_ComprehensiveTaskTests: BaseUITestCase {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
// Test data tracking
|
||||
var createdTaskTitles: [String] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Ensure user is logged in
|
||||
UITestHelpers.ensureLoggedIn(app: app)
|
||||
|
||||
// CRITICAL: Ensure at least one residence exists
|
||||
ensureResidenceExists()
|
||||
|
||||
// Navigate to Tasks tab
|
||||
navigateToTasksTab()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
createdTaskTitles.removeAll()
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Ensures at least one residence exists (required for tasks to work)
|
||||
private func ensureResidenceExists() {
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
if residencesTab.waitForExistence(timeout: 5) {
|
||||
residencesTab.tap()
|
||||
sleep(2)
|
||||
|
||||
let emptyStateText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No properties' OR label CONTAINS[c] 'No residences'")).firstMatch
|
||||
|
||||
if emptyStateText.exists {
|
||||
createTestResidence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createTestResidence() {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Residence.addButton]
|
||||
guard addButton.waitForExistence(timeout: 5) else { return }
|
||||
addButton.tap()
|
||||
sleep(2)
|
||||
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
guard nameField.waitForExistence(timeout: 5) else { return }
|
||||
nameField.tap()
|
||||
nameField.typeText("Test Home for Comprehensive Tasks")
|
||||
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
fillField(placeholder: "Street", text: "123 Test St")
|
||||
fillField(placeholder: "City", text: "TestCity")
|
||||
fillField(placeholder: "State", text: "TS")
|
||||
fillField(placeholder: "Postal", text: "12345")
|
||||
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateToTasksTab() {
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
if tasksTab.waitForExistence(timeout: 5) {
|
||||
if !tasksTab.isSelected {
|
||||
tasksTab.tap()
|
||||
sleep(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openTaskForm() -> Bool {
|
||||
let addButton = findAddTaskButton()
|
||||
guard addButton.exists && addButton.isEnabled else { return false }
|
||||
addButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify form opened
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
return titleField.waitForExistence(timeout: 5)
|
||||
}
|
||||
|
||||
private func findAddTaskButton() -> XCUIElement {
|
||||
sleep(2)
|
||||
|
||||
let addButtonById = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
|
||||
if addButtonById.exists && addButtonById.isEnabled {
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
let navBarButtons = app.navigationBars.buttons
|
||||
for i in 0..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if button.label == "plus" || button.label.contains("Add") {
|
||||
if button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addButtonById
|
||||
}
|
||||
|
||||
private func fillField(placeholder: String, text: String) {
|
||||
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
|
||||
if field.exists {
|
||||
field.tap()
|
||||
field.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectPicker(label: String, option: String) {
|
||||
let picker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(label)'")).firstMatch
|
||||
if picker.exists {
|
||||
picker.tap()
|
||||
sleep(1)
|
||||
|
||||
// Try to find and tap the option
|
||||
let optionButton = app.buttons[option]
|
||||
if optionButton.exists {
|
||||
optionButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createTask(
|
||||
title: String,
|
||||
description: String? = nil,
|
||||
scrollToFindFields: Bool = true
|
||||
) -> Bool {
|
||||
guard openTaskForm() else { return false }
|
||||
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
titleField.tap()
|
||||
titleField.typeText(title)
|
||||
|
||||
if let desc = description {
|
||||
if scrollToFindFields { app.swipeUp(); sleep(1) }
|
||||
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
|
||||
if descField.exists {
|
||||
descField.tap()
|
||||
descField.typeText(desc)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to Save button
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
guard saveButton.exists else { return false }
|
||||
saveButton.tap()
|
||||
|
||||
sleep(4) // Wait for API call
|
||||
|
||||
// Track created task
|
||||
createdTaskTitles.append(title)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func findTask(title: String) -> XCUIElement {
|
||||
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(title)'")).firstMatch
|
||||
}
|
||||
|
||||
private func deleteAllTestTasks() {
|
||||
for title in createdTaskTitles {
|
||||
let task = findTask(title: title)
|
||||
if task.exists {
|
||||
task.tap()
|
||||
sleep(2)
|
||||
|
||||
// Try to find delete button
|
||||
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if deleteButton.exists {
|
||||
deleteButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm deletion
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Archive' OR label CONTAINS[c] 'Confirm'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Go back to list
|
||||
let backButton = app.navigationBars.buttons.firstMatch
|
||||
if backButton.exists {
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 1. Error/Validation Tests
|
||||
|
||||
func test01_cannotCreateTaskWithEmptyTitle() {
|
||||
guard openTaskForm() else {
|
||||
XCTFail("Failed to open task form")
|
||||
return
|
||||
}
|
||||
|
||||
// Leave title empty but fill other required fields
|
||||
// Select category
|
||||
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
|
||||
if categoryPicker.exists {
|
||||
app.staticTexts["Appliances"].firstMatch.tap()
|
||||
app.buttons["Plumbing"].firstMatch.tap()
|
||||
}
|
||||
|
||||
// Select frequency
|
||||
let frequencyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Frequency'")).firstMatch
|
||||
if frequencyPicker.exists {
|
||||
app.staticTexts["Once"].firstMatch.tap()
|
||||
app.buttons["Once"].firstMatch.tap()
|
||||
}
|
||||
|
||||
// Select priority
|
||||
let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch
|
||||
if priorityPicker.exists {
|
||||
app.staticTexts["High"].firstMatch.tap()
|
||||
app.buttons["Low"].firstMatch.tap()
|
||||
}
|
||||
|
||||
// Select status
|
||||
let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch
|
||||
if statusPicker.exists {
|
||||
app.staticTexts["Pending"].firstMatch.tap()
|
||||
app.buttons["Pending"].firstMatch.tap()
|
||||
}
|
||||
|
||||
// Scroll to save button
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save button should be disabled when title is empty
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||
XCTAssertFalse(saveButton.isEnabled, "Save button should be disabled when title is empty")
|
||||
}
|
||||
|
||||
func test02_cancelTaskCreation() {
|
||||
guard openTaskForm() else {
|
||||
XCTFail("Failed to open task form")
|
||||
return
|
||||
}
|
||||
|
||||
// Fill some data
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
titleField.tap()
|
||||
titleField.typeText("This will be canceled")
|
||||
|
||||
// Tap cancel
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
|
||||
cancelButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Should be back on tasks list
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTab.exists, "Should be back on tasks list")
|
||||
|
||||
// Task should not exist
|
||||
let task = findTask(title: "This will be canceled")
|
||||
XCTAssertFalse(task.exists, "Canceled task should not exist")
|
||||
}
|
||||
|
||||
// MARK: - 2. Creation Tests
|
||||
|
||||
func test03_createTaskWithMinimalData() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let taskTitle = "Minimal Task \(timestamp)"
|
||||
|
||||
let success = createTask(title: taskTitle)
|
||||
XCTAssertTrue(success, "Should successfully create task with minimal data")
|
||||
|
||||
let taskInList = findTask(title: taskTitle)
|
||||
XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Task should appear in list")
|
||||
}
|
||||
|
||||
func test04_createTaskWithAllFields() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let taskTitle = "Complete Task \(timestamp)"
|
||||
let description = "This is a comprehensive test task with all fields populated including a very detailed description."
|
||||
|
||||
let success = createTask(title: taskTitle, description: description)
|
||||
XCTAssertTrue(success, "Should successfully create task with all fields")
|
||||
|
||||
let taskInList = findTask(title: taskTitle)
|
||||
XCTAssertTrue(taskInList.waitForExistence(timeout: 10), "Complete task should appear in list")
|
||||
}
|
||||
|
||||
func test05_createMultipleTasksInSequence() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
for i in 1...3 {
|
||||
let taskTitle = "Sequential Task \(i) - \(timestamp)"
|
||||
let success = createTask(title: taskTitle)
|
||||
XCTAssertTrue(success, "Should create task \(i)")
|
||||
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify all tasks exist
|
||||
for i in 1...3 {
|
||||
let taskTitle = "Sequential Task \(i) - \(timestamp)"
|
||||
let task = findTask(title: taskTitle)
|
||||
XCTAssertTrue(task.exists, "Task \(i) should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
func test06_createTaskWithVeryLongTitle() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let longTitle = "This is an extremely long task title that goes on and on and on to test how the system handles very long text input in the title field \(timestamp)"
|
||||
|
||||
let success = createTask(title: longTitle)
|
||||
XCTAssertTrue(success, "Should handle very long titles")
|
||||
|
||||
// Verify it appears (may be truncated in display)
|
||||
let task = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long task title'")).firstMatch
|
||||
XCTAssertTrue(task.waitForExistence(timeout: 10), "Long title task should exist")
|
||||
}
|
||||
|
||||
func test07_createTaskWithSpecialCharacters() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let specialTitle = "Special !@#$%^&*() Task \(timestamp)"
|
||||
|
||||
let success = createTask(title: specialTitle)
|
||||
XCTAssertTrue(success, "Should handle special characters")
|
||||
|
||||
let task = findTask(title: "Special")
|
||||
XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with special chars should exist")
|
||||
}
|
||||
|
||||
func test08_createTaskWithEmojis() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let emojiTitle = "Fix Plumbing Task \(timestamp)"
|
||||
|
||||
let success = createTask(title: emojiTitle)
|
||||
XCTAssertTrue(success, "Should handle emojis")
|
||||
|
||||
let task = findTask(title: "Fix Plumbing")
|
||||
XCTAssertTrue(task.waitForExistence(timeout: 10), "Task with emojis should exist")
|
||||
}
|
||||
|
||||
// MARK: - 3. Edit/Update Tests
|
||||
|
||||
func test09_editTaskTitle() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let originalTitle = "Original Title \(timestamp)"
|
||||
let newTitle = "Edited Title \(timestamp)"
|
||||
|
||||
// Create task
|
||||
guard createTask(title: originalTitle) else {
|
||||
XCTFail("Failed to create task")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap task
|
||||
let task = findTask(title: originalTitle)
|
||||
XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist")
|
||||
task.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
if editButton.exists {
|
||||
editButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Edit title
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
if titleField.exists {
|
||||
titleField.tap()
|
||||
// Clear existing text
|
||||
titleField.doubleTap()
|
||||
sleep(1)
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
titleField.typeText(newTitle)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Track new title
|
||||
createdTaskTitles.append(newTitle)
|
||||
|
||||
// Verify new title appears
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
let updatedTask = findTask(title: newTitle)
|
||||
XCTAssertTrue(updatedTask.exists, "Task should show updated title")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test10_updateAllTaskFields() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let originalTitle = "Update All Fields \(timestamp)"
|
||||
let newTitle = "All Fields Updated \(timestamp)"
|
||||
let newDescription = "This task has been fully updated with all new values including description, category, priority, and status."
|
||||
|
||||
// Create task with initial values
|
||||
guard createTask(title: originalTitle, description: "Original description") else {
|
||||
XCTFail("Failed to create task")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap task
|
||||
let task = findTask(title: originalTitle)
|
||||
XCTAssertTrue(task.waitForExistence(timeout: 5), "Task should exist")
|
||||
task.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button
|
||||
let editButton = app.staticTexts.matching(identifier: "Actions").element(boundBy: 0).firstMatch
|
||||
XCTAssertTrue(editButton.exists, "Edit button should exist")
|
||||
editButton.tap()
|
||||
app.buttons["pencil"].firstMatch.tap()
|
||||
sleep(2)
|
||||
|
||||
// Update title
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
XCTAssertTrue(titleField.exists, "Title field should exist")
|
||||
titleField.tap()
|
||||
sleep(1)
|
||||
titleField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
sleep(1)
|
||||
titleField.typeText(newTitle)
|
||||
|
||||
// Scroll to description
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Update description
|
||||
let descField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Description'")).firstMatch
|
||||
if descField.exists {
|
||||
descField.tap()
|
||||
sleep(1)
|
||||
// Clear existing text
|
||||
descField.doubleTap()
|
||||
sleep(1)
|
||||
if app.buttons["Select All"].exists {
|
||||
app.buttons["Select All"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
descField.typeText(newDescription)
|
||||
}
|
||||
|
||||
// Update category (if picker exists)
|
||||
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
|
||||
if categoryPicker.exists {
|
||||
categoryPicker.tap()
|
||||
sleep(1)
|
||||
// Select a different category
|
||||
let electricalOption = app.buttons["Electrical"]
|
||||
if electricalOption.exists {
|
||||
electricalOption.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to more fields
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Update priority (if picker exists)
|
||||
let priorityPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Priority'")).firstMatch
|
||||
if priorityPicker.exists {
|
||||
priorityPicker.tap()
|
||||
sleep(1)
|
||||
// Select high priority
|
||||
let highOption = app.buttons["High"]
|
||||
if highOption.exists {
|
||||
highOption.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Update status (if picker exists)
|
||||
let statusPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Status'")).firstMatch
|
||||
if statusPicker.exists {
|
||||
statusPicker.tap()
|
||||
sleep(1)
|
||||
// Select in progress status
|
||||
let inProgressOption = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress' OR label CONTAINS[c] 'InProgress'")).firstMatch
|
||||
if inProgressOption.exists {
|
||||
inProgressOption.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to save button
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// Save
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist")
|
||||
saveButton.tap()
|
||||
sleep(4)
|
||||
|
||||
// Track new title
|
||||
createdTaskTitles.append(newTitle)
|
||||
|
||||
// Verify updated task appears in list with new title
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
let updatedTask = findTask(title: newTitle)
|
||||
XCTAssertTrue(updatedTask.exists, "Task should show updated title in list")
|
||||
|
||||
// Tap on task to verify details were updated
|
||||
updatedTask.tap()
|
||||
sleep(2)
|
||||
|
||||
// Verify updated priority (High) appears
|
||||
let highPriorityBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'High'")).firstMatch
|
||||
XCTAssertTrue(highPriorityBadge.exists || true, "Updated priority should be visible (if priority is shown in detail)")
|
||||
}
|
||||
|
||||
// MARK: - 4. Navigation/View Tests
|
||||
|
||||
func test11_navigateFromTasksToOtherTabs() {
|
||||
// From Tasks tab
|
||||
navigateToTasksTab()
|
||||
|
||||
// Navigate to Residences
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
|
||||
|
||||
// Navigate back to Tasks
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
tasksTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab")
|
||||
|
||||
// Navigate to Contractors
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
XCTAssertTrue(contractorsTab.exists, "Contractors tab should exist")
|
||||
contractorsTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
|
||||
|
||||
// Back to Tasks
|
||||
tasksTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(tasksTab.isSelected, "Should be back on Tasks tab again")
|
||||
}
|
||||
|
||||
func test12_refreshTasksList() {
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
|
||||
// Pull to refresh (if implemented) or use refresh button
|
||||
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
|
||||
if refreshButton.exists {
|
||||
refreshButton.tap()
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
// Verify we're still on tasks tab
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTab.isSelected, "Should still be on Tasks tab after refresh")
|
||||
}
|
||||
|
||||
// MARK: - 5. Persistence Tests
|
||||
|
||||
func test13_taskPersistsAfterBackgroundingApp() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let taskTitle = "Persistence Test \(timestamp)"
|
||||
|
||||
// Create task
|
||||
guard createTask(title: taskTitle) else {
|
||||
XCTFail("Failed to create task")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
|
||||
// Verify task exists
|
||||
var task = findTask(title: taskTitle)
|
||||
XCTAssertTrue(task.exists, "Task should exist before backgrounding")
|
||||
|
||||
// Background and reactivate app
|
||||
XCUIDevice.shared.press(.home)
|
||||
sleep(2)
|
||||
app.activate()
|
||||
sleep(3)
|
||||
|
||||
// Navigate back to tasks
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
|
||||
// Verify task still exists
|
||||
task = findTask(title: taskTitle)
|
||||
XCTAssertTrue(task.exists, "Task should persist after backgrounding app")
|
||||
}
|
||||
|
||||
// MARK: - 6. Performance Tests
|
||||
|
||||
func test14_taskListPerformance() {
|
||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
||||
navigateToTasksTab()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
func test15_taskCreationPerformance() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
measure(metrics: [XCTClockMetric()]) {
|
||||
let taskTitle = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
|
||||
_ = createTask(title: taskTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
717
iosApp/HoneyDueUITests/Suite7_ContractorTests.swift
Normal file
717
iosApp/HoneyDueUITests/Suite7_ContractorTests.swift
Normal file
@@ -0,0 +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 {
|
||||
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..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if button.label == "plus" || button.label.contains("Add") {
|
||||
if button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for any button with plus icon
|
||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
|
||||
}
|
||||
|
||||
private func fillTextField(placeholder: String, text: String) {
|
||||
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
|
||||
if field.exists {
|
||||
field.tap()
|
||||
field.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSpecialty(specialty: String) {
|
||||
let specialtyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Specialty'")).firstMatch
|
||||
if specialtyPicker.exists {
|
||||
specialtyPicker.tap()
|
||||
sleep(1)
|
||||
|
||||
// Try to find and tap the specialty option
|
||||
let specialtyButton = app.buttons[specialty]
|
||||
if specialtyButton.exists {
|
||||
specialtyButton.tap()
|
||||
sleep(1)
|
||||
} else {
|
||||
// Try cells if it's a navigation style picker
|
||||
let cells = app.cells
|
||||
for i in 0..<cells.count {
|
||||
let cell = cells.element(boundBy: i)
|
||||
if cell.staticTexts[specialty].exists {
|
||||
cell.tap()
|
||||
sleep(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createContractor(
|
||||
name: String,
|
||||
phone: String = "555-123-4567",
|
||||
email: String? = nil,
|
||||
company: String? = nil,
|
||||
specialty: String? = nil,
|
||||
scrollBeforeSave: Bool = true
|
||||
) -> Bool {
|
||||
guard openContractorForm() else { return false }
|
||||
|
||||
// Fill name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
nameField.tap()
|
||||
nameField.typeText(name)
|
||||
|
||||
// Fill phone (required field)
|
||||
fillTextField(placeholder: "Phone", text: phone)
|
||||
|
||||
// Fill optional fields
|
||||
if let email = email {
|
||||
fillTextField(placeholder: "Email", text: email)
|
||||
}
|
||||
|
||||
if let company = company {
|
||||
fillTextField(placeholder: "Company", text: company)
|
||||
}
|
||||
|
||||
// Select specialty if provided
|
||||
if let specialty = specialty {
|
||||
selectSpecialty(specialty: specialty)
|
||||
}
|
||||
|
||||
// Scroll to save button if needed
|
||||
if scrollBeforeSave {
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Add button (for creating new contractors)
|
||||
let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
|
||||
guard addButton.exists else { return false }
|
||||
addButton.tap()
|
||||
|
||||
sleep(4) // Wait for API call
|
||||
|
||||
// Track created contractor
|
||||
createdContractorNames.append(name)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func findContractor(name: String, scrollIfNeeded: Bool = true) -> XCUIElement {
|
||||
let element = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
|
||||
|
||||
// If element is visible, return it immediately
|
||||
if element.exists && element.isHittable {
|
||||
return element
|
||||
}
|
||||
|
||||
// If scrolling is not needed, return the element as-is
|
||||
guard scrollIfNeeded else {
|
||||
return element
|
||||
}
|
||||
|
||||
// Get the scroll view
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
guard scrollView.exists else {
|
||||
return element
|
||||
}
|
||||
|
||||
// First, scroll to the top of the list
|
||||
scrollView.swipeDown(velocity: .fast)
|
||||
usleep(30_000) // 0.03 second delay
|
||||
|
||||
// Now scroll down from top, checking after each swipe
|
||||
var lastVisibleRow = ""
|
||||
for _ in 0..<Int.max {
|
||||
// Check if element is now visible
|
||||
if element.exists && element.isHittable {
|
||||
return element
|
||||
}
|
||||
|
||||
// Get the last visible row before swiping
|
||||
let visibleTexts = app.staticTexts.allElementsBoundByIndex.filter { $0.isHittable }
|
||||
let currentLastRow = visibleTexts.last?.label ?? ""
|
||||
|
||||
// If last row hasn't changed, we've reached the end
|
||||
if !lastVisibleRow.isEmpty && currentLastRow == lastVisibleRow {
|
||||
break
|
||||
}
|
||||
|
||||
lastVisibleRow = currentLastRow
|
||||
|
||||
// Scroll down one swipe
|
||||
scrollView.swipeUp(velocity: .slow)
|
||||
usleep(50_000) // 0.05 second delay
|
||||
}
|
||||
|
||||
// Return element (test assertions will handle if not found)
|
||||
return element
|
||||
}
|
||||
|
||||
// MARK: - 1. Validation & Error Handling Tests
|
||||
|
||||
func test01_cannotCreateContractorWithEmptyName() {
|
||||
guard openContractorForm() else {
|
||||
XCTFail("Failed to open contractor form")
|
||||
return
|
||||
}
|
||||
|
||||
// Leave name empty, fill only phone
|
||||
fillTextField(placeholder: "Phone", text: "555-123-4567")
|
||||
|
||||
// Scroll to Add button if needed
|
||||
app.swipeUp()
|
||||
sleep(1)
|
||||
|
||||
// When creating, button should say "Add"
|
||||
let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch
|
||||
XCTAssertTrue(addButton.exists, "Add button should exist when creating contractor")
|
||||
XCTAssertFalse(addButton.isEnabled, "Add button should be disabled when name is empty")
|
||||
}
|
||||
|
||||
func test02_cancelContractorCreation() {
|
||||
guard openContractorForm() else {
|
||||
XCTFail("Failed to open contractor form")
|
||||
return
|
||||
}
|
||||
|
||||
// Fill some data
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
nameField.tap()
|
||||
nameField.typeText("This will be canceled")
|
||||
|
||||
// Tap cancel
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
|
||||
cancelButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Should be back on contractors list
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
XCTAssertTrue(contractorsTab.exists, "Should be back on contractors list")
|
||||
|
||||
// Contractor should not exist
|
||||
let contractor = findContractor(name: "This will be canceled")
|
||||
XCTAssertFalse(contractor.exists, "Canceled contractor should not exist")
|
||||
}
|
||||
|
||||
// MARK: - 2. Basic Contractor Creation Tests
|
||||
|
||||
func test03_createContractorWithMinimalData() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let contractorName = "John Doe \(timestamp)"
|
||||
|
||||
let success = createContractor(name: contractorName)
|
||||
XCTAssertTrue(success, "Should successfully create contractor with minimal data")
|
||||
|
||||
let contractorInList = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractorInList.waitForExistence(timeout: 10), "Contractor should appear in list")
|
||||
}
|
||||
|
||||
func test04_createContractorWithAllFields() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let contractorName = "Jane Smith \(timestamp)"
|
||||
|
||||
let success = createContractor(
|
||||
name: contractorName,
|
||||
phone: "555-987-6543",
|
||||
email: "jane.smith@example.com",
|
||||
company: "Smith Plumbing Inc",
|
||||
specialty: "Plumbing"
|
||||
)
|
||||
XCTAssertTrue(success, "Should successfully create contractor with all fields")
|
||||
|
||||
let contractorInList = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractorInList.waitForExistence(timeout: 10), "Complete contractor should appear in list")
|
||||
}
|
||||
|
||||
func test05_createContractorWithDifferentSpecialties() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let specialties = ["Plumbing", "Electrical", "HVAC"]
|
||||
|
||||
for (index, specialty) in specialties.enumerated() {
|
||||
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
|
||||
let success = createContractor(name: contractorName, specialty: specialty)
|
||||
XCTAssertTrue(success, "Should create \(specialty) contractor")
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify all contractors exist
|
||||
for (index, specialty) in specialties.enumerated() {
|
||||
let contractorName = "\(specialty) Expert \(timestamp)_\(index)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "\(specialty) contractor should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
func test06_createMultipleContractorsInSequence() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
for i in 1...3 {
|
||||
let contractorName = "Sequential Contractor \(i) - \(timestamp)"
|
||||
let success = createContractor(name: contractorName)
|
||||
XCTAssertTrue(success, "Should create contractor \(i)")
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify all contractors exist
|
||||
for i in 1...3 {
|
||||
let contractorName = "Sequential Contractor \(i) - \(timestamp)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor \(i) should exist in list")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 3. Edge Case Tests - Phone Numbers
|
||||
|
||||
func test07_createContractorWithDifferentPhoneFormats() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let phoneFormats = [
|
||||
("555-123-4567", "Dashed"),
|
||||
("(555) 123-4567", "Parentheses"),
|
||||
("5551234567", "NoFormat"),
|
||||
("555.123.4567", "Dotted")
|
||||
]
|
||||
|
||||
for (index, (phone, format)) in phoneFormats.enumerated() {
|
||||
let contractorName = "\(format) Phone \(timestamp)_\(index)"
|
||||
let success = createContractor(name: contractorName, phone: phone)
|
||||
XCTAssertTrue(success, "Should create contractor with \(format) phone format")
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify all contractors exist
|
||||
for (index, (_, format)) in phoneFormats.enumerated() {
|
||||
let contractorName = "\(format) Phone \(timestamp)_\(index)"
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor with \(format) phone should exist")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 4. Edge Case Tests - Emails
|
||||
|
||||
func test08_createContractorWithValidEmails() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let emails = [
|
||||
"simple@example.com",
|
||||
"firstname.lastname@example.com",
|
||||
"email+tag@example.co.uk",
|
||||
"email_with_underscore@example.com"
|
||||
]
|
||||
|
||||
for (index, email) in emails.enumerated() {
|
||||
let contractorName = "Email Test \(index) - \(timestamp)"
|
||||
let success = createContractor(name: contractorName, email: email)
|
||||
XCTAssertTrue(success, "Should create contractor with email: \(email)")
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 5. Edge Case Tests - Names
|
||||
|
||||
func test09_createContractorWithVeryLongName() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let longName = "John Christopher Alexander Montgomery Wellington III Esquire \(timestamp)"
|
||||
|
||||
let success = createContractor(name: longName)
|
||||
XCTAssertTrue(success, "Should handle very long names")
|
||||
|
||||
// Verify it appears (may be truncated in display)
|
||||
let contractor = findContractor(name: "John Christopher")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Long name contractor should exist")
|
||||
}
|
||||
|
||||
func test10_createContractorWithSpecialCharactersInName() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let specialName = "O'Brien-Smith Jr. \(timestamp)"
|
||||
|
||||
let success = createContractor(name: specialName)
|
||||
XCTAssertTrue(success, "Should handle special characters in names")
|
||||
|
||||
let contractor = findContractor(name: "O'Brien")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with special chars should exist")
|
||||
}
|
||||
|
||||
func test11_createContractorWithInternationalCharacters() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let internationalName = "José García \(timestamp)"
|
||||
|
||||
let success = createContractor(name: internationalName)
|
||||
XCTAssertTrue(success, "Should handle international characters")
|
||||
|
||||
let contractor = findContractor(name: "José")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with international chars should exist")
|
||||
}
|
||||
|
||||
func test12_createContractorWithEmojisInName() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let emojiName = "Bob 🔧 Builder \(timestamp)"
|
||||
|
||||
let success = createContractor(name: emojiName)
|
||||
XCTAssertTrue(success, "Should handle emojis in names")
|
||||
|
||||
let contractor = findContractor(name: "Bob")
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 10), "Contractor with emojis should exist")
|
||||
}
|
||||
|
||||
// MARK: - 6. Contractor Editing Tests
|
||||
|
||||
func test13_editContractorName() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let originalName = "Original Contractor \(timestamp)"
|
||||
let newName = "Edited Contractor \(timestamp)"
|
||||
|
||||
// Create contractor
|
||||
guard createContractor(name: originalName) else {
|
||||
XCTFail("Failed to create contractor")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap contractor
|
||||
let contractor = findContractor(name: originalName)
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 5), "Contractor should exist")
|
||||
contractor.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button (may be in menu)
|
||||
app/*@START_MENU_TOKEN@*/.images["ellipsis.circle"]/*[[".buttons[\"More\"].images",".buttons",".images[\"More\"]",".images[\"ellipsis.circle\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.buttons["pencil"]/*[[".buttons.containing(.image, identifier: \"pencil\")",".cells",".buttons[\"Edit\"]",".buttons[\"pencil\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
// Edit name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
if nameField.exists {
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
sleep(1)
|
||||
nameField.typeText(newName)
|
||||
|
||||
// Save (when editing, button should say "Save")
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
if saveButton.exists {
|
||||
saveButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Track new name
|
||||
createdContractorNames.append(newName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test14_updateAllContractorFields() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let originalName = "Update All Fields \(timestamp)"
|
||||
let newName = "All Fields Updated \(timestamp)"
|
||||
let newPhone = "999-888-7777"
|
||||
let newEmail = "updated@contractor.com"
|
||||
let newCompany = "Updated Company LLC"
|
||||
|
||||
// Create contractor with initial values
|
||||
guard createContractor(
|
||||
name: originalName,
|
||||
phone: "555-123-4567",
|
||||
email: "original@contractor.com",
|
||||
company: "Original Company"
|
||||
) else {
|
||||
XCTFail("Failed to create contractor")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap contractor
|
||||
let contractor = findContractor(name: originalName)
|
||||
XCTAssertTrue(contractor.waitForExistence(timeout: 5), "Contractor should exist")
|
||||
contractor.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button (may be in menu)
|
||||
app/*@START_MENU_TOKEN@*/.images["ellipsis.circle"]/*[[".buttons[\"More\"].images",".buttons",".images[\"More\"]",".images[\"ellipsis.circle\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
app/*@START_MENU_TOKEN@*/.buttons["pencil"]/*[[".buttons.containing(.image, identifier: \"pencil\")",".cells",".buttons[\"Edit\"]",".buttons[\"pencil\"]"],[[[-1,3],[-1,2],[-1,1,1],[-1,0]],[[-1,3],[-1,2]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
|
||||
// Update name
|
||||
let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Name'")).firstMatch
|
||||
XCTAssertTrue(nameField.exists, "Name field should exist")
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
nameField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
sleep(1)
|
||||
nameField.typeText(newName)
|
||||
|
||||
// Update phone
|
||||
let phoneField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Phone'")).firstMatch
|
||||
if phoneField.exists {
|
||||
phoneField.tap()
|
||||
sleep(1)
|
||||
phoneField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
phoneField.typeText(newPhone)
|
||||
}
|
||||
|
||||
// Update email
|
||||
let emailField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Email'")).firstMatch
|
||||
if emailField.exists {
|
||||
emailField.tap()
|
||||
sleep(1)
|
||||
emailField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
emailField.typeText(newEmail)
|
||||
}
|
||||
|
||||
// Update company
|
||||
let companyField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Company'")).firstMatch
|
||||
if companyField.exists {
|
||||
companyField.tap()
|
||||
sleep(1)
|
||||
companyField.tap()
|
||||
sleep(1)
|
||||
app.menuItems["Select All"].tap()
|
||||
companyField.typeText(newCompany)
|
||||
}
|
||||
|
||||
// Update specialty (if picker exists)
|
||||
let specialtyPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Specialty'")).firstMatch
|
||||
if specialtyPicker.exists {
|
||||
specialtyPicker.tap()
|
||||
sleep(1)
|
||||
// Select HVAC
|
||||
let hvacOption = app.buttons["HVAC"]
|
||||
if hvacOption.exists {
|
||||
hvacOption.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Save (when editing, button should say "Save")
|
||||
let saveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save'")).firstMatch
|
||||
XCTAssertTrue(saveButton.exists, "Save button should exist when editing contractor")
|
||||
saveButton.tap()
|
||||
sleep(4)
|
||||
|
||||
// Track new name
|
||||
createdContractorNames.append(newName)
|
||||
|
||||
// Verify updated contractor appears in list with new name
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
let updatedContractor = findContractor(name: newName)
|
||||
XCTAssertTrue(updatedContractor.exists, "Contractor should show updated name in list")
|
||||
|
||||
// Tap on contractor to verify details were updated
|
||||
updatedContractor.tap()
|
||||
sleep(2)
|
||||
|
||||
// Verify updated phone appears in detail view
|
||||
let phoneText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newPhone)' OR label CONTAINS '999-888-7777' OR label CONTAINS '9998887777'")).firstMatch
|
||||
XCTAssertTrue(phoneText.exists, "Updated phone should be visible in detail view")
|
||||
|
||||
// Verify updated email appears in detail view
|
||||
let emailText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newEmail)'")).firstMatch
|
||||
XCTAssertTrue(emailText.exists, "Updated email should be visible in detail view")
|
||||
|
||||
// Verify updated company appears in detail view
|
||||
let companyText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(newCompany)'")).firstMatch
|
||||
XCTAssertTrue(companyText.exists, "Updated company should be visible in detail view")
|
||||
|
||||
// Verify updated specialty (HVAC) appears
|
||||
let hvacBadge = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'HVAC'")).firstMatch
|
||||
XCTAssertTrue(hvacBadge.exists || true, "Updated specialty should be visible (if shown in detail)")
|
||||
}
|
||||
|
||||
// MARK: - 7. Navigation & List Tests
|
||||
|
||||
func test15_navigateFromContractorsToOtherTabs() {
|
||||
// From Contractors tab
|
||||
navigateToContractorsTab()
|
||||
|
||||
// Navigate to Residences
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
|
||||
residencesTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(residencesTab.isSelected, "Should be on Residences tab")
|
||||
|
||||
// Navigate back to Contractors
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
contractorsTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(contractorsTab.isSelected, "Should be back on Contractors tab")
|
||||
|
||||
// Navigate to Tasks
|
||||
let tasksTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
|
||||
XCTAssertTrue(tasksTab.exists, "Tasks tab should exist")
|
||||
tasksTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(tasksTab.isSelected, "Should be on Tasks tab")
|
||||
|
||||
// Back to Contractors
|
||||
contractorsTab.tap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(contractorsTab.isSelected, "Should be back on Contractors tab again")
|
||||
}
|
||||
|
||||
func test16_refreshContractorsList() {
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
|
||||
// Pull to refresh (if implemented) or use refresh button
|
||||
let refreshButton = app.navigationBars.buttons.containing(NSPredicate(format: "label CONTAINS 'arrow.clockwise' OR label CONTAINS 'refresh'")).firstMatch
|
||||
if refreshButton.exists {
|
||||
refreshButton.tap()
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
// Verify we're still on contractors tab
|
||||
let contractorsTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
|
||||
XCTAssertTrue(contractorsTab.isSelected, "Should still be on Contractors tab after refresh")
|
||||
}
|
||||
|
||||
func test17_viewContractorDetails() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let contractorName = "Detail View Test \(timestamp)"
|
||||
|
||||
// Create contractor
|
||||
guard createContractor(name: contractorName, email: "test@example.com", company: "Test Company") else {
|
||||
XCTFail("Failed to create contractor")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
|
||||
// Tap on contractor
|
||||
let contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor should exist")
|
||||
contractor.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify detail view appears with contact info
|
||||
let phoneLabel = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Phone' OR label CONTAINS '555'")).firstMatch
|
||||
let emailLabel = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Email' OR label CONTAINS 'test@example.com'")).firstMatch
|
||||
|
||||
XCTAssertTrue(phoneLabel.exists || emailLabel.exists, "Detail view should show contact information")
|
||||
}
|
||||
|
||||
// MARK: - 8. Data Persistence Tests
|
||||
|
||||
func test18_contractorPersistsAfterBackgroundingApp() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let contractorName = "Persistence Test \(timestamp)"
|
||||
|
||||
// Create contractor
|
||||
guard createContractor(name: contractorName) else {
|
||||
XCTFail("Failed to create contractor")
|
||||
return
|
||||
}
|
||||
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
|
||||
// Verify contractor exists
|
||||
var contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor should exist before backgrounding")
|
||||
|
||||
// Background and reactivate app
|
||||
XCUIDevice.shared.press(.home)
|
||||
sleep(2)
|
||||
app.activate()
|
||||
sleep(3)
|
||||
|
||||
// Navigate back to contractors
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
|
||||
// Verify contractor still exists
|
||||
contractor = findContractor(name: contractorName)
|
||||
XCTAssertTrue(contractor.exists, "Contractor should persist after backgrounding app")
|
||||
}
|
||||
|
||||
// MARK: - 9. Performance Tests
|
||||
|
||||
func test19_contractorListPerformance() {
|
||||
measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) {
|
||||
navigateToContractorsTab()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
func test20_contractorCreationPerformance() {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
|
||||
measure(metrics: [XCTClockMetric()]) {
|
||||
let contractorName = "Performance Test \(timestamp)_\(UUID().uuidString.prefix(8))"
|
||||
_ = createContractor(name: contractorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
944
iosApp/HoneyDueUITests/Suite8_DocumentWarrantyTests.swift
Normal file
944
iosApp/HoneyDueUITests/Suite8_DocumentWarrantyTests.swift
Normal file
@@ -0,0 +1,944 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive documents and warranties testing suite covering all scenarios, edge cases, and variations
|
||||
/// Tests both document types (permits, receipts, etc.) and warranties with filtering, searching, and CRUD operations
|
||||
final class Suite8_DocumentWarrantyTests: BaseUITestCase {
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
|
||||
// Test data tracking
|
||||
var createdDocumentTitles: [String] = []
|
||||
var currentResidenceId: Int32?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
// Ensure user is logged in
|
||||
UITestHelpers.ensureLoggedIn(app: app)
|
||||
|
||||
// Navigate to a residence first (documents are residence-specific)
|
||||
navigateToFirstResidence()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
createdDocumentTitles.removeAll()
|
||||
currentResidenceId = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func navigateToFirstResidence() {
|
||||
// Tap Residences tab
|
||||
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
|
||||
if residencesTab.waitForExistence(timeout: 5) {
|
||||
residencesTab.tap()
|
||||
sleep(3)
|
||||
}
|
||||
|
||||
// Tap first residence card
|
||||
let firstResidence = app.collectionViews.cells.firstMatch
|
||||
if firstResidence.waitForExistence(timeout: 5) {
|
||||
firstResidence.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateToDocumentsTab() {
|
||||
// Look for Documents tab or navigation link
|
||||
let documentsButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Documents' OR label CONTAINS[c] 'Warranties'")).firstMatch
|
||||
if documentsButton.waitForExistence(timeout: 5) {
|
||||
documentsButton.tap()
|
||||
sleep(3)
|
||||
}
|
||||
}
|
||||
|
||||
private func openDocumentForm() -> Bool {
|
||||
let addButton = findAddButton()
|
||||
guard addButton.exists && addButton.isEnabled else { return false }
|
||||
addButton.tap()
|
||||
sleep(3)
|
||||
|
||||
// Verify form opened
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'Title'")).firstMatch
|
||||
return titleField.waitForExistence(timeout: 5)
|
||||
}
|
||||
|
||||
private func findAddButton() -> XCUIElement {
|
||||
sleep(2)
|
||||
|
||||
// Look for add button by various methods
|
||||
let navBarButtons = app.navigationBars.buttons
|
||||
for i in 0..<navBarButtons.count {
|
||||
let button = navBarButtons.element(boundBy: i)
|
||||
if button.label == "plus" || button.label.contains("Add") {
|
||||
if button.isEnabled {
|
||||
return button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for any button with plus icon
|
||||
return app.buttons.containing(NSPredicate(format: "label CONTAINS 'plus'")).firstMatch
|
||||
}
|
||||
|
||||
private func fillTextField(placeholder: String, text: String) {
|
||||
let field = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] '\(placeholder)'")).firstMatch
|
||||
if field.exists {
|
||||
field.tap()
|
||||
field.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func fillTextEditor(text: String) {
|
||||
let textEditor = app.textViews.firstMatch
|
||||
if textEditor.exists {
|
||||
textEditor.tap()
|
||||
textEditor.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectProperty() {
|
||||
// Open the picker
|
||||
app.buttons["Select Property, Select Property"].tap()
|
||||
|
||||
// Try cells first (common for Picker list)
|
||||
let secondCell = app.cells.element(boundBy: 1)
|
||||
if secondCell.waitForExistence(timeout: 5) {
|
||||
secondCell.tap()
|
||||
} else {
|
||||
// Fallback: second static text after the title
|
||||
let allTexts = app.staticTexts.allElementsBoundByIndex
|
||||
// Expect something like: [ "Select Property" (title), "Select Property", "Test Home for Comprehensive Tasks", ... ]
|
||||
// So the second item row label is usually at index 2
|
||||
let secondItemText = allTexts[2]
|
||||
secondItemText.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func selectDocumentType(type: String) {
|
||||
let typePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Type'")).firstMatch
|
||||
if typePicker.exists {
|
||||
typePicker.tap()
|
||||
sleep(1)
|
||||
|
||||
let typeButton = app.buttons[type]
|
||||
if typeButton.exists {
|
||||
typeButton.tap()
|
||||
sleep(1)
|
||||
} else {
|
||||
// Try cells if it's a navigation style picker
|
||||
let cells = app.cells
|
||||
for i in 0..<cells.count {
|
||||
let cell = cells.element(boundBy: i)
|
||||
if cell.staticTexts[type].exists {
|
||||
cell.tap()
|
||||
sleep(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectCategory(category: String) {
|
||||
let categoryPicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Category'")).firstMatch
|
||||
if categoryPicker.exists {
|
||||
categoryPicker.tap()
|
||||
sleep(1)
|
||||
|
||||
let categoryButton = app.buttons[category]
|
||||
if categoryButton.exists {
|
||||
categoryButton.tap()
|
||||
sleep(1)
|
||||
} else {
|
||||
let cells = app.cells
|
||||
for i in 0..<cells.count {
|
||||
let cell = cells.element(boundBy: i)
|
||||
if cell.staticTexts[category].exists {
|
||||
cell.tap()
|
||||
sleep(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectDate(dateType: String, daysFromNow: Int) {
|
||||
let datePicker = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] '\(dateType)'")).firstMatch
|
||||
if datePicker.exists {
|
||||
datePicker.tap()
|
||||
sleep(1)
|
||||
|
||||
// Look for date picker and set date
|
||||
let datePickerWheel = app.datePickers.firstMatch
|
||||
if datePickerWheel.exists {
|
||||
let calendar = Calendar.current
|
||||
let targetDate = calendar.date(byAdding: .day, value: daysFromNow, to: Date())!
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
let dateString = formatter.string(from: targetDate)
|
||||
|
||||
// Try to type the date or interact with picker
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Dismiss picker
|
||||
app.buttons["Done"].tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func submitForm() -> Bool {
|
||||
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")).firstMatch
|
||||
guard submitButton.exists && submitButton.isEnabled else { return false }
|
||||
submitButton.tap()
|
||||
sleep(3)
|
||||
return true
|
||||
}
|
||||
|
||||
private func cancelForm() {
|
||||
let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch
|
||||
if cancelButton.exists {
|
||||
cancelButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToWarrantiesTab() {
|
||||
app/*@START_MENU_TOKEN@*/.buttons["checkmark.shield"]/*[[".segmentedControls",".buttons[\"Warranties\"]",".buttons[\"checkmark.shield\"]"],[[[-1,2],[-1,1],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
}
|
||||
|
||||
private func switchToDocumentsTab() {
|
||||
app/*@START_MENU_TOKEN@*/.buttons["doc.text"]/*[[".segmentedControls",".buttons[\"Documents\"]",".buttons[\"doc.text\"]"],[[[-1,2],[-1,0,1]],[[-1,2],[-1,1]]],[0]]@END_MENU_TOKEN@*/.firstMatch.tap()
|
||||
}
|
||||
|
||||
private func searchFor(text: String) {
|
||||
let searchField = app.searchFields.firstMatch
|
||||
if searchField.exists {
|
||||
searchField.tap()
|
||||
searchField.typeText(text)
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearSearch() {
|
||||
let searchField = app.searchFields.firstMatch
|
||||
if searchField.exists {
|
||||
let clearButton = searchField.buttons["Clear text"]
|
||||
if clearButton.exists {
|
||||
clearButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFilter(filterName: String) {
|
||||
// Open filter menu
|
||||
let filterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'line.3.horizontal.decrease'")).firstMatch
|
||||
if filterButton.exists {
|
||||
filterButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Select filter option
|
||||
let filterOption = app.buttons[filterName]
|
||||
if filterOption.exists {
|
||||
filterOption.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleActiveFilter() {
|
||||
let activeFilterButton = app.buttons.containing(NSPredicate(format: "label CONTAINS 'checkmark.circle'")).firstMatch
|
||||
if activeFilterButton.exists {
|
||||
activeFilterButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Cases
|
||||
|
||||
// MARK: Navigation Tests
|
||||
|
||||
func test01_NavigateToDocumentsScreen() {
|
||||
navigateToDocumentsTab()
|
||||
|
||||
// Verify we're on documents screen
|
||||
let navigationTitle = app.navigationBars["Documents & Warranties"]
|
||||
XCTAssertTrue(navigationTitle.waitForExistence(timeout: 5), "Should navigate to Documents & Warranties screen")
|
||||
|
||||
// Verify tabs are visible
|
||||
let warrantiesTab = app.buttons["Warranties"]
|
||||
let documentsTab = app.buttons["Documents"]
|
||||
XCTAssertTrue(warrantiesTab.exists || documentsTab.exists, "Should see tab switcher")
|
||||
}
|
||||
|
||||
func test02_SwitchBetweenWarrantiesAndDocuments() {
|
||||
navigateToDocumentsTab()
|
||||
|
||||
// Start on warranties tab
|
||||
switchToWarrantiesTab()
|
||||
sleep(1)
|
||||
|
||||
// Switch to documents tab
|
||||
switchToDocumentsTab()
|
||||
sleep(1)
|
||||
|
||||
// Switch back to warranties
|
||||
switchToWarrantiesTab()
|
||||
sleep(1)
|
||||
|
||||
// Should not crash and tabs should still exist
|
||||
let warrantiesTab = app.buttons["Warranties"]
|
||||
XCTAssertTrue(warrantiesTab.exists, "Tabs should remain functional after switching")
|
||||
}
|
||||
|
||||
// MARK: Document Creation Tests
|
||||
|
||||
func test03_CreateDocumentWithAllFields() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open document form")
|
||||
|
||||
let testTitle = "Test Permit \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
|
||||
// Fill all fields
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
fillTextEditor(text: "Test permit description with detailed information")
|
||||
fillTextField(placeholder: "Tags", text: "construction,permit")
|
||||
fillTextField(placeholder: "Item Name", text: "Kitchen Renovation")
|
||||
fillTextField(placeholder: "Location", text: "Main Kitchen")
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should submit form successfully")
|
||||
|
||||
// Verify document appears in list
|
||||
sleep(2)
|
||||
let documentCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(documentCard.exists, "Created document should appear in list")
|
||||
}
|
||||
|
||||
func test04_CreateDocumentWithMinimalFields() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open document form")
|
||||
|
||||
let testTitle = "Min Doc \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
|
||||
// Fill only required fields
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should submit form with minimal fields")
|
||||
|
||||
// Verify document appears
|
||||
sleep(2)
|
||||
let documentCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(documentCard.exists, "Document with minimal fields should appear")
|
||||
}
|
||||
|
||||
func test05_CreateDocumentWithEmptyTitle_ShouldFail() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open document form")
|
||||
|
||||
// Try to submit without title
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
selectDocumentType(type: "Insurance")
|
||||
|
||||
let submitButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Save' OR label CONTAINS[c] 'Add'")).firstMatch
|
||||
|
||||
// Submit button should be disabled or show error
|
||||
if submitButton.exists && submitButton.isEnabled {
|
||||
submitButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Should show error message
|
||||
let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'title'")).firstMatch
|
||||
XCTAssertTrue(errorMessage.exists, "Should show validation error for missing title")
|
||||
}
|
||||
|
||||
cancelForm()
|
||||
}
|
||||
|
||||
// MARK: Warranty Creation Tests
|
||||
|
||||
func test06_CreateWarrantyWithAllFields() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open warranty form")
|
||||
|
||||
let testTitle = "Test Warranty \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
|
||||
// Fill all warranty fields (including required fields)
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectCategory(category: "Appliances")
|
||||
fillTextField(placeholder: "Item Name", text: "Dishwasher") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "Bosch") // REQUIRED
|
||||
fillTextField(placeholder: "Model", text: "SHPM65Z55N")
|
||||
fillTextField(placeholder: "Serial", text: "SN123456789")
|
||||
fillTextField(placeholder: "Provider Contact", text: "1-800-BOSCH-00")
|
||||
fillTextEditor(text: "Full warranty coverage for 2 years")
|
||||
|
||||
// Select dates
|
||||
selectDate(dateType: "Start Date", daysFromNow: -30)
|
||||
selectDate(dateType: "End Date", daysFromNow: 700) // ~2 years
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should submit warranty successfully")
|
||||
|
||||
// Verify warranty appears
|
||||
sleep(2)
|
||||
let warrantyCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(warrantyCard.exists, "Created warranty should appear in list")
|
||||
}
|
||||
|
||||
func test07_CreateWarrantyWithFutureDates() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open warranty form")
|
||||
|
||||
let testTitle = "Future Warranty \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectCategory(category: "HVAC")
|
||||
fillTextField(placeholder: "Item Name", text: "Air Conditioner") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "Carrier HVAC") // REQUIRED
|
||||
|
||||
// Set start date in future
|
||||
selectDate(dateType: "Start Date", daysFromNow: 30)
|
||||
selectDate(dateType: "End Date", daysFromNow: 400)
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should create warranty with future dates")
|
||||
|
||||
sleep(2)
|
||||
let warrantyCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(warrantyCard.exists, "Warranty with future dates should be created")
|
||||
}
|
||||
|
||||
func test08_CreateExpiredWarranty() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open warranty form")
|
||||
|
||||
let testTitle = "Expired Warranty \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectCategory(category: "Plumbing")
|
||||
fillTextField(placeholder: "Item Name", text: "Water Heater") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "AO Smith") // REQUIRED
|
||||
|
||||
// Set dates in the past
|
||||
selectDate(dateType: "Start Date", daysFromNow: -400)
|
||||
selectDate(dateType: "End Date", daysFromNow: -30)
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should create expired warranty")
|
||||
|
||||
sleep(2)
|
||||
// Expired warranty might not show with active filter on
|
||||
// Toggle active filter off to see it
|
||||
toggleActiveFilter()
|
||||
sleep(1)
|
||||
|
||||
let warrantyCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(warrantyCard.exists, "Expired warranty should be created and visible when filter is off")
|
||||
}
|
||||
|
||||
// MARK: Search and Filter Tests
|
||||
|
||||
func test09_SearchDocumentsByTitle() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Create a test document first
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let searchableTitle = "Searchable Doc \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(searchableTitle)
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: searchableTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
XCTAssertTrue(submitForm(), "Should create document")
|
||||
sleep(2)
|
||||
|
||||
// Search for it
|
||||
searchFor(text: String(searchableTitle.prefix(15)))
|
||||
|
||||
// Should find the document
|
||||
let foundDocument = app.staticTexts[searchableTitle]
|
||||
XCTAssertTrue(foundDocument.exists, "Should find document by search")
|
||||
|
||||
clearSearch()
|
||||
}
|
||||
|
||||
func test10_FilterWarrantiesByCategory() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Apply category filter
|
||||
applyFilter(filterName: "Appliances")
|
||||
|
||||
sleep(2)
|
||||
|
||||
// Should show filter chip or indication
|
||||
let filterChip = app.staticTexts["Appliances"]
|
||||
XCTAssertTrue(filterChip.exists || app.buttons["Appliances"].exists, "Should show active category filter")
|
||||
|
||||
// Clear filter
|
||||
applyFilter(filterName: "All Categories")
|
||||
}
|
||||
|
||||
func test11_FilterDocumentsByType() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Apply type filter
|
||||
applyFilter(filterName: "Permit")
|
||||
|
||||
sleep(2)
|
||||
|
||||
// Should show filter indication
|
||||
let filterChip = app.staticTexts["Permit"]
|
||||
XCTAssertTrue(filterChip.exists || app.buttons["Permit"].exists, "Should show active type filter")
|
||||
|
||||
// Clear filter
|
||||
applyFilter(filterName: "All Types")
|
||||
}
|
||||
|
||||
func test12_ToggleActiveWarrantiesFilter() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Toggle active filter off
|
||||
toggleActiveFilter()
|
||||
sleep(1)
|
||||
|
||||
// Toggle it back on
|
||||
toggleActiveFilter()
|
||||
sleep(1)
|
||||
|
||||
// Should not crash
|
||||
let warrantiesTab = app.buttons["Warranties"]
|
||||
XCTAssertTrue(warrantiesTab.exists, "Active filter toggle should work without crashing")
|
||||
}
|
||||
|
||||
// MARK: Document Detail Tests
|
||||
|
||||
func test13_ViewDocumentDetail() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Create a document
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let testTitle = "Detail Test Doc \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
fillTextEditor(text: "This is a test receipt with details")
|
||||
XCTAssertTrue(submitForm(), "Should create document")
|
||||
sleep(2)
|
||||
|
||||
// Tap on the document card
|
||||
let documentCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(documentCard.exists, "Document should exist in list")
|
||||
documentCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Should show detail screen
|
||||
let detailTitle = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(detailTitle.exists, "Should show document detail screen")
|
||||
|
||||
// Go back
|
||||
let backButton = app.navigationBars.buttons.firstMatch
|
||||
backButton.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
func test14_ViewWarrantyDetailWithDates() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Create a warranty
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let testTitle = "Warranty Detail Test \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectCategory(category: "Appliances")
|
||||
fillTextField(placeholder: "Item Name", text: "Test Appliance") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "Test Company") // REQUIRED
|
||||
selectDate(dateType: "Start Date", daysFromNow: -30)
|
||||
selectDate(dateType: "End Date", daysFromNow: 335)
|
||||
XCTAssertTrue(submitForm(), "Should create warranty")
|
||||
sleep(2)
|
||||
|
||||
// Tap on warranty
|
||||
let warrantyCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(warrantyCard.exists, "Warranty should exist")
|
||||
warrantyCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Should show warranty details with dates
|
||||
let detailScreen = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(detailScreen.exists, "Should show warranty detail")
|
||||
|
||||
// Look for date information
|
||||
let dateLabels = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] '20' OR label CONTAINS[c] 'Start' OR label CONTAINS[c] 'End'"))
|
||||
XCTAssertTrue(dateLabels.count > 0, "Should display date information")
|
||||
|
||||
// Go back
|
||||
app.navigationBars.buttons.firstMatch.tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// MARK: Edit Tests
|
||||
|
||||
func test15_EditDocumentTitle() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Create document
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let originalTitle = "Edit Test \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(originalTitle)
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: originalTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
XCTAssertTrue(submitForm(), "Should create document")
|
||||
sleep(2)
|
||||
|
||||
// Open detail
|
||||
let documentCard = app.staticTexts[originalTitle]
|
||||
XCTAssertTrue(documentCard.exists, "Document should exist")
|
||||
documentCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Tap edit button
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
if editButton.exists {
|
||||
editButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Change title
|
||||
let titleField = app.textFields.containing(NSPredicate(format: "value == '\(originalTitle)'")).firstMatch
|
||||
if titleField.exists {
|
||||
titleField.tap()
|
||||
titleField.clearText()
|
||||
let newTitle = "Edited \(originalTitle)"
|
||||
titleField.typeText(newTitle)
|
||||
createdDocumentTitles.append(newTitle)
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should save edited document")
|
||||
sleep(2)
|
||||
|
||||
// Verify new title appears
|
||||
let updatedTitle = app.staticTexts[newTitle]
|
||||
XCTAssertTrue(updatedTitle.exists, "Updated title should appear")
|
||||
}
|
||||
}
|
||||
|
||||
// Go back to list
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
func test16_EditWarrantyDates() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Create warranty
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let testTitle = "Edit Dates Warranty \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(testTitle)
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: testTitle)
|
||||
selectCategory(category: "Electronics")
|
||||
fillTextField(placeholder: "Item Name", text: "TV") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "Samsung") // REQUIRED
|
||||
selectDate(dateType: "Start Date", daysFromNow: -60)
|
||||
selectDate(dateType: "End Date", daysFromNow: 305)
|
||||
XCTAssertTrue(submitForm(), "Should create warranty")
|
||||
sleep(2)
|
||||
|
||||
// Open and edit
|
||||
let warrantyCard = app.staticTexts[testTitle]
|
||||
XCTAssertTrue(warrantyCard.exists, "Warranty should exist")
|
||||
warrantyCard.tap()
|
||||
sleep(2)
|
||||
|
||||
let editButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Edit'")).firstMatch
|
||||
if editButton.exists {
|
||||
editButton.tap()
|
||||
sleep(2)
|
||||
|
||||
// Change end date to extend warranty
|
||||
selectDate(dateType: "End Date", daysFromNow: 730) // 2 years
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should save edited warranty dates")
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// MARK: Delete Tests
|
||||
|
||||
func test17_DeleteDocument() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Create document to delete
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let deleteTitle = "To Delete \(UUID().uuidString.prefix(8))"
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: deleteTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
XCTAssertTrue(submitForm(), "Should create document")
|
||||
sleep(2)
|
||||
|
||||
// Open detail
|
||||
let documentCard = app.staticTexts[deleteTitle]
|
||||
XCTAssertTrue(documentCard.exists, "Document should exist")
|
||||
documentCard.tap()
|
||||
sleep(2)
|
||||
|
||||
// Find and tap delete button
|
||||
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch
|
||||
if deleteButton.exists {
|
||||
deleteButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm deletion
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Should navigate back to list
|
||||
sleep(2)
|
||||
|
||||
// Verify document no longer exists
|
||||
let deletedCard = app.staticTexts[deleteTitle]
|
||||
XCTAssertFalse(deletedCard.exists, "Deleted document should not appear in list")
|
||||
}
|
||||
}
|
||||
|
||||
func test18_DeleteWarranty() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Create warranty to delete
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
let deleteTitle = "Warranty to Delete \(UUID().uuidString.prefix(8))"
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: deleteTitle)
|
||||
selectCategory(category: "Other")
|
||||
fillTextField(placeholder: "Item Name", text: "Test Item") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "Test Provider") // REQUIRED
|
||||
XCTAssertTrue(submitForm(), "Should create warranty")
|
||||
sleep(2)
|
||||
|
||||
// Open and delete
|
||||
let warrantyCard = app.staticTexts[deleteTitle]
|
||||
XCTAssertTrue(warrantyCard.exists, "Warranty should exist")
|
||||
warrantyCard.tap()
|
||||
sleep(2)
|
||||
|
||||
let deleteButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'trash'")).firstMatch
|
||||
if deleteButton.exists {
|
||||
deleteButton.tap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm
|
||||
let confirmButton = app.alerts.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Delete'")).firstMatch
|
||||
if confirmButton.exists {
|
||||
confirmButton.tap()
|
||||
sleep(2)
|
||||
}
|
||||
|
||||
// Verify deleted
|
||||
sleep(2)
|
||||
let deletedCard = app.staticTexts[deleteTitle]
|
||||
XCTAssertFalse(deletedCard.exists, "Deleted warranty should not appear")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Edge Cases and Error Handling
|
||||
|
||||
func test19_CancelDocumentCreation() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
|
||||
// Fill some fields
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: "Cancelled Document")
|
||||
selectDocumentType(type: "Insurance")
|
||||
|
||||
// Cancel instead of save
|
||||
cancelForm()
|
||||
|
||||
// Should not appear in list
|
||||
sleep(2)
|
||||
let cancelledDoc = app.staticTexts["Cancelled Document"]
|
||||
XCTAssertFalse(cancelledDoc.exists, "Cancelled document should not be created")
|
||||
}
|
||||
|
||||
func test20_HandleEmptyDocumentsList() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
// Apply very specific filter to get empty list
|
||||
searchFor(text: "NONEXISTENT_DOCUMENT_12345")
|
||||
|
||||
sleep(2)
|
||||
|
||||
// Should show empty state
|
||||
let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No documents' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch
|
||||
|
||||
// Either empty state exists or no items are shown
|
||||
let hasNoItems = app.cells.count == 0
|
||||
XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty documents list gracefully")
|
||||
|
||||
clearSearch()
|
||||
}
|
||||
|
||||
func test21_HandleEmptyWarrantiesList() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Search for non-existent warranty
|
||||
searchFor(text: "NONEXISTENT_WARRANTY_99999")
|
||||
|
||||
sleep(2)
|
||||
|
||||
let emptyMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'No warranties' OR label CONTAINS[c] 'No results' OR label CONTAINS[c] 'empty'")).firstMatch
|
||||
let hasNoItems = app.cells.count == 0
|
||||
XCTAssertTrue(emptyMessage.exists || hasNoItems, "Should handle empty warranties list gracefully")
|
||||
|
||||
clearSearch()
|
||||
}
|
||||
|
||||
func test22_CreateDocumentWithLongTitle() {
|
||||
navigateToDocumentsTab()
|
||||
switchToDocumentsTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
|
||||
let longTitle = "This is a very long document title that exceeds normal length expectations to test how the UI handles lengthy text input " + UUID().uuidString
|
||||
createdDocumentTitles.append(longTitle)
|
||||
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: longTitle)
|
||||
selectDocumentType(type: "Insurance")
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should handle long title")
|
||||
|
||||
sleep(2)
|
||||
// Just verify it was created (partial match)
|
||||
let partialTitle = String(longTitle.prefix(30))
|
||||
let documentExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] '\(partialTitle)'")).firstMatch.exists
|
||||
XCTAssertTrue(documentExists, "Document with long title should be created")
|
||||
}
|
||||
|
||||
func test23_CreateWarrantyWithSpecialCharacters() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
XCTAssertTrue(openDocumentForm(), "Should open form")
|
||||
|
||||
let specialTitle = "Warranty w/ Special #Chars: @ & $ % \(UUID().uuidString.prefix(8))"
|
||||
createdDocumentTitles.append(specialTitle)
|
||||
|
||||
selectProperty() // REQUIRED - Select property first
|
||||
fillTextField(placeholder: "Title", text: specialTitle)
|
||||
selectCategory(category: "Other")
|
||||
fillTextField(placeholder: "Item Name", text: "Test @#$ Item") // REQUIRED
|
||||
fillTextField(placeholder: "Provider", text: "Special & Co.") // REQUIRED
|
||||
|
||||
XCTAssertTrue(submitForm(), "Should handle special characters")
|
||||
|
||||
sleep(2)
|
||||
let partialTitle = String(specialTitle.prefix(20))
|
||||
let warrantyExists = app.staticTexts.containing(NSPredicate(format: "label CONTAINS '\(partialTitle)'")).firstMatch.exists
|
||||
XCTAssertTrue(warrantyExists, "Warranty with special characters should be created")
|
||||
}
|
||||
|
||||
func test24_RapidTabSwitching() {
|
||||
navigateToDocumentsTab()
|
||||
|
||||
// Rapidly switch between tabs
|
||||
for _ in 0..<5 {
|
||||
switchToWarrantiesTab()
|
||||
usleep(500000) // 0.5 seconds
|
||||
switchToDocumentsTab()
|
||||
usleep(500000) // 0.5 seconds
|
||||
}
|
||||
|
||||
// Should remain stable
|
||||
let warrantiesTab = app.buttons["Warranties"]
|
||||
let documentsTab = app.buttons["Documents"]
|
||||
XCTAssertTrue(warrantiesTab.exists && documentsTab.exists, "Should handle rapid tab switching without crashing")
|
||||
}
|
||||
|
||||
func test25_MultipleFiltersCombined() {
|
||||
navigateToDocumentsTab()
|
||||
switchToWarrantiesTab()
|
||||
|
||||
// Apply multiple filters
|
||||
toggleActiveFilter() // Turn off active filter
|
||||
sleep(1)
|
||||
applyFilter(filterName: "Appliances")
|
||||
sleep(1)
|
||||
searchFor(text: "Test")
|
||||
|
||||
sleep(2)
|
||||
|
||||
// Should apply all filters without crashing
|
||||
let searchField = app.searchFields.firstMatch
|
||||
XCTAssertTrue(searchField.exists, "Should handle multiple filters simultaneously")
|
||||
|
||||
// Clean up
|
||||
clearSearch()
|
||||
sleep(1)
|
||||
applyFilter(filterName: "All Categories")
|
||||
sleep(1)
|
||||
toggleActiveFilter() // Turn active filter back on
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - XCUIElement Extension for Clearing Text
|
||||
|
||||
extension XCUIElement {
|
||||
func clearText() {
|
||||
guard let stringValue = self.value as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
self.tap()
|
||||
|
||||
let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
|
||||
self.typeText(deleteString)
|
||||
}
|
||||
}
|
||||
525
iosApp/HoneyDueUITests/Suite9_IntegrationE2ETests.swift
Normal file
525
iosApp/HoneyDueUITests/Suite9_IntegrationE2ETests.swift
Normal file
@@ -0,0 +1,525 @@
|
||||
import XCTest
|
||||
|
||||
/// Comprehensive End-to-End Integration Tests
|
||||
/// Mirrors the backend integration tests in honeyDueAPI-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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import XCTest
|
||||
|
||||
/// Centralized app launch configuration for UI tests.
|
||||
///
|
||||
/// Provides consistent launch arguments and environment variables across
|
||||
/// all test suites. Disables animations and sets locale to English for
|
||||
/// deterministic test behavior.
|
||||
enum TestLaunchConfig {
|
||||
|
||||
/// Standard launch arguments for UI test mode.
|
||||
/// Disables animations and forces English locale.
|
||||
static let standardArguments: [String] = [
|
||||
"-UITEST_MODE", "1",
|
||||
"-AppleLanguages", "(en)",
|
||||
"-AppleLocale", "en_US",
|
||||
"-UIAnimationsEnabled", "NO"
|
||||
]
|
||||
|
||||
/// Launch environment variables for UI tests.
|
||||
static let standardEnvironment: [String: String] = [
|
||||
"UITEST_MODE": "1",
|
||||
"ANIMATIONS_DISABLED": "1"
|
||||
]
|
||||
|
||||
/// Configure and launch app with standard test settings.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - additionalArguments: Extra launch arguments to append.
|
||||
/// - additionalEnvironment: Extra environment variables to merge.
|
||||
/// - Returns: The launched `XCUIApplication` instance.
|
||||
@discardableResult
|
||||
static func launchApp(
|
||||
additionalArguments: [String] = [],
|
||||
additionalEnvironment: [String: String] = [:]
|
||||
) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = standardArguments + additionalArguments
|
||||
var env = standardEnvironment
|
||||
additionalEnvironment.forEach { env[$0.key] = $0.value }
|
||||
app.launchEnvironment = env
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
/// Launch app pre-authenticated (skips login flow).
|
||||
///
|
||||
/// Passes test credentials via launch arguments and environment so the
|
||||
/// app can bypass the normal authentication flow during UI tests.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - email: Test user email address.
|
||||
/// - token: Test authentication token.
|
||||
/// - Returns: The launched `XCUIApplication` instance.
|
||||
@discardableResult
|
||||
static func launchAuthenticated(
|
||||
email: String = "test@example.com",
|
||||
token: String = "test-token-12345"
|
||||
) -> XCUIApplication {
|
||||
return launchApp(
|
||||
additionalArguments: ["-TEST_AUTH_EMAIL", email, "-TEST_AUTH_TOKEN", token],
|
||||
additionalEnvironment: ["TEST_AUTH_EMAIL": email, "TEST_AUTH_TOKEN": token]
|
||||
)
|
||||
}
|
||||
}
|
||||
83
iosApp/HoneyDueUITests/Tests/AccessibilityTests.swift
Normal file
83
iosApp/HoneyDueUITests/Tests/AccessibilityTests.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import XCTest
|
||||
|
||||
final class AccessibilityTests: BaseUITestCase {
|
||||
func testA001_OnboardingPrimaryControlsAreReachable() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
|
||||
app.buttons[UITestID.Onboarding.startFreshButton].waitUntilHittable(timeout: defaultTimeout)
|
||||
app.buttons[UITestID.Onboarding.joinExistingButton].waitUntilHittable(timeout: defaultTimeout)
|
||||
app.buttons[UITestID.Onboarding.loginButton].waitUntilHittable(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testA002_LoginControlsRemainOperable() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
|
||||
app.textFields[UITestID.Auth.usernameField].waitUntilHittable(timeout: defaultTimeout)
|
||||
app.secureTextFields[UITestID.Auth.passwordField].waitUntilHittable(timeout: defaultTimeout)
|
||||
app.buttons[UITestID.Auth.loginButton].waitUntilHittable(timeout: defaultTimeout)
|
||||
|
||||
login.tapPasswordVisibilityToggle()
|
||||
login.assertPasswordFieldVisible()
|
||||
}
|
||||
|
||||
func testA003_CoreControlsExposeIdentifiers() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
_ = login
|
||||
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists)
|
||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists)
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists)
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists)
|
||||
}
|
||||
|
||||
// MARK: - Additional Accessibility Coverage
|
||||
|
||||
func testA004_ValuePropsScreenControlsAreReachable() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let continueButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsNextButton).firstMatch
|
||||
continueButton.waitUntilHittable(timeout: defaultTimeout)
|
||||
|
||||
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
|
||||
XCTAssertTrue(backButton.waitForExistence(timeout: defaultTimeout), "Back button should exist on value props screen")
|
||||
}
|
||||
|
||||
func testA005_NameResidenceScreenControlsAreReachable() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad()
|
||||
valueProps.tapContinue()
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let nameField = app.textFields[UITestID.Onboarding.residenceNameField]
|
||||
nameField.waitUntilHittable(timeout: defaultTimeout)
|
||||
|
||||
let continueButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceContinueButton).firstMatch
|
||||
XCTAssertTrue(continueButton.waitForExistence(timeout: defaultTimeout), "Continue button should exist on name residence screen")
|
||||
|
||||
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
|
||||
XCTAssertTrue(backButton.waitForExistence(timeout: defaultTimeout), "Back button should exist on name residence screen")
|
||||
}
|
||||
|
||||
func testA006_CreateAccountScreenControlsAreReachable() {
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "A11Y Test")
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let createAccountTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.createAccountTitle).firstMatch
|
||||
XCTAssertTrue(createAccountTitle.exists, "Create account title should be accessible")
|
||||
|
||||
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
|
||||
XCTAssertTrue(backButton.waitForExistence(timeout: defaultTimeout), "Back button should exist on create account screen")
|
||||
}
|
||||
}
|
||||
19
iosApp/HoneyDueUITests/Tests/AppLaunchTests.swift
Normal file
19
iosApp/HoneyDueUITests/Tests/AppLaunchTests.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import XCTest
|
||||
|
||||
final class AppLaunchTests: BaseUITestCase {
|
||||
func testF001_ColdLaunchShowsOnboardingWelcome() {
|
||||
RootScreen(app: app).waitForReady(timeout: defaultTimeout)
|
||||
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testF002_ColdLaunchShowsPrimaryOnboardingActions() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
|
||||
XCTAssertTrue(app.buttons[UITestID.Onboarding.startFreshButton].exists)
|
||||
XCTAssertTrue(app.buttons[UITestID.Onboarding.joinExistingButton].exists)
|
||||
XCTAssertTrue(app.buttons[UITestID.Onboarding.loginButton].exists)
|
||||
}
|
||||
}
|
||||
133
iosApp/HoneyDueUITests/Tests/AuthenticationTests.swift
Normal file
133
iosApp/HoneyDueUITests/Tests/AuthenticationTests.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import XCTest
|
||||
|
||||
final class AuthenticationTests: BaseUITestCase {
|
||||
func testF201_OnboardingLoginEntryShowsLoginScreen() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testF202_LoginScreenCanTogglePasswordVisibility() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.enterUsername("u")
|
||||
login.enterPassword("p")
|
||||
login.tapPasswordVisibilityToggle()
|
||||
login.assertPasswordFieldVisible()
|
||||
}
|
||||
|
||||
func testF203_RegisterSheetCanOpenAndDismiss() {
|
||||
let register = TestFlows.openRegisterFromLogin(app: app)
|
||||
register.tapCancel()
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testF204_RegisterFormAcceptsInput() {
|
||||
let register = TestFlows.openRegisterFromLogin(app: app)
|
||||
register.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists)
|
||||
}
|
||||
|
||||
func testF205_LoginButtonDisabledWhenCredentialsAreEmpty() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
XCTAssertFalse(loginButton.isEnabled, "Login button should be disabled when username/password are empty")
|
||||
}
|
||||
|
||||
// MARK: - Additional Authentication Coverage
|
||||
|
||||
func testF206_ForgotPasswordButtonIsAccessible() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let forgotButton = app.buttons[UITestID.Auth.forgotPasswordButton]
|
||||
forgotButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
XCTAssertTrue(forgotButton.isHittable, "Forgot password button should be accessible")
|
||||
}
|
||||
|
||||
func testF207_LoginScreenShowsAllExpectedElements() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Username field should exist")
|
||||
XCTAssertTrue(
|
||||
app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists,
|
||||
"Password field should exist"
|
||||
)
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.loginButton].exists, "Login button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists, "Sign up button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists, "Forgot password button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.passwordVisibilityToggle].exists, "Password visibility toggle should exist")
|
||||
}
|
||||
|
||||
func testF208_RegisterFormShowsAllRequiredFields() {
|
||||
let register = TestFlows.openRegisterFromLogin(app: app)
|
||||
register.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists, "Register username field should exist")
|
||||
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists, "Register email field should exist")
|
||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists, "Register password field should exist")
|
||||
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists, "Register confirm password field should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist")
|
||||
XCTAssertTrue(app.buttons[UITestID.Auth.registerCancelButton].exists, "Register cancel button should exist")
|
||||
}
|
||||
|
||||
func testF209_ForgotPasswordNavigatesToResetFlow() {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Verify that tapping forgot password transitions away from login
|
||||
// The forgot password screen should appear (either sheet or navigation)
|
||||
let forgotPasswordAppeared = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Forgot' OR label CONTAINS[c] 'Reset' OR label CONTAINS[c] 'Password'")
|
||||
).firstMatch.waitForExistence(timeout: defaultTimeout)
|
||||
|
||||
XCTAssertTrue(forgotPasswordAppeared, "Forgot password flow should appear after tapping button")
|
||||
}
|
||||
|
||||
// MARK: - AUTH-005: Invalid token at startup clears session and returns to login
|
||||
|
||||
func test08_invalidatedTokenRedirectsToLogin() throws {
|
||||
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
||||
|
||||
// Create a verified account via API
|
||||
guard let session = TestAccountManager.createVerifiedAccount() else {
|
||||
XCTFail("Could not create verified test account")
|
||||
return
|
||||
}
|
||||
|
||||
// Login via UI
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
TestFlows.loginWithCredentials(app: app, username: session.username, password: session.password)
|
||||
|
||||
// Wait until the main tab bar is visible, confirming successful login
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
XCTAssertTrue(
|
||||
mainTabs.waitForExistence(timeout: longTimeout),
|
||||
"Expected main tabs after login"
|
||||
)
|
||||
|
||||
// Invalidate the token via the logout API (simulates a server-side token revocation)
|
||||
TestAccountManager.invalidateToken(session)
|
||||
|
||||
// Force restart the app — terminate and relaunch without --reset-state so the
|
||||
// app restores its persisted session, which should then be rejected by the server.
|
||||
app.terminate()
|
||||
app.launchArguments = ["--ui-testing", "--disable-animations"]
|
||||
app.launch()
|
||||
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
|
||||
// The app should detect the invalid token and redirect to the login screen
|
||||
let usernameField = app.textFields[UITestID.Auth.usernameField]
|
||||
XCTAssertTrue(
|
||||
usernameField.waitForExistence(timeout: longTimeout),
|
||||
"Expected login screen after startup with an invalidated token"
|
||||
)
|
||||
}
|
||||
}
|
||||
215
iosApp/HoneyDueUITests/Tests/ContractorIntegrationTests.swift
Normal file
215
iosApp/HoneyDueUITests/Tests/ContractorIntegrationTests.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
import XCTest
|
||||
|
||||
/// Integration tests for contractor CRUD against the real local backend.
|
||||
///
|
||||
/// Test Plan IDs: CON-002, CON-005, CON-006
|
||||
/// Data is seeded via API and cleaned up in tearDown.
|
||||
final class ContractorIntegrationTests: AuthenticatedTestCase {
|
||||
|
||||
override var useSeededAccount: Bool { true }
|
||||
|
||||
// MARK: - CON-002: Create Contractor
|
||||
|
||||
func testCON002_CreateContractorMinimalFields() {
|
||||
navigateToContractors()
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
|
||||
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
|
||||
|
||||
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
||||
|| emptyState.waitForExistence(timeout: 3)
|
||||
|| contractorList.waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(loaded, "Contractors screen should load")
|
||||
|
||||
if addButton.exists && addButton.isHittable {
|
||||
addButton.forceTap()
|
||||
} else {
|
||||
let emptyAddButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||
).firstMatch
|
||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
||||
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
let uniqueName = "IntTest Contractor \(Int(Date().timeIntervalSince1970))"
|
||||
nameField.forceTap()
|
||||
nameField.typeText(uniqueName)
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
|
||||
let newContractor = app.staticTexts[uniqueName]
|
||||
XCTAssertTrue(
|
||||
newContractor.waitForExistence(timeout: longTimeout),
|
||||
"Newly created contractor should appear in list"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CON-005: Edit Contractor
|
||||
|
||||
func testCON005_EditContractor() {
|
||||
// Seed a contractor via API
|
||||
let contractor = cleaner.seedContractor(name: "Edit Target Contractor \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToContractors()
|
||||
|
||||
// Find and tap the seeded contractor
|
||||
let card = app.staticTexts[contractor.name]
|
||||
card.waitForExistenceOrFail(timeout: longTimeout)
|
||||
card.forceTap()
|
||||
|
||||
// Tap edit
|
||||
let editButton = app.buttons[AccessibilityIdentifiers.Contractor.editButton]
|
||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
editButton.forceTap()
|
||||
|
||||
// Update name
|
||||
let nameField = app.textFields[AccessibilityIdentifiers.Contractor.nameField]
|
||||
nameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
nameField.forceTap()
|
||||
nameField.press(forDuration: 1.0)
|
||||
let selectAll = app.menuItems["Select All"]
|
||||
if selectAll.waitForExistence(timeout: 2) {
|
||||
selectAll.tap()
|
||||
}
|
||||
|
||||
let updatedName = "Updated Contractor \(Int(Date().timeIntervalSince1970))"
|
||||
nameField.typeText(updatedName)
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Contractor.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
|
||||
let updatedText = app.staticTexts[updatedName]
|
||||
XCTAssertTrue(
|
||||
updatedText.waitForExistence(timeout: longTimeout),
|
||||
"Updated contractor name should appear after edit"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CON-007: Favorite Toggle
|
||||
|
||||
func test20_toggleContractorFavorite() {
|
||||
// Seed a contractor via API and track it for cleanup
|
||||
let contractor = cleaner.seedContractor(name: "Favorite Toggle Contractor \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToContractors()
|
||||
|
||||
// Find and open the seeded contractor
|
||||
let card = app.staticTexts[contractor.name]
|
||||
card.waitForExistenceOrFail(timeout: longTimeout)
|
||||
card.forceTap()
|
||||
|
||||
// Look for a favorite / star button in the detail view.
|
||||
// The button may be labelled "Favorite", carry a star SF symbol, or use a toggle.
|
||||
let favoriteButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Favorite' OR label CONTAINS[c] 'Star' OR label CONTAINS[c] 'favourite'")
|
||||
).firstMatch
|
||||
|
||||
guard favoriteButton.waitForExistence(timeout: defaultTimeout) else {
|
||||
XCTFail("Favorite/star button not found on contractor detail view")
|
||||
return
|
||||
}
|
||||
|
||||
// Capture initial accessibility value / label to detect change
|
||||
let initialLabel = favoriteButton.label
|
||||
|
||||
// First toggle — mark as favourite
|
||||
favoriteButton.forceTap()
|
||||
|
||||
// Brief pause so the UI can settle after the API call
|
||||
_ = app.staticTexts.firstMatch.waitForExistence(timeout: 2)
|
||||
|
||||
// The button's label or selected state should have changed
|
||||
let afterFirstToggleLabel = favoriteButton.label
|
||||
XCTAssertNotEqual(
|
||||
initialLabel, afterFirstToggleLabel,
|
||||
"Favorite button appearance should change after first toggle"
|
||||
)
|
||||
|
||||
// Second toggle — un-mark as favourite, state should return to original
|
||||
favoriteButton.forceTap()
|
||||
|
||||
_ = app.staticTexts.firstMatch.waitForExistence(timeout: 2)
|
||||
|
||||
let afterSecondToggleLabel = favoriteButton.label
|
||||
XCTAssertEqual(
|
||||
initialLabel, afterSecondToggleLabel,
|
||||
"Favorite button appearance should return to original after second toggle"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CON-008: Contractor by Residence Filter
|
||||
|
||||
func test21_contractorByResidenceFilter() throws {
|
||||
// Seed a residence and a contractor linked to it
|
||||
let residence = cleaner.seedResidence(name: "Filter Test Residence \(Int(Date().timeIntervalSince1970))")
|
||||
let contractor = cleaner.seedContractor(
|
||||
name: "Residence Contractor \(Int(Date().timeIntervalSince1970))",
|
||||
fields: ["residence_id": residence.id]
|
||||
)
|
||||
|
||||
navigateToResidences()
|
||||
|
||||
// Open the seeded residence's detail view
|
||||
let residenceText = app.staticTexts[residence.name]
|
||||
residenceText.waitForExistenceOrFail(timeout: longTimeout)
|
||||
residenceText.forceTap()
|
||||
|
||||
// Look for a Contractors section within the residence detail.
|
||||
// The section header text or accessibility element is checked first.
|
||||
let contractorsSectionHeader = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Contractor'")
|
||||
).firstMatch
|
||||
|
||||
guard contractorsSectionHeader.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("Residence detail does not expose a Contractors section — skipping filter test")
|
||||
}
|
||||
|
||||
// Verify the seeded contractor appears in the residence's contractor list
|
||||
let contractorEntry = app.staticTexts[contractor.name]
|
||||
XCTAssertTrue(
|
||||
contractorEntry.waitForExistence(timeout: defaultTimeout),
|
||||
"Contractor '\(contractor.name)' should appear in the contractors section of residence '\(residence.name)'"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CON-006: Delete Contractor
|
||||
|
||||
func testCON006_DeleteContractor() {
|
||||
// Seed a contractor via API — don't track since we'll delete through UI
|
||||
let deleteName = "Delete Contractor \(Int(Date().timeIntervalSince1970))"
|
||||
TestDataSeeder.createContractor(token: session.token, name: deleteName)
|
||||
|
||||
navigateToContractors()
|
||||
|
||||
let target = app.staticTexts[deleteName]
|
||||
target.waitForExistenceOrFail(timeout: longTimeout)
|
||||
target.forceTap()
|
||||
|
||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Contractor.deleteButton]
|
||||
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
deleteButton.forceTap()
|
||||
|
||||
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
||||
let alertDelete = app.alerts.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
||||
).firstMatch
|
||||
|
||||
if confirmButton.waitForExistence(timeout: shortTimeout) {
|
||||
confirmButton.tap()
|
||||
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
|
||||
alertDelete.tap()
|
||||
}
|
||||
|
||||
let deletedContractor = app.staticTexts[deleteName]
|
||||
XCTAssertTrue(
|
||||
deletedContractor.waitForNonExistence(timeout: longTimeout),
|
||||
"Deleted contractor should no longer appear"
|
||||
)
|
||||
}
|
||||
}
|
||||
894
iosApp/HoneyDueUITests/Tests/DataLayerTests.swift
Normal file
894
iosApp/HoneyDueUITests/Tests/DataLayerTests.swift
Normal file
@@ -0,0 +1,894 @@
|
||||
import XCTest
|
||||
|
||||
/// Integration tests for the data layer covering caching, ETag, logout cleanup, persistence, and lookup consistency.
|
||||
///
|
||||
/// Test Plan IDs: DATA-001 through DATA-007.
|
||||
/// All tests run against the real local backend via `AuthenticatedTestCase`.
|
||||
final class DataLayerTests: AuthenticatedTestCase {
|
||||
|
||||
override var useSeededAccount: Bool { true }
|
||||
|
||||
/// Don't reset state by default — individual tests override when needed.
|
||||
override var includeResetStateLaunchArgument: Bool { false }
|
||||
|
||||
// MARK: - DATA-001: Lookups Initialize After Login
|
||||
|
||||
func testDATA001_LookupsInitializeAfterLogin() {
|
||||
// After AuthenticatedTestCase.setUp, the app is logged in and on main tabs.
|
||||
// Navigate to tasks and open the create form to verify pickers are populated.
|
||||
navigateToTasks()
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
|
||||
guard addButton.waitForExistence(timeout: defaultTimeout) else {
|
||||
XCTFail("Tasks add button not found after login")
|
||||
return
|
||||
}
|
||||
addButton.forceTap()
|
||||
|
||||
// Verify that the category picker exists and is populated
|
||||
let categoryPicker = app.buttons[AccessibilityIdentifiers.Task.categoryPicker]
|
||||
.exists ? app.buttons[AccessibilityIdentifiers.Task.categoryPicker]
|
||||
: app.otherElements[AccessibilityIdentifiers.Task.categoryPicker]
|
||||
|
||||
XCTAssertTrue(
|
||||
categoryPicker.waitForExistence(timeout: defaultTimeout),
|
||||
"Category picker should exist in task form, indicating lookups loaded"
|
||||
)
|
||||
|
||||
// Verify priority picker exists
|
||||
let priorityPicker = app.buttons[AccessibilityIdentifiers.Task.priorityPicker]
|
||||
.exists ? app.buttons[AccessibilityIdentifiers.Task.priorityPicker]
|
||||
: app.otherElements[AccessibilityIdentifiers.Task.priorityPicker]
|
||||
|
||||
XCTAssertTrue(
|
||||
priorityPicker.waitForExistence(timeout: defaultTimeout),
|
||||
"Priority picker should exist in task form, indicating lookups loaded"
|
||||
)
|
||||
|
||||
// Verify residence picker exists (needs at least one residence)
|
||||
let residencePicker = app.buttons[AccessibilityIdentifiers.Task.residencePicker]
|
||||
.exists ? app.buttons[AccessibilityIdentifiers.Task.residencePicker]
|
||||
: app.otherElements[AccessibilityIdentifiers.Task.residencePicker]
|
||||
|
||||
XCTAssertTrue(
|
||||
residencePicker.waitForExistence(timeout: defaultTimeout),
|
||||
"Residence picker should exist in task form, indicating residences loaded"
|
||||
)
|
||||
|
||||
// Verify frequency picker exists — proves all lookup types loaded
|
||||
let frequencyPicker = app.buttons[AccessibilityIdentifiers.Task.frequencyPicker]
|
||||
.exists ? app.buttons[AccessibilityIdentifiers.Task.frequencyPicker]
|
||||
: app.otherElements[AccessibilityIdentifiers.Task.frequencyPicker]
|
||||
|
||||
XCTAssertTrue(
|
||||
frequencyPicker.waitForExistence(timeout: defaultTimeout),
|
||||
"Frequency picker should exist in task form, indicating lookups loaded"
|
||||
)
|
||||
|
||||
// Tap category picker to verify it has options (not empty)
|
||||
if categoryPicker.isHittable {
|
||||
categoryPicker.forceTap()
|
||||
|
||||
// Look for picker options - any text that's NOT the placeholder
|
||||
let pickerOptions = app.staticTexts.allElementsBoundByIndex
|
||||
let hasOptions = pickerOptions.contains { element in
|
||||
element.exists && !element.label.isEmpty
|
||||
}
|
||||
XCTAssertTrue(hasOptions, "Category picker should have options after lookups initialize")
|
||||
|
||||
// Dismiss picker if needed
|
||||
let doneButton = app.buttons["Done"]
|
||||
if doneButton.exists && doneButton.isHittable {
|
||||
doneButton.tap()
|
||||
} else {
|
||||
// Tap outside to dismiss
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||
}
|
||||
}
|
||||
|
||||
cancelTaskForm()
|
||||
}
|
||||
|
||||
// MARK: - DATA-002: ETag Refresh Handles 304
|
||||
|
||||
func testDATA002_ETagRefreshHandles304() {
|
||||
// Verify that a second visit to a lookup-dependent form still shows data.
|
||||
// If ETag / 304 handling were broken, the second load would show empty pickers.
|
||||
|
||||
// First: verify lookups are loaded via the static_data endpoint
|
||||
// The API returns an ETag header, and the app stores it for conditional requests.
|
||||
verifyStaticDataEndpointSupportsETag()
|
||||
|
||||
// Open task form → verify pickers populated → close
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
cancelTaskForm()
|
||||
|
||||
// Navigate away and back — triggers a cache check.
|
||||
// The app will send If-None-Match with the stored ETag.
|
||||
// Backend returns 304, app keeps cached lookups.
|
||||
navigateToResidences()
|
||||
sleep(1)
|
||||
navigateToTasks()
|
||||
|
||||
// Open form again and verify pickers still populated (304 path worked)
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
cancelTaskForm()
|
||||
}
|
||||
|
||||
// MARK: - DATA-003: Legacy Fallback When Seeded Endpoint Unavailable
|
||||
|
||||
func testDATA003_LegacyFallbackStillLoadsCoreLookups() throws {
|
||||
// The app uses /api/static_data/ as the primary seeded endpoint.
|
||||
// If it fails, there's a fallback that still loads core lookup types.
|
||||
// We can't break the endpoint in a UI test, but we CAN verify the
|
||||
// core lookups are available from BOTH the primary and fallback endpoints.
|
||||
|
||||
// Verify the primary endpoint is reachable
|
||||
let primaryResult = TestAccountAPIClient.rawRequest(method: "GET", path: "/static_data/")
|
||||
XCTAssertTrue(
|
||||
primaryResult.succeeded,
|
||||
"Primary static_data endpoint should be reachable (status \(primaryResult.statusCode))"
|
||||
)
|
||||
|
||||
// Verify the response contains all required lookup types
|
||||
guard let data = primaryResult.data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
XCTFail("Could not parse static_data response")
|
||||
return
|
||||
}
|
||||
|
||||
let requiredKeys = ["residence_types", "task_categories", "task_priorities", "task_frequencies", "contractor_specialties"]
|
||||
for key in requiredKeys {
|
||||
guard let array = json[key] as? [[String: Any]], !array.isEmpty else {
|
||||
XCTFail("static_data response missing or empty '\(key)'")
|
||||
continue
|
||||
}
|
||||
// Verify each item has an 'id' and 'name' for map building
|
||||
let firstItem = array[0]
|
||||
XCTAssertNotNil(firstItem["id"], "\(key) items should have 'id' for associateBy")
|
||||
XCTAssertNotNil(firstItem["name"], "\(key) items should have 'name' for display")
|
||||
}
|
||||
|
||||
// Verify lookups are populated in the app UI (proves the app loaded them)
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
|
||||
// Also verify contractor specialty picker in contractor form
|
||||
cancelTaskForm()
|
||||
navigateToContractors()
|
||||
|
||||
let contractorAddButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton]
|
||||
let contractorEmptyState = app.otherElements[AccessibilityIdentifiers.Contractor.emptyStateView]
|
||||
let contractorList = app.otherElements[AccessibilityIdentifiers.Contractor.contractorsList]
|
||||
|
||||
let contractorLoaded = contractorAddButton.waitForExistence(timeout: defaultTimeout)
|
||||
|| contractorEmptyState.waitForExistence(timeout: 3)
|
||||
|| contractorList.waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(contractorLoaded, "Contractors screen should load")
|
||||
|
||||
if contractorAddButton.exists && contractorAddButton.isHittable {
|
||||
contractorAddButton.forceTap()
|
||||
} else {
|
||||
let emptyAddButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||
).firstMatch
|
||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
let specialtyPicker = app.buttons[AccessibilityIdentifiers.Contractor.specialtyPicker]
|
||||
.exists ? app.buttons[AccessibilityIdentifiers.Contractor.specialtyPicker]
|
||||
: app.otherElements[AccessibilityIdentifiers.Contractor.specialtyPicker]
|
||||
|
||||
XCTAssertTrue(
|
||||
specialtyPicker.waitForExistence(timeout: defaultTimeout),
|
||||
"Contractor specialty picker should exist, proving contractor_specialties loaded"
|
||||
)
|
||||
|
||||
let contractorCancelButton = app.buttons[AccessibilityIdentifiers.Contractor.formCancelButton]
|
||||
if contractorCancelButton.exists && contractorCancelButton.isHittable {
|
||||
contractorCancelButton.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DATA-004: Cache Timeout and Force Refresh
|
||||
|
||||
func testDATA004_CacheTimeoutAndForceRefresh() {
|
||||
// Seed data via API so we have something to verify in the cache
|
||||
let residence = cleaner.seedResidence(name: "Cache Test \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
// Navigate to residences — data should appear from cache or initial load
|
||||
navigateToResidences()
|
||||
|
||||
let residenceText = app.staticTexts[residence.name]
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: longTimeout),
|
||||
"Seeded residence should appear in list (initial cache load)"
|
||||
)
|
||||
|
||||
// Navigate away and back — cached data should still be available immediately
|
||||
navigateToTasks()
|
||||
sleep(1)
|
||||
navigateToResidences()
|
||||
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: defaultTimeout),
|
||||
"Seeded residence should still appear after tab switch (data served from cache)"
|
||||
)
|
||||
|
||||
// Seed a second residence via API while we're on the residences tab
|
||||
let residence2 = cleaner.seedResidence(name: "Cache Test 2 \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
// Without refresh, the new residence may not appear (stale cache)
|
||||
// Pull-to-refresh should force a fresh fetch
|
||||
let scrollView = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
|
||||
let listElement = scrollView.exists ? scrollView : app.otherElements[AccessibilityIdentifiers.Residence.residencesList]
|
||||
|
||||
// Perform pull-to-refresh gesture
|
||||
let start = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
|
||||
let finish = listElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
||||
start.press(forDuration: 0.1, thenDragTo: finish)
|
||||
|
||||
let residence2Text = app.staticTexts[residence2.name]
|
||||
XCTAssertTrue(
|
||||
residence2Text.waitForExistence(timeout: longTimeout),
|
||||
"Second residence should appear after pull-to-refresh (forced fresh fetch)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - DATA-005: Cache Invalidation on Logout
|
||||
|
||||
func testDATA005_LogoutClearsUserDataButRetainsTheme() {
|
||||
// Seed data so there's something to clear
|
||||
let residence = cleaner.seedResidence(name: "Logout Test \(Int(Date().timeIntervalSince1970))")
|
||||
let _ = cleaner.seedTask(residenceId: residence.id, title: "Logout Task \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
// Verify data is visible
|
||||
navigateToResidences()
|
||||
let residenceText = app.staticTexts[residence.name]
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: longTimeout),
|
||||
"Seeded data should be visible before logout"
|
||||
)
|
||||
|
||||
// Perform logout via UI
|
||||
performLogout()
|
||||
|
||||
// Verify we're on login screen (user data cleared, session invalidated)
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
XCTAssertTrue(
|
||||
usernameField.waitForExistence(timeout: longTimeout),
|
||||
"Should be on login screen after logout"
|
||||
)
|
||||
|
||||
// Verify main tabs are NOT accessible (data cleared)
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
XCTAssertFalse(mainTabs.exists, "Main app should not be accessible after logout")
|
||||
|
||||
// Re-login with the same seeded account
|
||||
loginViaUI()
|
||||
|
||||
// After re-login, the seeded residence should still exist on backend
|
||||
// but this proves the app fetched fresh data, not stale cache
|
||||
navigateToResidences()
|
||||
|
||||
// The seeded residence from this test should appear (it's on the backend)
|
||||
XCTAssertTrue(
|
||||
residenceText.waitForExistence(timeout: longTimeout),
|
||||
"Data should reload after re-login (fresh fetch, not stale cache)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - DATA-006: Disk Persistence After App Restart
|
||||
|
||||
func testDATA006_LookupsPersistAfterAppRestart() {
|
||||
// Verify lookups are loaded
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
cancelTaskForm()
|
||||
|
||||
// Terminate and relaunch the app
|
||||
app.terminate()
|
||||
|
||||
// Relaunch WITHOUT --reset-state so persisted data survives
|
||||
app.launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations"
|
||||
]
|
||||
app.launch()
|
||||
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
|
||||
// The app may need re-login (token persisted) or go to onboarding.
|
||||
// If we land on main tabs, lookups should be available from disk.
|
||||
// If we land on login, log in and then check.
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
while Date() < deadline {
|
||||
if mainTabs.exists || tabBar.exists {
|
||||
break
|
||||
}
|
||||
if usernameField.exists {
|
||||
// Need to re-login
|
||||
loginViaUI()
|
||||
break
|
||||
}
|
||||
if onboardingRoot.exists {
|
||||
// Navigate to login from onboarding
|
||||
let loginButton = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||||
if loginButton.waitForExistence(timeout: 5) {
|
||||
loginButton.forceTap()
|
||||
}
|
||||
if usernameField.waitForExistence(timeout: 10) {
|
||||
loginViaUI()
|
||||
}
|
||||
break
|
||||
}
|
||||
// Handle email verification gate
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
if verificationScreen.codeField.exists {
|
||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verificationScreen.submitCode()
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
// Wait for main app
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|
||||
|| tabBar.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(reachedMain, "Should reach main app after restart")
|
||||
|
||||
// After restart + potential re-login, lookups should be available
|
||||
// (either from disk persistence or fresh fetch after login)
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
cancelTaskForm()
|
||||
}
|
||||
|
||||
// MARK: - DATA-007: Lookup Map/List Consistency
|
||||
|
||||
func testDATA007_LookupMapListConsistency() throws {
|
||||
// Verify that lookup data from the API has consistent IDs across all types
|
||||
// and that these IDs match what the app displays in pickers.
|
||||
|
||||
// Fetch the raw static_data from the backend
|
||||
let result = TestAccountAPIClient.rawRequest(method: "GET", path: "/static_data/")
|
||||
XCTAssertTrue(result.succeeded, "static_data endpoint should return 200")
|
||||
|
||||
guard let data = result.data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
XCTFail("Could not parse static_data response")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify each lookup type has unique IDs (no duplicates)
|
||||
let lookupKeys = [
|
||||
"residence_types",
|
||||
"task_categories",
|
||||
"task_priorities",
|
||||
"task_frequencies",
|
||||
"contractor_specialties"
|
||||
]
|
||||
|
||||
for key in lookupKeys {
|
||||
guard let items = json[key] as? [[String: Any]] else {
|
||||
XCTFail("Missing '\(key)' in static_data")
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract IDs
|
||||
let ids = items.compactMap { $0["id"] as? Int }
|
||||
XCTAssertEqual(ids.count, items.count, "\(key): every item should have an integer 'id'")
|
||||
|
||||
// Verify unique IDs (would break associateBy)
|
||||
let uniqueIds = Set(ids)
|
||||
XCTAssertEqual(
|
||||
uniqueIds.count, ids.count,
|
||||
"\(key): all IDs should be unique (found \(ids.count - uniqueIds.count) duplicates)"
|
||||
)
|
||||
|
||||
// Verify every item has a non-empty name
|
||||
let names = items.compactMap { $0["name"] as? String }
|
||||
XCTAssertEqual(names.count, items.count, "\(key): every item should have a 'name'")
|
||||
for name in names {
|
||||
XCTAssertFalse(name.isEmpty, "\(key): no item should have an empty name")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the app's pickers reflect the API data by checking task form
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
|
||||
// Count the number of categories from the API
|
||||
let apiCategories = (json["task_categories"] as? [[String: Any]])?.count ?? 0
|
||||
XCTAssertGreaterThan(apiCategories, 0, "API should have task categories")
|
||||
|
||||
// Verify category picker has selectable options
|
||||
let categoryPicker = findPicker(AccessibilityIdentifiers.Task.categoryPicker)
|
||||
if categoryPicker.isHittable {
|
||||
categoryPicker.forceTap()
|
||||
sleep(1)
|
||||
|
||||
// Count visible category options
|
||||
let pickerTexts = app.staticTexts.allElementsBoundByIndex.filter {
|
||||
$0.exists && !$0.label.isEmpty && $0.label != "Category"
|
||||
}
|
||||
XCTAssertGreaterThan(
|
||||
pickerTexts.count, 0,
|
||||
"Category picker should have options matching API data"
|
||||
)
|
||||
|
||||
// Dismiss picker
|
||||
dismissPicker()
|
||||
}
|
||||
|
||||
// Verify priority picker has the expected number of priorities
|
||||
let apiPriorities = (json["task_priorities"] as? [[String: Any]])?.count ?? 0
|
||||
XCTAssertGreaterThan(apiPriorities, 0, "API should have task priorities")
|
||||
|
||||
let priorityPicker = findPicker(AccessibilityIdentifiers.Task.priorityPicker)
|
||||
if priorityPicker.isHittable {
|
||||
priorityPicker.forceTap()
|
||||
sleep(1)
|
||||
|
||||
let priorityTexts = app.staticTexts.allElementsBoundByIndex.filter {
|
||||
$0.exists && !$0.label.isEmpty && $0.label != "Priority"
|
||||
}
|
||||
XCTAssertGreaterThan(
|
||||
priorityTexts.count, 0,
|
||||
"Priority picker should have options matching API data"
|
||||
)
|
||||
|
||||
dismissPicker()
|
||||
}
|
||||
|
||||
cancelTaskForm()
|
||||
}
|
||||
|
||||
// MARK: - DATA-006 (UI): Disk Persistence Preserves Lookups After App Restart
|
||||
|
||||
/// test08: DATA-006 — Lookups and current user reload correctly after a real app restart.
|
||||
///
|
||||
/// Terminates the app and relaunches without `--reset-state` so persisted data
|
||||
/// survives. After re-login the task pickers must still be populated, proving that
|
||||
/// the disk persistence layer successfully seeded the in-memory DataManager.
|
||||
func test08_diskPersistencePreservesLookupsAfterRestart() {
|
||||
// Step 1: Verify lookups are loaded before the restart
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
cancelTaskForm()
|
||||
|
||||
// Step 2: Terminate the app — persisted data should survive on disk
|
||||
app.terminate()
|
||||
|
||||
// Step 3: Relaunch WITHOUT --reset-state so the on-disk cache is preserved
|
||||
app.launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations"
|
||||
// Intentionally omitting --reset-state
|
||||
]
|
||||
app.launch()
|
||||
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
|
||||
// Step 4: Handle whatever landing screen the app shows after restart.
|
||||
// The token may have persisted (main tabs) or expired (login screen).
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
while Date() < deadline {
|
||||
if mainTabs.exists || tabBar.exists {
|
||||
break
|
||||
}
|
||||
if usernameField.exists {
|
||||
loginViaUI()
|
||||
break
|
||||
}
|
||||
if onboardingRoot.exists {
|
||||
let loginBtn = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||||
if loginBtn.waitForExistence(timeout: 5) {
|
||||
loginBtn.forceTap()
|
||||
}
|
||||
if usernameField.waitForExistence(timeout: 10) {
|
||||
loginViaUI()
|
||||
}
|
||||
break
|
||||
}
|
||||
// Handle email verification gate (new accounts only — seeded account is pre-verified)
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
if verificationScreen.codeField.exists {
|
||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verificationScreen.submitCode()
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|
||||
|| tabBar.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(reachedMain, "Should reach main app after restart and potential re-login")
|
||||
|
||||
// Step 5: After restart + potential re-login, lookups must still be available.
|
||||
// If disk persistence works, the DataManager is seeded from disk before the
|
||||
// first login-triggered fetch completes, so pickers appear immediately.
|
||||
navigateToTasks()
|
||||
openTaskForm()
|
||||
assertTaskFormPickersPopulated()
|
||||
cancelTaskForm()
|
||||
}
|
||||
|
||||
// MARK: - THEME-001: Theme Persistence via UI
|
||||
|
||||
/// test09: THEME-001 — Theme choice persists across app restarts.
|
||||
///
|
||||
/// Navigates to the profile tab, checks for theme-related settings, optionally
|
||||
/// selects a non-default theme, then restarts the app and verifies the profile
|
||||
/// screen still loads (confirming the theme setting did not cause a crash and
|
||||
/// persisted state is coherent).
|
||||
func test09_themePersistsAcrossRestart() {
|
||||
// Step 1: Navigate to the profile tab and confirm it loads
|
||||
navigateToProfile()
|
||||
|
||||
let profileView = app.otherElements[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
|
||||
// The profile screen should be accessible via the profile tab
|
||||
let profileLoaded = profileView.waitForExistence(timeout: defaultTimeout)
|
||||
|| app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Profile' OR label CONTAINS[c] 'Account'")
|
||||
).firstMatch.waitForExistence(timeout: defaultTimeout)
|
||||
XCTAssertTrue(profileLoaded, "Profile/settings screen should load after tapping profile tab")
|
||||
|
||||
// Step 2: Look for a theme picker button in the profile/settings UI.
|
||||
// The exact identifier depends on implementation — check for common patterns.
|
||||
let themeButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Theme' OR label CONTAINS[c] 'Appearance' OR label CONTAINS[c] 'Color'")
|
||||
).firstMatch
|
||||
|
||||
var selectedThemeName: String? = nil
|
||||
|
||||
if themeButton.waitForExistence(timeout: shortTimeout) && themeButton.isHittable {
|
||||
themeButton.forceTap()
|
||||
sleep(1)
|
||||
|
||||
// Look for theme options in any picker/sheet that appears
|
||||
// Try to select a theme that is NOT the currently selected one
|
||||
let themeOptions = app.buttons.allElementsBoundByIndex.filter { button in
|
||||
button.exists && button.isHittable &&
|
||||
button.label != "Theme" && button.label != "Appearance" &&
|
||||
!button.label.isEmpty && button.label != "Cancel" && button.label != "Done"
|
||||
}
|
||||
|
||||
if let firstOption = themeOptions.first {
|
||||
selectedThemeName = firstOption.label
|
||||
firstOption.forceTap()
|
||||
sleep(1)
|
||||
}
|
||||
|
||||
// Dismiss the theme picker if still visible
|
||||
let doneButton = app.buttons["Done"]
|
||||
if doneButton.exists && doneButton.isHittable {
|
||||
doneButton.tap()
|
||||
} else {
|
||||
let cancelButton = app.buttons["Cancel"]
|
||||
if cancelButton.exists && cancelButton.isHittable {
|
||||
cancelButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Terminate and relaunch without --reset-state
|
||||
app.terminate()
|
||||
|
||||
app.launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations"
|
||||
// Intentionally omitting --reset-state to preserve theme setting
|
||||
]
|
||||
app.launch()
|
||||
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
|
||||
// Step 4: Re-login if needed
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let onboardingRoot = app.otherElements[UITestID.Root.onboarding]
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
while Date() < deadline {
|
||||
if mainTabs.exists || tabBar.exists { break }
|
||||
if usernameField.exists { loginViaUI(); break }
|
||||
if onboardingRoot.exists {
|
||||
let loginBtn = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
|
||||
if loginBtn.waitForExistence(timeout: 5) { loginBtn.forceTap() }
|
||||
if usernameField.waitForExistence(timeout: 10) { loginViaUI() }
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|
||||
|| tabBar.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(reachedMain, "Should reach main app after restart")
|
||||
|
||||
// Step 5: Navigate to profile again and confirm the screen loads.
|
||||
// If the theme setting is persisted and applied without errors, the app
|
||||
// renders the profile tab correctly.
|
||||
navigateToProfile()
|
||||
|
||||
let profileReloaded = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Profile' OR label CONTAINS[c] 'Account' OR label CONTAINS[c] 'Settings'")
|
||||
).firstMatch.waitForExistence(timeout: defaultTimeout)
|
||||
|| app.otherElements.containing(
|
||||
NSPredicate(format: "identifier CONTAINS[c] 'Profile' OR identifier CONTAINS[c] 'Settings'")
|
||||
).firstMatch.exists
|
||||
|
||||
XCTAssertTrue(
|
||||
profileReloaded,
|
||||
"Profile/settings screen should load after restart with persisted theme — " +
|
||||
"confirming the theme state ('\(selectedThemeName ?? "default")') did not cause a crash"
|
||||
)
|
||||
|
||||
// If we successfully selected a theme, try to verify it's still reflected in the UI
|
||||
if let themeName = selectedThemeName {
|
||||
let themeStillVisible = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] %@", themeName)
|
||||
).firstMatch.exists
|
||||
// Non-fatal: theme picker UI varies; just log the result
|
||||
if themeStillVisible {
|
||||
// Theme label is visible — persistence confirmed at UI level
|
||||
XCTAssertTrue(true, "Theme '\(themeName)' is still visible in settings after restart")
|
||||
}
|
||||
// If not visible, the theme may have been applied silently — the lack of crash is the pass criterion
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TCOMP-004: Completion History
|
||||
|
||||
/// TCOMP-004 — History list loads for a task and is sorted correctly.
|
||||
///
|
||||
/// Seeds a task, marks it complete via API (if the endpoint exists), then opens
|
||||
/// the task detail to look for a completion history section. If the task completion
|
||||
/// endpoint is not available in `TestAccountAPIClient`, the test documents this
|
||||
/// gap and exercises the task detail view at minimum.
|
||||
func test10_completionHistoryLoadsAndIsSorted() throws {
|
||||
// Seed a residence and task via API
|
||||
let residence = cleaner.seedResidence(name: "TCOMP004 Residence \(Int(Date().timeIntervalSince1970))")
|
||||
let task = cleaner.seedTask(residenceId: residence.id, title: "TCOMP004 Task \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
// Attempt to mark the task as complete via the mark-in-progress endpoint first,
|
||||
// then look for a complete action. The completeTask endpoint is not yet in
|
||||
// TestAccountAPIClient — document this and proceed with what is available.
|
||||
//
|
||||
// NOTE: If a POST /tasks/{id}/complete/ endpoint is added to TestAccountAPIClient,
|
||||
// call it here to seed a completion record before opening the task detail.
|
||||
let markedInProgress = TestAccountAPIClient.markTaskInProgress(token: session.token, id: task.id)
|
||||
// Completion via API not yet implemented in TestAccountAPIClient — see TCOMP-004 stub note.
|
||||
|
||||
// Navigate to tasks and open the seeded task
|
||||
navigateToTasks()
|
||||
|
||||
let taskText = app.staticTexts[task.title]
|
||||
guard taskText.waitForExistence(timeout: longTimeout) else {
|
||||
throw XCTSkip("Seeded task '\(task.title)' not visible in current view — may require filter toggle")
|
||||
}
|
||||
taskText.forceTap()
|
||||
|
||||
// Verify the task detail view loaded
|
||||
let detailView = app.otherElements[AccessibilityIdentifiers.Task.detailView]
|
||||
let taskDetailLoaded = detailView.waitForExistence(timeout: defaultTimeout)
|
||||
|| app.staticTexts[task.title].waitForExistence(timeout: defaultTimeout)
|
||||
XCTAssertTrue(taskDetailLoaded, "Task detail view should load after tapping the task")
|
||||
|
||||
// Look for a completion history section.
|
||||
// The identifier pattern mirrors the codebase convention used in AccessibilityIdentifiers.
|
||||
let historySection = app.otherElements.containing(
|
||||
NSPredicate(format: "identifier CONTAINS[c] 'History' OR identifier CONTAINS[c] 'Completion'")
|
||||
).firstMatch
|
||||
|
||||
let historyText = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'History' OR label CONTAINS[c] 'Completed' OR label CONTAINS[c] 'completion'")
|
||||
).firstMatch
|
||||
|
||||
if historySection.waitForExistence(timeout: shortTimeout) || historyText.waitForExistence(timeout: shortTimeout) {
|
||||
// History section is visible — verify at least one entry if the task was completed
|
||||
if markedInProgress != nil {
|
||||
// The task was set in-progress; a full completion record requires the complete endpoint.
|
||||
// Assert the history section is accessible (not empty or crashed).
|
||||
XCTAssertTrue(
|
||||
historySection.exists || historyText.exists,
|
||||
"Completion history section should be present in task detail"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// NOTE: If this assertion fails, the task detail may not yet expose a completion
|
||||
// history section in the UI. The TCOMP-004 test plan item requires:
|
||||
// 1. POST /tasks/{id}/complete/ endpoint in TestAccountAPIClient
|
||||
// 2. A completion history accessibility identifier in AccessibilityIdentifiers.Task
|
||||
// 3. The SwiftUI task detail view to expose that section with an accessibility id
|
||||
// Until all three are implemented, skip rather than fail hard.
|
||||
throw XCTSkip(
|
||||
"TCOMP-004: No completion history section found in task detail. " +
|
||||
"This test requires: (1) TestAccountAPIClient.completeTask() endpoint, " +
|
||||
"(2) AccessibilityIdentifiers.Task.completionHistorySection, and " +
|
||||
"(3) the SwiftUI detail view to expose the history list with that identifier."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Open the task creation form.
|
||||
private func openTaskForm() {
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
|
||||
let taskList = app.otherElements[AccessibilityIdentifiers.Task.tasksList]
|
||||
|
||||
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
||||
|| emptyState.waitForExistence(timeout: 3)
|
||||
|| taskList.waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(loaded, "Tasks screen should load")
|
||||
|
||||
if addButton.exists && addButton.isHittable {
|
||||
addButton.forceTap()
|
||||
} else {
|
||||
let emptyAddButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||
).firstMatch
|
||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
// Wait for form to be ready
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should appear")
|
||||
}
|
||||
|
||||
/// Cancel/dismiss the task form.
|
||||
private func cancelTaskForm() {
|
||||
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton]
|
||||
if cancelButton.exists && cancelButton.isHittable {
|
||||
cancelButton.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert all four core task form pickers are populated.
|
||||
private func assertTaskFormPickersPopulated(file: StaticString = #filePath, line: UInt = #line) {
|
||||
let pickerIds = [
|
||||
("Category", AccessibilityIdentifiers.Task.categoryPicker),
|
||||
("Priority", AccessibilityIdentifiers.Task.priorityPicker),
|
||||
("Frequency", AccessibilityIdentifiers.Task.frequencyPicker),
|
||||
("Residence", AccessibilityIdentifiers.Task.residencePicker)
|
||||
]
|
||||
|
||||
for (name, identifier) in pickerIds {
|
||||
let picker = findPicker(identifier)
|
||||
XCTAssertTrue(
|
||||
picker.waitForExistence(timeout: defaultTimeout),
|
||||
"\(name) picker should exist, indicating lookups loaded",
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a picker element that may be a button or otherElement.
|
||||
private func findPicker(_ identifier: String) -> XCUIElement {
|
||||
let asButton = app.buttons[identifier]
|
||||
if asButton.exists { return asButton }
|
||||
return app.otherElements[identifier]
|
||||
}
|
||||
|
||||
/// Dismiss an open picker overlay.
|
||||
private func dismissPicker() {
|
||||
let doneButton = app.buttons["Done"]
|
||||
if doneButton.exists && doneButton.isHittable {
|
||||
doneButton.tap()
|
||||
} else {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform logout via the UI (settings → logout → confirm).
|
||||
private func performLogout() {
|
||||
// Navigate to Residences tab (where settings button lives)
|
||||
navigateToResidences()
|
||||
sleep(1)
|
||||
|
||||
// Tap settings button
|
||||
let settingsButton = app.buttons[AccessibilityIdentifiers.Navigation.settingsButton]
|
||||
settingsButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
settingsButton.forceTap()
|
||||
sleep(1)
|
||||
|
||||
// Scroll to and tap logout button
|
||||
let logoutButton = app.buttons[AccessibilityIdentifiers.Profile.logoutButton]
|
||||
if !logoutButton.waitForExistence(timeout: defaultTimeout) {
|
||||
// Try scrolling to find it
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
if scrollView.exists {
|
||||
logoutButton.scrollIntoView(in: scrollView)
|
||||
}
|
||||
}
|
||||
logoutButton.forceTap()
|
||||
sleep(1)
|
||||
|
||||
// Confirm logout in alert
|
||||
let alert = app.alerts.firstMatch
|
||||
if alert.waitForExistence(timeout: shortTimeout) {
|
||||
let confirmLogout = alert.buttons["Log Out"]
|
||||
if confirmLogout.exists {
|
||||
confirmLogout.tap()
|
||||
} else {
|
||||
// Fallback: tap any destructive-looking button
|
||||
let deleteButton = alert.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Log' OR label CONTAINS[c] 'Confirm'")
|
||||
).firstMatch
|
||||
if deleteButton.exists {
|
||||
deleteButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the static_data endpoint supports ETag by hitting it directly.
|
||||
private func verifyStaticDataEndpointSupportsETag() {
|
||||
// First request — should return 200 with ETag
|
||||
let firstResult = TestAccountAPIClient.rawRequest(method: "GET", path: "/static_data/")
|
||||
XCTAssertTrue(firstResult.succeeded, "static_data should return 200")
|
||||
|
||||
// Parse ETag from response (we need the raw HTTP headers)
|
||||
// Use a direct URLRequest to capture the ETag header
|
||||
guard let url = URL(string: "\(TestAccountAPIClient.baseURL)/static_data/") else {
|
||||
XCTFail("Invalid URL")
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 15
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var etag: String?
|
||||
var secondStatus: Int?
|
||||
|
||||
// Fetch ETag
|
||||
URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
defer { semaphore.signal() }
|
||||
etag = (response as? HTTPURLResponse)?.allHeaderFields["Etag"] as? String
|
||||
}.resume()
|
||||
semaphore.wait()
|
||||
|
||||
XCTAssertNotNil(etag, "static_data response should include an ETag header")
|
||||
guard let etagValue = etag else { return }
|
||||
|
||||
// Second request with If-None-Match — should return 304
|
||||
var conditionalRequest = URLRequest(url: url)
|
||||
conditionalRequest.httpMethod = "GET"
|
||||
conditionalRequest.setValue(etagValue, forHTTPHeaderField: "If-None-Match")
|
||||
conditionalRequest.timeoutInterval = 15
|
||||
|
||||
URLSession.shared.dataTask(with: conditionalRequest) { _, response, _ in
|
||||
defer { semaphore.signal() }
|
||||
secondStatus = (response as? HTTPURLResponse)?.statusCode
|
||||
}.resume()
|
||||
semaphore.wait()
|
||||
|
||||
XCTAssertEqual(
|
||||
secondStatus, 304,
|
||||
"static_data with matching ETag should return 304 Not Modified"
|
||||
)
|
||||
}
|
||||
}
|
||||
184
iosApp/HoneyDueUITests/Tests/DocumentIntegrationTests.swift
Normal file
184
iosApp/HoneyDueUITests/Tests/DocumentIntegrationTests.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import XCTest
|
||||
|
||||
/// Integration tests for document CRUD against the real local backend.
|
||||
///
|
||||
/// Test Plan IDs: DOC-002, DOC-004, DOC-005
|
||||
/// Data is seeded via API and cleaned up in tearDown.
|
||||
final class DocumentIntegrationTests: AuthenticatedTestCase {
|
||||
|
||||
override var useSeededAccount: Bool { true }
|
||||
|
||||
// MARK: - DOC-002: Create Document
|
||||
|
||||
func testDOC002_CreateDocumentWithRequiredFields() {
|
||||
// Seed a residence so the document form has a valid residence picker
|
||||
cleaner.seedResidence()
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton]
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Document.emptyStateView]
|
||||
let documentList = app.otherElements[AccessibilityIdentifiers.Document.documentsList]
|
||||
|
||||
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
||||
|| emptyState.waitForExistence(timeout: 3)
|
||||
|| documentList.waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(loaded, "Documents screen should load")
|
||||
|
||||
if addButton.exists && addButton.isHittable {
|
||||
addButton.forceTap()
|
||||
} else {
|
||||
let emptyAddButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||
).firstMatch
|
||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
let uniqueTitle = "IntTest Doc \(Int(Date().timeIntervalSince1970))"
|
||||
titleField.forceTap()
|
||||
titleField.typeText(uniqueTitle)
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
|
||||
let newDoc = app.staticTexts[uniqueTitle]
|
||||
XCTAssertTrue(
|
||||
newDoc.waitForExistence(timeout: longTimeout),
|
||||
"Newly created document should appear in list"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - DOC-004: Edit Document
|
||||
|
||||
func testDOC004_EditDocument() {
|
||||
// Seed a residence and document via API
|
||||
let residence = cleaner.seedResidence()
|
||||
let doc = cleaner.seedDocument(residenceId: residence.id, title: "Edit Target Doc \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
// Find and tap the seeded document
|
||||
let card = app.staticTexts[doc.title]
|
||||
card.waitForExistenceOrFail(timeout: longTimeout)
|
||||
card.forceTap()
|
||||
|
||||
// Tap edit
|
||||
let editButton = app.buttons[AccessibilityIdentifiers.Document.editButton]
|
||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
editButton.forceTap()
|
||||
|
||||
// Update title
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Document.titleField]
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
titleField.forceTap()
|
||||
titleField.press(forDuration: 1.0)
|
||||
let selectAll = app.menuItems["Select All"]
|
||||
if selectAll.waitForExistence(timeout: 2) {
|
||||
selectAll.tap()
|
||||
}
|
||||
|
||||
let updatedTitle = "Updated Doc \(Int(Date().timeIntervalSince1970))"
|
||||
titleField.typeText(updatedTitle)
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Document.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
|
||||
let updatedText = app.staticTexts[updatedTitle]
|
||||
XCTAssertTrue(
|
||||
updatedText.waitForExistence(timeout: longTimeout),
|
||||
"Updated document title should appear after edit"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - DOC-007: Document Image Section Exists
|
||||
// NOTE: Full image-deletion testing (the original DOC-007 scenario) requires a
|
||||
// document with at least one uploaded image. Image upload cannot be triggered
|
||||
// via API alone — it requires user interaction with the photo picker inside the
|
||||
// app (or a multipart upload endpoint). This stub seeds a document, opens its
|
||||
// detail view, and verifies the images section is present so that a human tester
|
||||
// or future automation (with photo injection) can extend it.
|
||||
|
||||
func test22_documentImageSectionExists() throws {
|
||||
// Seed a residence and a document via API
|
||||
let residence = cleaner.seedResidence()
|
||||
let document = cleaner.seedDocument(
|
||||
residenceId: residence.id,
|
||||
title: "Image Section Doc \(Int(Date().timeIntervalSince1970))"
|
||||
)
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
// Open the seeded document's detail
|
||||
let docText = app.staticTexts[document.title]
|
||||
docText.waitForExistenceOrFail(timeout: longTimeout)
|
||||
docText.forceTap()
|
||||
|
||||
// Verify the detail view loaded
|
||||
let detailView = app.otherElements[AccessibilityIdentifiers.Document.detailView]
|
||||
let detailLoaded = detailView.waitForExistence(timeout: defaultTimeout)
|
||||
|| app.navigationBars.staticTexts[document.title].waitForExistence(timeout: defaultTimeout)
|
||||
XCTAssertTrue(detailLoaded, "Document detail view should load after tapping the document")
|
||||
|
||||
// Look for an images / photos section header or add-image button.
|
||||
// The exact identifier or label will depend on the document detail implementation.
|
||||
let imagesSection = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Attachment'")
|
||||
).firstMatch
|
||||
|
||||
let addImageButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Image' OR label CONTAINS[c] 'Photo' OR label CONTAINS[c] 'Add'")
|
||||
).firstMatch
|
||||
|
||||
let sectionVisible = imagesSection.waitForExistence(timeout: defaultTimeout)
|
||||
|| addImageButton.waitForExistence(timeout: 3)
|
||||
|
||||
// This assertion will fail gracefully if the images section is not yet implemented.
|
||||
// When it does fail, it surfaces the missing UI element for the developer.
|
||||
XCTAssertTrue(
|
||||
sectionVisible,
|
||||
"Document detail should show an images/photos section or an add-image button. " +
|
||||
"Full deletion of a specific image requires manual upload first — see DOC-007 in test plan."
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - DOC-005: Delete Document
|
||||
|
||||
func testDOC005_DeleteDocument() {
|
||||
// Seed a document via API — don't track since we'll delete through UI
|
||||
let residence = cleaner.seedResidence()
|
||||
let deleteTitle = "Delete Doc \(Int(Date().timeIntervalSince1970))"
|
||||
TestDataSeeder.createDocument(token: session.token, residenceId: residence.id, title: deleteTitle)
|
||||
|
||||
navigateToDocuments()
|
||||
|
||||
let target = app.staticTexts[deleteTitle]
|
||||
target.waitForExistenceOrFail(timeout: longTimeout)
|
||||
target.forceTap()
|
||||
|
||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Document.deleteButton]
|
||||
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
deleteButton.forceTap()
|
||||
|
||||
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
||||
let alertDelete = app.alerts.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
||||
).firstMatch
|
||||
|
||||
if confirmButton.waitForExistence(timeout: shortTimeout) {
|
||||
confirmButton.tap()
|
||||
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
|
||||
alertDelete.tap()
|
||||
}
|
||||
|
||||
let deletedDoc = app.staticTexts[deleteTitle]
|
||||
XCTAssertTrue(
|
||||
deletedDoc.waitForNonExistence(timeout: longTimeout),
|
||||
"Deleted document should no longer appear"
|
||||
)
|
||||
}
|
||||
}
|
||||
257
iosApp/HoneyDueUITests/Tests/OnboardingTests.swift
Normal file
257
iosApp/HoneyDueUITests/Tests/OnboardingTests.swift
Normal file
@@ -0,0 +1,257 @@
|
||||
import XCTest
|
||||
|
||||
final class OnboardingTests: BaseUITestCase {
|
||||
func testF101_StartFreshFlowReachesCreateAccount() {
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Blueprint House")
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testF102_JoinExistingFlowGoesToCreateAccount() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapJoinExisting()
|
||||
|
||||
let createAccount = OnboardingCreateAccountScreen(app: app)
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testF103_BackNavigationFromNameResidenceReturnsToValueProps() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad()
|
||||
valueProps.tapContinue()
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad()
|
||||
nameResidence.tapBack()
|
||||
|
||||
XCTAssertTrue(app.otherElements[UITestID.Root.onboarding].waitForExistence(timeout: defaultTimeout))
|
||||
}
|
||||
|
||||
func testF104_SkipOnValuePropsMovesToNameResidence() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad()
|
||||
|
||||
let skipButton = app.buttons[UITestID.Onboarding.skipButton]
|
||||
skipButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
skipButton.forceTap()
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Additional Onboarding Coverage
|
||||
|
||||
func testF105_JoinExistingFlowSkipsValuePropsAndNameResidence() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapJoinExisting()
|
||||
|
||||
let createAccount = OnboardingCreateAccountScreen(app: app)
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Verify value props and name residence screens were NOT shown
|
||||
let valuePropsTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.valuePropsContainer).firstMatch
|
||||
XCTAssertFalse(valuePropsTitle.exists, "Value props should be skipped for Join Existing flow")
|
||||
|
||||
let nameResidenceTitle = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.nameResidenceTitle).firstMatch
|
||||
XCTAssertFalse(nameResidenceTitle.exists, "Name residence should be skipped for Join Existing flow")
|
||||
}
|
||||
|
||||
func testF106_NameResidenceFieldAcceptsInput() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad()
|
||||
valueProps.tapContinue()
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad()
|
||||
|
||||
let nameField = app.textFields[UITestID.Onboarding.residenceNameField]
|
||||
nameField.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
nameField.typeText("My Test Home")
|
||||
|
||||
XCTAssertEqual(nameField.value as? String, "My Test Home", "Residence name field should accept and display typed text")
|
||||
}
|
||||
|
||||
func testF107_ProgressIndicatorVisibleDuringOnboarding() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad()
|
||||
|
||||
let progress = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.progressIndicator).firstMatch
|
||||
XCTAssertTrue(progress.waitForExistence(timeout: defaultTimeout), "Progress indicator should be visible during onboarding flow")
|
||||
}
|
||||
|
||||
func testF108_BackFromCreateAccountNavigatesToPreviousStep() {
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: "Back Test")
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
|
||||
backButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
backButton.forceTap()
|
||||
|
||||
// Should return to name residence step
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - ONB-005: Residence Bootstrap
|
||||
|
||||
/// ONB-005: Start Fresh creates a residence automatically after email verification.
|
||||
/// Drives the full Start Fresh flow — welcome → value props → name residence →
|
||||
/// create account → verify email — then confirms the app lands on main tabs,
|
||||
/// which indicates the residence was bootstrapped during onboarding.
|
||||
func testF110_startFreshCreatesResidenceAfterVerification() {
|
||||
try? XCTSkipIf(
|
||||
!TestAccountAPIClient.isBackendReachable(),
|
||||
"Local backend is not reachable — skipping ONB-005"
|
||||
)
|
||||
|
||||
// Generate unique credentials so we don't collide with other test runs
|
||||
let creds = TestAccountManager.uniqueCredentials(prefix: "onb005")
|
||||
let uniqueResidenceName = "ONB005 Home \(Int(Date().timeIntervalSince1970))"
|
||||
|
||||
// Step 1: Navigate Start Fresh flow to the Create Account screen
|
||||
let createAccount = TestFlows.navigateStartFreshToCreateAccount(app: app, residenceName: uniqueResidenceName)
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Step 2: Expand the email sign-up form and fill it in
|
||||
createAccount.expandEmailSignup()
|
||||
|
||||
// Use the Onboarding-specific field identifiers for the create account form
|
||||
let onbUsernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField]
|
||||
let onbEmailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField]
|
||||
let onbPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.passwordField]
|
||||
let onbConfirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField]
|
||||
|
||||
onbUsernameField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
onbUsernameField.forceTap()
|
||||
onbUsernameField.typeText(creds.username)
|
||||
|
||||
onbEmailField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
onbEmailField.forceTap()
|
||||
onbEmailField.typeText(creds.email)
|
||||
|
||||
onbPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
onbPasswordField.forceTap()
|
||||
onbPasswordField.typeText(creds.password)
|
||||
|
||||
onbConfirmPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
onbConfirmPasswordField.forceTap()
|
||||
onbConfirmPasswordField.typeText(creds.password)
|
||||
|
||||
// Step 3: Submit the create account form
|
||||
let createAccountButton = app.descendants(matching: .any)
|
||||
.matching(identifier: UITestID.Onboarding.createAccountButton).firstMatch
|
||||
createAccountButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
createAccountButton.forceTap()
|
||||
|
||||
// Step 4: Verify email with the debug code
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
verificationScreen.waitForLoad(timeout: longTimeout)
|
||||
verificationScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verificationScreen.submitCode()
|
||||
|
||||
// Step 5: After verification, the app should transition to main tabs.
|
||||
// Landing on main tabs proves the onboarding completed and the residence
|
||||
// was bootstrapped automatically — no manual residence creation was required.
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|
||||
|| tabBar.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(
|
||||
reachedMain,
|
||||
"App should reach main tabs after Start Fresh onboarding + email verification, " +
|
||||
"confirming the residence '\(uniqueResidenceName)' was created automatically"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - ONB-008: Completion Persistence
|
||||
|
||||
/// ONB-008: Completing onboarding persists the completion flag so the next
|
||||
/// launch bypasses onboarding entirely and goes directly to login or main tabs.
|
||||
func testF111_completedOnboardingBypassedOnRelaunch() {
|
||||
try? XCTSkipIf(
|
||||
!TestAccountAPIClient.isBackendReachable(),
|
||||
"Local backend is not reachable — skipping ONB-008"
|
||||
)
|
||||
|
||||
// Step 1: Complete onboarding via the Join Existing path (quickest path to main tabs).
|
||||
// Navigate to the create account screen which marks the onboarding intent as started.
|
||||
// Then use a pre-seeded account so we can reach main tabs without creating a new account.
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad()
|
||||
welcome.tapAlreadyHaveAccount()
|
||||
|
||||
// Log in with the seeded account to complete onboarding and reach main tabs
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.enterUsername("admin")
|
||||
login.enterPassword("test1234")
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
|
||||
// Wait for main tabs — this confirms onboarding is considered complete
|
||||
let mainTabs = app.otherElements[UITestID.Root.mainTabs]
|
||||
let tabBar = app.tabBars.firstMatch
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: longTimeout)
|
||||
|| tabBar.waitForExistence(timeout: 5)
|
||||
XCTAssertTrue(reachedMain, "Should reach main tabs after first login to establish completed-onboarding state")
|
||||
|
||||
// Step 2: Terminate the app
|
||||
app.terminate()
|
||||
|
||||
// Step 3: Relaunch WITHOUT --reset-state so the onboarding-completed flag is preserved.
|
||||
// This simulates a real app restart where the user should NOT see onboarding again.
|
||||
app.launchArguments = [
|
||||
"--ui-testing",
|
||||
"--disable-animations"
|
||||
// NOTE: intentionally omitting --reset-state
|
||||
]
|
||||
app.launch()
|
||||
app.otherElements[UITestID.Root.ready].waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
|
||||
// Step 4: The app should NOT show the onboarding welcome screen.
|
||||
// It should land on the login screen (token expired/missing) or main tabs
|
||||
// (if the auth token persisted). Either outcome is valid — what matters is
|
||||
// that the onboarding root is NOT shown.
|
||||
let onboardingWelcomeTitle = app.descendants(matching: .any)
|
||||
.matching(identifier: UITestID.Onboarding.welcomeTitle).firstMatch
|
||||
let startFreshButton = app.descendants(matching: .any)
|
||||
.matching(identifier: UITestID.Onboarding.startFreshButton).firstMatch
|
||||
|
||||
// Give the app a moment to settle on its landing screen
|
||||
sleep(2)
|
||||
|
||||
let isShowingOnboarding = onboardingWelcomeTitle.exists || startFreshButton.exists
|
||||
XCTAssertFalse(
|
||||
isShowingOnboarding,
|
||||
"App should NOT show the onboarding welcome screen after onboarding was completed on a previous launch"
|
||||
)
|
||||
|
||||
// Additionally verify the app landed on a valid post-onboarding screen
|
||||
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
|
||||
let isOnLogin = loginField.waitForExistence(timeout: defaultTimeout)
|
||||
let isOnMain = mainTabs.exists || tabBar.exists
|
||||
|
||||
XCTAssertTrue(
|
||||
isOnLogin || isOnMain,
|
||||
"After relaunch without reset, app should show login or main tabs — not onboarding"
|
||||
)
|
||||
}
|
||||
}
|
||||
204
iosApp/HoneyDueUITests/Tests/PasswordResetTests.swift
Normal file
204
iosApp/HoneyDueUITests/Tests/PasswordResetTests.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import XCTest
|
||||
|
||||
/// Tests for the password reset flow against the local backend (DEBUG=true, code=123456).
|
||||
///
|
||||
/// Test Plan IDs: AUTH-015, AUTH-016, AUTH-017
|
||||
final class PasswordResetTests: BaseUITestCase {
|
||||
|
||||
private var testSession: TestSession?
|
||||
|
||||
override func setUpWithError() throws {
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
}
|
||||
|
||||
// Create a verified account via API so we have real credentials for reset
|
||||
guard let session = TestAccountManager.createVerifiedAccount() else {
|
||||
throw XCTSkip("Could not create verified test account")
|
||||
}
|
||||
testSession = session
|
||||
|
||||
try super.setUpWithError()
|
||||
}
|
||||
|
||||
// MARK: - AUTH-015: Verify reset code reaches new password screen
|
||||
|
||||
func testAUTH015_VerifyResetCodeSuccessPath() throws {
|
||||
let session = try XCTUnwrap(testSession)
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Enter email and send code
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(session.user.email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Enter the debug verification code
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Should reach the new password screen
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
resetScreen.waitForLoad(timeout: longTimeout)
|
||||
}
|
||||
|
||||
// MARK: - AUTH-016: Full reset password cycle + login with new password
|
||||
|
||||
func testAUTH016_ResetPasswordSuccess() throws {
|
||||
let session = try XCTUnwrap(testSession)
|
||||
let newPassword = "NewPass9876!"
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Complete the full reset flow via UI
|
||||
TestFlows.completeForgotPasswordFlow(
|
||||
app: app,
|
||||
email: session.user.email,
|
||||
newPassword: newPassword
|
||||
)
|
||||
|
||||
// Wait for success indication - either success message or return to login
|
||||
let successText = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'")
|
||||
).firstMatch
|
||||
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
var succeeded = false
|
||||
while Date() < deadline {
|
||||
if successText.exists || returnButton.exists {
|
||||
succeeded = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTAssertTrue(succeeded, "Expected success indication after password reset")
|
||||
|
||||
// If return to login button appears, tap it
|
||||
if returnButton.exists && returnButton.isHittable {
|
||||
returnButton.tap()
|
||||
}
|
||||
|
||||
// Verify we can login with the new password via API
|
||||
let loginResponse = TestAccountAPIClient.login(
|
||||
username: session.username,
|
||||
password: newPassword
|
||||
)
|
||||
XCTAssertNotNil(loginResponse, "Should be able to login with new password after reset")
|
||||
}
|
||||
|
||||
// MARK: - AUTH-015 (alias): Verify reset code reaches the new password screen
|
||||
|
||||
func test03_verifyResetCodeSuccess() throws {
|
||||
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
||||
|
||||
let session = try XCTUnwrap(testSession)
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Enter email and send the reset code
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(session.user.email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
// Enter the debug verification code on the verify screen
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// The reset password screen should now appear
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
resetScreen.waitForLoad(timeout: longTimeout)
|
||||
}
|
||||
|
||||
// MARK: - AUTH-016 (alias): Full reset flow + login with new password
|
||||
|
||||
func test04_resetPasswordSuccessAndLogin() throws {
|
||||
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
|
||||
|
||||
let session = try XCTUnwrap(testSession)
|
||||
let newPassword = "NewPass9876!"
|
||||
|
||||
// Navigate to forgot password, then drive the complete 3-step reset flow
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
TestFlows.completeForgotPasswordFlow(
|
||||
app: app,
|
||||
email: session.user.email,
|
||||
newPassword: newPassword
|
||||
)
|
||||
|
||||
// Wait for a success indication — either a success message or the return-to-login button
|
||||
let successText = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'")
|
||||
).firstMatch
|
||||
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
|
||||
|
||||
let deadline = Date().addingTimeInterval(longTimeout)
|
||||
var resetSucceeded = false
|
||||
while Date() < deadline {
|
||||
if successText.exists || returnButton.exists {
|
||||
resetSucceeded = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
XCTAssertTrue(resetSucceeded, "Expected success indication after password reset")
|
||||
|
||||
// If the return-to-login button is present, tap it to go back to the login screen
|
||||
if returnButton.exists && returnButton.isHittable {
|
||||
returnButton.tap()
|
||||
}
|
||||
|
||||
// Confirm the new password works by logging in via the API
|
||||
let loginResponse = TestAccountAPIClient.login(
|
||||
username: session.username,
|
||||
password: newPassword
|
||||
)
|
||||
XCTAssertNotNil(loginResponse, "Should be able to login with the new password after a successful reset")
|
||||
}
|
||||
|
||||
// MARK: - AUTH-017: Mismatched passwords are blocked
|
||||
|
||||
func testAUTH017_MismatchedPasswordBlocked() throws {
|
||||
let session = try XCTUnwrap(testSession)
|
||||
|
||||
// Navigate to forgot password
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.tapForgotPassword()
|
||||
|
||||
// Get to the reset password screen
|
||||
let forgotScreen = ForgotPasswordScreen(app: app)
|
||||
forgotScreen.waitForLoad()
|
||||
forgotScreen.enterEmail(session.user.email)
|
||||
forgotScreen.tapSendCode()
|
||||
|
||||
let verifyScreen = VerifyResetCodeScreen(app: app)
|
||||
verifyScreen.waitForLoad()
|
||||
verifyScreen.enterCode(TestAccountAPIClient.debugVerificationCode)
|
||||
verifyScreen.tapVerify()
|
||||
|
||||
// Enter mismatched passwords
|
||||
let resetScreen = ResetPasswordScreen(app: app)
|
||||
resetScreen.waitForLoad(timeout: longTimeout)
|
||||
resetScreen.enterNewPassword("ValidPass123!")
|
||||
resetScreen.enterConfirmPassword("DifferentPass456!")
|
||||
|
||||
// The reset button should be disabled when passwords don't match
|
||||
XCTAssertFalse(resetScreen.isResetButtonEnabled, "Reset button should be disabled when passwords don't match")
|
||||
}
|
||||
}
|
||||
@@ -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 = LoginScreenObject(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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 = LoginScreenObject(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 = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testR202_validCredentialsSubmitFromLogin() {
|
||||
UITestHelpers.ensureOnLoginScreen(app: app)
|
||||
let login = LoginScreenObject(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")
|
||||
}
|
||||
}
|
||||
@@ -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 = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
login.enterUsername("testuser")
|
||||
login.enterPassword("TestPass123!")
|
||||
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].waitForExistenceOrFail(timeout: defaultTimeout).forceTap()
|
||||
|
||||
let main = MainTabScreenObject(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)
|
||||
}
|
||||
}
|
||||
227
iosApp/HoneyDueUITests/Tests/ResidenceIntegrationTests.swift
Normal file
227
iosApp/HoneyDueUITests/Tests/ResidenceIntegrationTests.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
import XCTest
|
||||
|
||||
/// Integration tests for residence CRUD against the real local backend.
|
||||
///
|
||||
/// Uses a seeded admin account. Data is seeded via API and cleaned up in tearDown.
|
||||
final class ResidenceIntegrationTests: AuthenticatedTestCase {
|
||||
|
||||
override var useSeededAccount: Bool { true }
|
||||
|
||||
// MARK: - Create Residence
|
||||
|
||||
func testRES_CreateResidenceAppearsInList() {
|
||||
navigateToResidences()
|
||||
|
||||
let residenceList = ResidenceListScreen(app: app)
|
||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
residenceList.openCreateResidence()
|
||||
|
||||
let form = ResidenceFormScreen(app: app)
|
||||
form.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let uniqueName = "IntTest Residence \(Int(Date().timeIntervalSince1970))"
|
||||
form.enterName(uniqueName)
|
||||
form.save()
|
||||
|
||||
let newResidence = app.staticTexts[uniqueName]
|
||||
XCTAssertTrue(
|
||||
newResidence.waitForExistence(timeout: longTimeout),
|
||||
"Newly created residence should appear in the list"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Edit Residence
|
||||
|
||||
func testRES_EditResidenceUpdatesInList() {
|
||||
// Seed a residence via API so we have a known target to edit
|
||||
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToResidences()
|
||||
|
||||
let residenceList = ResidenceListScreen(app: app)
|
||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Find and tap the seeded residence
|
||||
let card = app.staticTexts[seeded.name]
|
||||
card.waitForExistenceOrFail(timeout: longTimeout)
|
||||
card.forceTap()
|
||||
|
||||
// Tap edit button on detail view
|
||||
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
editButton.forceTap()
|
||||
|
||||
let form = ResidenceFormScreen(app: app)
|
||||
form.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Clear and re-enter name
|
||||
let nameField = form.nameField
|
||||
nameField.waitUntilHittable(timeout: 10).tap()
|
||||
nameField.press(forDuration: 1.0)
|
||||
let selectAll = app.menuItems["Select All"]
|
||||
if selectAll.waitForExistence(timeout: 2) {
|
||||
selectAll.tap()
|
||||
}
|
||||
|
||||
let updatedName = "Updated Res \(Int(Date().timeIntervalSince1970))"
|
||||
nameField.typeText(updatedName)
|
||||
form.save()
|
||||
|
||||
let updatedText = app.staticTexts[updatedName]
|
||||
XCTAssertTrue(
|
||||
updatedText.waitForExistence(timeout: longTimeout),
|
||||
"Updated residence name should appear after edit"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - RES-007: Primary Residence
|
||||
|
||||
func test18_setPrimaryResidence() {
|
||||
// Seed two residences via API; the second one will be promoted to primary
|
||||
let firstResidence = cleaner.seedResidence(name: "Primary Test A \(Int(Date().timeIntervalSince1970))")
|
||||
let secondResidence = cleaner.seedResidence(name: "Primary Test B \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToResidences()
|
||||
|
||||
let residenceList = ResidenceListScreen(app: app)
|
||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Open the second residence's detail
|
||||
let secondCard = app.staticTexts[secondResidence.name]
|
||||
secondCard.waitForExistenceOrFail(timeout: longTimeout)
|
||||
secondCard.forceTap()
|
||||
|
||||
// Tap edit
|
||||
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton]
|
||||
editButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
editButton.forceTap()
|
||||
|
||||
let form = ResidenceFormScreen(app: app)
|
||||
form.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Find and toggle the "is primary" toggle
|
||||
let isPrimaryToggle = app.switches[AccessibilityIdentifiers.Residence.isPrimaryToggle]
|
||||
isPrimaryToggle.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
isPrimaryToggle.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
|
||||
// Toggle it on (value "0" means off, "1" means on)
|
||||
if (isPrimaryToggle.value as? String) == "0" {
|
||||
isPrimaryToggle.forceTap()
|
||||
}
|
||||
|
||||
form.save()
|
||||
|
||||
// After saving, a primary indicator should be visible — either a label,
|
||||
// badge, or the toggle being on in the refreshed detail view.
|
||||
let primaryIndicator = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
||||
).firstMatch
|
||||
|
||||
let primaryBadge = app.images.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Primary'")
|
||||
).firstMatch
|
||||
|
||||
let indicatorVisible = primaryIndicator.waitForExistence(timeout: longTimeout)
|
||||
|| primaryBadge.waitForExistence(timeout: 3)
|
||||
|
||||
XCTAssertTrue(
|
||||
indicatorVisible,
|
||||
"A primary residence indicator should appear after setting '\(secondResidence.name)' as primary"
|
||||
)
|
||||
|
||||
// Clean up: remove unused firstResidence id from tracking (already tracked via cleaner)
|
||||
_ = firstResidence
|
||||
}
|
||||
|
||||
// MARK: - OFF-004: Double Submit Protection
|
||||
|
||||
func test19_doubleSubmitProtection() {
|
||||
navigateToResidences()
|
||||
|
||||
let residenceList = ResidenceListScreen(app: app)
|
||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
residenceList.openCreateResidence()
|
||||
|
||||
let form = ResidenceFormScreen(app: app)
|
||||
form.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let uniqueName = "DoubleSubmit \(Int(Date().timeIntervalSince1970))"
|
||||
form.enterName(uniqueName)
|
||||
|
||||
// Rapidly tap save twice to test double-submit protection
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
// Second tap immediately after — if the button is already disabled this will be a no-op
|
||||
if saveButton.isHittable {
|
||||
saveButton.forceTap()
|
||||
}
|
||||
|
||||
// Wait for the form to dismiss (sheet closes, we return to the list)
|
||||
let formDismissed = saveButton.waitForNonExistence(timeout: longTimeout)
|
||||
XCTAssertTrue(formDismissed, "Form should dismiss after save")
|
||||
|
||||
// Back on the residences list — count how many cells with the unique name exist
|
||||
let matchingTexts = app.staticTexts.matching(
|
||||
NSPredicate(format: "label == %@", uniqueName)
|
||||
)
|
||||
|
||||
// Allow time for the list to fully load
|
||||
_ = app.staticTexts[uniqueName].waitForExistence(timeout: defaultTimeout)
|
||||
|
||||
XCTAssertEqual(
|
||||
matchingTexts.count, 1,
|
||||
"Only one residence named '\(uniqueName)' should exist — double-submit protection should prevent duplicates"
|
||||
)
|
||||
|
||||
// Track the created residence for cleanup
|
||||
if let residences = TestAccountAPIClient.listResidences(token: session.token) {
|
||||
if let created = residences.first(where: { $0.name == uniqueName }) {
|
||||
cleaner.trackResidence(created.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Residence
|
||||
|
||||
func testRES_DeleteResidenceRemovesFromList() {
|
||||
// Seed a residence via API — don't track it since we'll delete through the UI
|
||||
let deleteName = "Delete Me \(Int(Date().timeIntervalSince1970))"
|
||||
TestDataSeeder.createResidence(token: session.token, name: deleteName)
|
||||
|
||||
navigateToResidences()
|
||||
|
||||
let residenceList = ResidenceListScreen(app: app)
|
||||
residenceList.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Find and tap the seeded residence
|
||||
let target = app.staticTexts[deleteName]
|
||||
target.waitForExistenceOrFail(timeout: longTimeout)
|
||||
target.forceTap()
|
||||
|
||||
// Tap delete button
|
||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Residence.deleteButton]
|
||||
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
deleteButton.forceTap()
|
||||
|
||||
// Confirm deletion in alert
|
||||
let confirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
||||
let alertDelete = app.alerts.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
||||
).firstMatch
|
||||
|
||||
if confirmButton.waitForExistence(timeout: shortTimeout) {
|
||||
confirmButton.tap()
|
||||
} else if alertDelete.waitForExistence(timeout: shortTimeout) {
|
||||
alertDelete.tap()
|
||||
}
|
||||
|
||||
let deletedResidence = app.staticTexts[deleteName]
|
||||
XCTAssertTrue(
|
||||
deletedResidence.waitForNonExistence(timeout: longTimeout),
|
||||
"Deleted residence should no longer appear in the list"
|
||||
)
|
||||
}
|
||||
}
|
||||
165
iosApp/HoneyDueUITests/Tests/StabilityTests.swift
Normal file
165
iosApp/HoneyDueUITests/Tests/StabilityTests.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import XCTest
|
||||
|
||||
final class StabilityTests: BaseUITestCase {
|
||||
func testP001_RapidOnboardingNavigationDoesNotCrash() {
|
||||
for _ in 0..<3 {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad(timeout: defaultTimeout)
|
||||
valueProps.tapBack()
|
||||
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func testP002_RepeatedForwardNavigationRemainsResponsive() {
|
||||
for index in 0..<3 {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad(timeout: defaultTimeout)
|
||||
valueProps.tapContinue()
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad(timeout: defaultTimeout)
|
||||
nameResidence.enterResidenceName("Stress Home \(index)")
|
||||
nameResidence.tapBack()
|
||||
|
||||
valueProps.waitForLoad(timeout: defaultTimeout)
|
||||
valueProps.tapBack()
|
||||
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func testP003_RapidDoubleTapOnValuePropsContinueLandsOnNameResidence() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
welcome.tapStartFresh()
|
||||
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
let continueButton = app.buttons[UITestID.Onboarding.valuePropsNextButton]
|
||||
continueButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
if continueButton.exists && continueButton.isHittable {
|
||||
continueButton.tap()
|
||||
}
|
||||
|
||||
let nameResidence = OnboardingNameResidenceScreen(app: app)
|
||||
nameResidence.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Additional Stability Coverage
|
||||
|
||||
func testP004_StartFreshThenBackToWelcomeThenJoinExistingDoesNotCorruptState() {
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Start fresh path
|
||||
welcome.tapStartFresh()
|
||||
let valueProps = OnboardingValuePropsScreen(app: app)
|
||||
valueProps.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Go back to welcome
|
||||
valueProps.tapBack()
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Switch to join existing path
|
||||
welcome.tapJoinExisting()
|
||||
let createAccount = OnboardingCreateAccountScreen(app: app)
|
||||
createAccount.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
|
||||
func testP005_RepeatedLoginNavigationRemainsStable() {
|
||||
for _ in 0..<3 {
|
||||
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Dismiss login (swipe down or navigate back)
|
||||
let backButton = app.descendants(matching: .any).matching(identifier: UITestID.Onboarding.backButton).firstMatch
|
||||
if backButton.waitForExistence(timeout: shortTimeout) && backButton.isHittable {
|
||||
backButton.forceTap()
|
||||
} else {
|
||||
// Try swipe down to dismiss sheet
|
||||
app.swipeDown()
|
||||
}
|
||||
|
||||
// Should return to onboarding
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OFF-003: Retry Button Existence
|
||||
|
||||
/// OFF-003: Retry button is accessible from error states.
|
||||
///
|
||||
/// A true end-to-end retry test (where the network actually fails then succeeds)
|
||||
/// is not feasible in XCUITest without network manipulation infrastructure. This
|
||||
/// test verifies the structural requirement: that the retry accessibility identifier
|
||||
/// `AccessibilityIdentifiers.Common.retryButton` is defined and that any error view
|
||||
/// in the app exposes a tappable retry control.
|
||||
///
|
||||
/// When an error view IS visible (e.g., backend is unreachable), the test asserts the
|
||||
/// retry button exists and can be tapped without crashing the app.
|
||||
func testP010_retryButtonExistsOnErrorState() {
|
||||
// Navigate to the login screen from onboarding — this is the most common
|
||||
// path that could encounter an error state if the backend is unreachable.
|
||||
let welcome = OnboardingWelcomeScreen(app: app)
|
||||
welcome.waitForLoad(timeout: defaultTimeout)
|
||||
welcome.tapAlreadyHaveAccount()
|
||||
|
||||
let login = LoginScreenObject(app: app)
|
||||
login.waitForLoad(timeout: defaultTimeout)
|
||||
|
||||
// Attempt login with intentionally wrong credentials to trigger an error state
|
||||
login.enterUsername("nonexistent_user_off003")
|
||||
login.enterPassword("WrongPass!")
|
||||
|
||||
let loginButton = app.buttons[UITestID.Auth.loginButton]
|
||||
loginButton.waitUntilHittable(timeout: defaultTimeout).tap()
|
||||
|
||||
// Wait briefly to allow any error state to appear
|
||||
sleep(3)
|
||||
|
||||
// Check for error view and retry button
|
||||
let retryButton = app.buttons[AccessibilityIdentifiers.Common.retryButton]
|
||||
let errorView = app.otherElements[AccessibilityIdentifiers.Common.errorView]
|
||||
|
||||
// If an error view is visible, assert the retry button is also present and tappable
|
||||
if errorView.exists {
|
||||
XCTAssertTrue(
|
||||
retryButton.waitForExistence(timeout: shortTimeout),
|
||||
"Retry button (\(AccessibilityIdentifiers.Common.retryButton)) should exist when an error view is shown"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
retryButton.isEnabled,
|
||||
"Retry button should be enabled so the user can re-attempt the failed operation"
|
||||
)
|
||||
// Tapping retry should not crash the app
|
||||
retryButton.forceTap()
|
||||
sleep(1)
|
||||
XCTAssertTrue(app.exists, "App should remain running after tapping retry")
|
||||
} else {
|
||||
// No error view is currently visible — this is acceptable if login
|
||||
// shows an inline error message instead. Confirm the app is still in a
|
||||
// usable state (it did not crash and the login screen is still present).
|
||||
let stillOnLogin = app.textFields[UITestID.Auth.usernameField].exists
|
||||
let showsAlert = app.alerts.firstMatch.exists
|
||||
let showsErrorText = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'error'")
|
||||
).firstMatch.exists
|
||||
|
||||
XCTAssertTrue(
|
||||
stillOnLogin || showsAlert || showsErrorText,
|
||||
"After a failed login the app should show an error state — login screen, alert, or inline error"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
231
iosApp/HoneyDueUITests/Tests/TaskIntegrationTests.swift
Normal file
231
iosApp/HoneyDueUITests/Tests/TaskIntegrationTests.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import XCTest
|
||||
|
||||
/// Integration tests for task operations against the real local backend.
|
||||
///
|
||||
/// Test Plan IDs: TASK-010, TASK-012, plus create/edit flows.
|
||||
/// Data is seeded via API and cleaned up in tearDown.
|
||||
final class TaskIntegrationTests: AuthenticatedTestCase {
|
||||
|
||||
override var useSeededAccount: Bool { true }
|
||||
|
||||
// MARK: - Create Task
|
||||
|
||||
func testTASK_CreateTaskAppearsInList() {
|
||||
// Seed a residence via API so task creation has a valid target
|
||||
let residence = cleaner.seedResidence()
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
|
||||
let emptyState = app.otherElements[AccessibilityIdentifiers.Task.emptyStateView]
|
||||
let taskList = app.otherElements[AccessibilityIdentifiers.Task.tasksList]
|
||||
|
||||
let loaded = addButton.waitForExistence(timeout: defaultTimeout)
|
||||
|| emptyState.waitForExistence(timeout: 3)
|
||||
|| taskList.waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(loaded, "Tasks screen should load")
|
||||
|
||||
if addButton.exists && addButton.isHittable {
|
||||
addButton.forceTap()
|
||||
} else {
|
||||
let emptyAddButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||
).firstMatch
|
||||
emptyAddButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
let uniqueTitle = "IntTest Task \(Int(Date().timeIntervalSince1970))"
|
||||
titleField.forceTap()
|
||||
titleField.typeText(uniqueTitle)
|
||||
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
|
||||
let newTask = app.staticTexts[uniqueTitle]
|
||||
XCTAssertTrue(
|
||||
newTask.waitForExistence(timeout: longTimeout),
|
||||
"Newly created task should appear"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TASK-010: Uncancel Task
|
||||
|
||||
func testTASK010_UncancelTaskFlow() throws {
|
||||
// Seed a cancelled task via API
|
||||
let residence = cleaner.seedResidence()
|
||||
let cancelledTask = TestDataSeeder.createCancelledTask(token: session.token, residenceId: residence.id)
|
||||
cleaner.trackTask(cancelledTask.id)
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
// Find the cancelled task
|
||||
let taskText = app.staticTexts[cancelledTask.title]
|
||||
guard taskText.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("Cancelled task not visible in current view")
|
||||
}
|
||||
taskText.forceTap()
|
||||
|
||||
// Look for an uncancel or reopen button
|
||||
let uncancelButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
||||
).firstMatch
|
||||
|
||||
if uncancelButton.waitForExistence(timeout: defaultTimeout) {
|
||||
uncancelButton.forceTap()
|
||||
|
||||
let statusText = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
||||
).firstMatch
|
||||
XCTAssertFalse(statusText.exists, "Task should no longer show as cancelled after uncancel")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TASK-010 (v2): Uncancel Task — Restores Cancelled Task to Active Lifecycle
|
||||
|
||||
func test15_uncancelRestorescancelledTask() throws {
|
||||
// Seed a residence and a task, then cancel the task via API
|
||||
let residence = cleaner.seedResidence(name: "Uncancel Test Residence \(Int(Date().timeIntervalSince1970))")
|
||||
let task = cleaner.seedTask(residenceId: residence.id, title: "Uncancel Me \(Int(Date().timeIntervalSince1970))")
|
||||
guard TestAccountAPIClient.cancelTask(token: session.token, id: task.id) != nil else {
|
||||
throw XCTSkip("Could not cancel task via API — skipping uncancel test")
|
||||
}
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
// The cancelled task should be visible somewhere on the tasks screen
|
||||
// (e.g., in a Cancelled column or section)
|
||||
let taskText = app.staticTexts[task.title]
|
||||
guard taskText.waitForExistence(timeout: longTimeout) else {
|
||||
throw XCTSkip("Cancelled task '\(task.title)' not visible — may require a Cancelled filter to be active")
|
||||
}
|
||||
taskText.forceTap()
|
||||
|
||||
// Look for an uncancel / reopen / restore action
|
||||
let uncancelButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
||||
).firstMatch
|
||||
|
||||
guard uncancelButton.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("No uncancel button found — feature may not yet be implemented in UI")
|
||||
}
|
||||
uncancelButton.forceTap()
|
||||
|
||||
// After uncancelling, the task should no longer show a Cancelled status label
|
||||
let cancelledLabel = app.staticTexts.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Cancelled'")
|
||||
).firstMatch
|
||||
XCTAssertFalse(
|
||||
cancelledLabel.waitForExistence(timeout: defaultTimeout),
|
||||
"Task should no longer display 'Cancelled' status after being restored"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TASK-004: Create Task from Template
|
||||
|
||||
func test16_createTaskFromTemplate() throws {
|
||||
// Seed a residence so template-created tasks have a valid target
|
||||
cleaner.seedResidence(name: "Template Test Residence \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
// Tap the add task button (or empty-state equivalent)
|
||||
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton]
|
||||
let emptyAddButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Add' OR label CONTAINS[c] 'Create'")
|
||||
).firstMatch
|
||||
|
||||
let addVisible = addButton.waitForExistence(timeout: defaultTimeout) || emptyAddButton.waitForExistence(timeout: 3)
|
||||
XCTAssertTrue(addVisible, "An add/create task button should be visible on the tasks screen")
|
||||
|
||||
if addButton.exists && addButton.isHittable {
|
||||
addButton.forceTap()
|
||||
} else {
|
||||
emptyAddButton.forceTap()
|
||||
}
|
||||
|
||||
// Look for a Templates or Browse Templates option within the add-task flow.
|
||||
// NOTE: The exact accessibility identifier for the template browser is not yet defined
|
||||
// in AccessibilityIdentifiers.swift. The identifiers below use the pattern established
|
||||
// in the codebase (e.g., "TaskForm.TemplatesButton") and will need to be wired up in
|
||||
// the SwiftUI view when the template browser feature is implemented.
|
||||
let templateButton = app.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Template' OR label CONTAINS[c] 'Browse'")
|
||||
).firstMatch
|
||||
|
||||
guard templateButton.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("Template browser not yet reachable from the add-task flow — skipping")
|
||||
}
|
||||
templateButton.forceTap()
|
||||
|
||||
// Select the first available template
|
||||
let firstTemplate = app.cells.firstMatch
|
||||
guard firstTemplate.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("No templates available in template browser — skipping")
|
||||
}
|
||||
firstTemplate.forceTap()
|
||||
|
||||
// After selecting a template the form should be pre-filled — the title field should
|
||||
// contain something (i.e., not be empty)
|
||||
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
|
||||
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
let preFilledTitle = titleField.value as? String ?? ""
|
||||
XCTAssertFalse(
|
||||
preFilledTitle.isEmpty,
|
||||
"Title field should be pre-filled by the selected template"
|
||||
)
|
||||
|
||||
// Save the templated task
|
||||
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
|
||||
saveButton.scrollIntoView(in: app.scrollViews.firstMatch)
|
||||
saveButton.forceTap()
|
||||
|
||||
// The task should now appear in the list
|
||||
let savedTask = app.staticTexts[preFilledTitle]
|
||||
XCTAssertTrue(
|
||||
savedTask.waitForExistence(timeout: longTimeout),
|
||||
"Task created from template ('\(preFilledTitle)') should appear in the task list"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TASK-012: Delete Task
|
||||
|
||||
func testTASK012_DeleteTaskUpdatesViews() {
|
||||
// Seed a task via API
|
||||
let residence = cleaner.seedResidence()
|
||||
let task = cleaner.seedTask(residenceId: residence.id, title: "Delete Task \(Int(Date().timeIntervalSince1970))")
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
// Find and open the task
|
||||
let taskText = app.staticTexts[task.title]
|
||||
taskText.waitForExistenceOrFail(timeout: longTimeout)
|
||||
taskText.forceTap()
|
||||
|
||||
// Delete the task
|
||||
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
|
||||
deleteButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
deleteButton.forceTap()
|
||||
|
||||
// Confirm deletion
|
||||
let confirmDelete = app.alerts.buttons.containing(
|
||||
NSPredicate(format: "label CONTAINS[c] 'Delete' OR label CONTAINS[c] 'Confirm'")
|
||||
).firstMatch
|
||||
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
|
||||
|
||||
if alertConfirmButton.waitForExistence(timeout: shortTimeout) {
|
||||
alertConfirmButton.tap()
|
||||
} else if confirmDelete.waitForExistence(timeout: shortTimeout) {
|
||||
confirmDelete.tap()
|
||||
}
|
||||
|
||||
let deletedTask = app.staticTexts[task.title]
|
||||
XCTAssertTrue(
|
||||
deletedTask.waitForNonExistence(timeout: longTimeout),
|
||||
"Deleted task should no longer appear in views"
|
||||
)
|
||||
}
|
||||
}
|
||||
174
iosApp/HoneyDueUITests/UITestHelpers.swift
Normal file
174
iosApp/HoneyDueUITests/UITestHelpers.swift
Normal file
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user