From 7dce2116815d2b67dddaacc6ebb1567f21e6906b Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 8 Nov 2025 16:02:01 -0600 Subject: [PATCH] wip --- .../kotlin/com/example/mycrib/App.kt | 2 +- .../com/example/mycrib/models/CustomTask.kt | 4 +- .../com/example/mycrib/models/Lookups.kt | 1 + .../com/example/mycrib/navigation/Routes.kt | 2 +- .../mycrib/ui/components/AddNewTaskDialog.kt | 2 +- .../AddNewTaskWithResidenceDialog.kt | 2 +- .../mycrib/ui/components/task/TaskCard.kt | 4 +- .../mycrib/ui/screens/EditTaskScreen.kt | 4 +- iosApp/iosApp/Subviews/Task/TaskCard.swift | 10 +- .../iosApp/Subviews/Task/TasksSection.swift | 4 +- iosApp/iosApp/Task/AllTasksView.swift | 80 +-- iosApp/iosApp/Task/EditTaskView.swift | 2 +- .../iosAppUITests/AuthenticationUITests.swift | 269 ++++++++++ iosApp/iosAppUITests/MultiUserUITests.swift | 470 ++++++++++++++++++ iosApp/iosAppUITests/ResidenceUITests.swift | 346 +++++++++++++ iosApp/iosAppUITests/TaskUITests.swift | 431 ++++++++++++++++ iosApp/iosAppUITests/TestHelpers.swift | 180 +++++++ 17 files changed, 1757 insertions(+), 56 deletions(-) create mode 100644 iosApp/iosAppUITests/AuthenticationUITests.swift create mode 100644 iosApp/iosAppUITests/MultiUserUITests.swift create mode 100644 iosApp/iosAppUITests/ResidenceUITests.swift create mode 100644 iosApp/iosAppUITests/TaskUITests.swift create mode 100644 iosApp/iosAppUITests/TestHelpers.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index 95bb377..b058305 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -423,7 +423,7 @@ fun App() { description = route.description, category = TaskCategory(route.categoryId, route.categoryName), frequency = TaskFrequency( - route.frequencyId, route.frequencyName, "", + route.frequencyId, route.frequencyName, "", route.frequencyName, daySpan = 0, notifyDays = 0 ), diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index f1a45b6..499e06c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -14,7 +14,7 @@ data class CustomTask ( val category: String, val priority: String, val status: String? = null, - @SerialName("due_date") val dueDate: String, + @SerialName("due_date") val dueDate: String?, @SerialName("estimated_cost") val estimatedCost: String? = null, @SerialName("actual_cost") val actualCost: String? = null, val notes: String? = null, @@ -62,7 +62,7 @@ data class TaskDetail( val priority: TaskPriority, val frequency: TaskFrequency, val status: TaskStatus?, - @SerialName("due_date") val dueDate: String, + @SerialName("due_date") val dueDate: String?, @SerialName("estimated_cost") val estimatedCost: String? = null, @SerialName("actual_cost") val actualCost: String? = null, val notes: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt index 000b29b..a71906f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt @@ -26,6 +26,7 @@ data class TaskFrequencyResponse( data class TaskFrequency( val id: Int, val name: String, + @SerialName("lookup_name") val lookupName: String, @SerialName("display_name") val displayName: String, @SerialName("day_span") val daySpan: Int? = null, @SerialName("notify_days") val notifyDays: Int? = null diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index 23c9e2e..91b49fb 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -62,7 +62,7 @@ data class EditTaskRoute( val priorityName: String, val statusId: Int?, val statusName: String?, - val dueDate: String, + val dueDate: String?, val estimatedCost: String?, val createdAt: String, val updatedAt: String diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt index c05adb1..cb7d21c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt @@ -31,7 +31,7 @@ fun AddNewTaskDialog( var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) } var frequency by remember { mutableStateOf(TaskFrequency( - id = 0, name = "", displayName = "", + id = 0, name = "", lookupName = "", displayName = "", daySpan = 0, notifyDays = 0 )) } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt index 2a12197..64f801d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskWithResidenceDialog.kt @@ -34,7 +34,7 @@ fun AddNewTaskWithResidenceDialog( var selectedResidenceId by remember { mutableStateOf(residencesResponse.residences.firstOrNull()?.id ?: 0) } var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) } var frequency by remember { mutableStateOf(TaskFrequency( - id = 0, name = "", displayName = "", + id = 0, name = "", lookupName = "", displayName = "", daySpan = 0, notifyDays = 0 )) } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index 4e17444..0b85daa 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -127,7 +127,7 @@ fun TaskCard( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = task.nextScheduledDate ?: task.dueDate, + text = task.nextScheduledDate ?: task.dueDate ?: "N/A", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold ) @@ -345,7 +345,7 @@ fun TaskCardPreview() { category = TaskCategory(id = 1, name = "maintenance", description = ""), priority = TaskPriority(id = 2, name = "medium", displayName = "Medium", description = ""), frequency = TaskFrequency( - id = 1, name = "monthly", displayName = "Monthly", + id = 1, name = "monthly", lookupName = "monthly", displayName = "Monthly", daySpan = 0, notifyDays = 0 ), diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt index 256ad22..54c2804 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditTaskScreen.kt @@ -31,7 +31,7 @@ fun EditTaskScreen( var selectedFrequency by remember { mutableStateOf(task.frequency) } var selectedPriority by remember { mutableStateOf(task.priority) } var selectedStatus by remember { mutableStateOf(task.status) } - var dueDate by remember { mutableStateOf(task.dueDate) } + var dueDate by remember { mutableStateOf(task.dueDate ?: "") } var estimatedCost by remember { mutableStateOf(task.estimatedCost ?: "") } var categoryExpanded by remember { mutableStateOf(false) } @@ -70,7 +70,7 @@ fun EditTaskScreen( titleError = "" } - if (dueDate.isBlank()) { + if (dueDate.isNullOrBlank()) { dueDateError = "Due date is required" isValid = false } else { diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 5dffba8..7ecb1c0 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -43,9 +43,11 @@ struct TaskCard: View { Spacer() - Label(formatDate(task.dueDate), systemImage: "calendar") - .font(.caption) - .foregroundColor(.secondary) + if let due_date = task.dueDate { + Label(formatDate(due_date), systemImage: "calendar") + .font(.caption) + .foregroundColor(.secondary) + } } if task.completions.count > 0 { @@ -169,7 +171,7 @@ struct TaskCard: View { description: "Remove all debris from gutters", category: TaskCategory(id: 1, name: "maintenance", description: ""), priority: TaskPriority(id: 2, name: "medium", displayName: "", description: ""), - frequency: TaskFrequency(id: 1, name: "monthly", displayName: "30", daySpan: 0, notifyDays: 0), + frequency: TaskFrequency(id: 1, name: "monthly", lookupName: "", displayName: "30", daySpan: 0, notifyDays: 0), status: TaskStatus(id: 1, name: "pending", displayName: "", description: ""), dueDate: "2024-12-15", estimatedCost: "150.00", diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index 9ae0f1b..74f4246 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -85,7 +85,7 @@ struct TasksSection: View { description: "Remove all debris", category: TaskCategory(id: 1, name: "maintenance", description: ""), priority: TaskPriority(id: 2, name: "medium", displayName: "Medium", description: ""), - frequency: TaskFrequency(id: 1, name: "monthly", displayName: "Monthly", daySpan: 0, notifyDays: 0), + frequency: TaskFrequency(id: 1, name: "monthly", lookupName: "", displayName: "Monthly", daySpan: 0, notifyDays: 0), status: TaskStatus(id: 1, name: "pending", displayName: "Pending", description: ""), dueDate: "2024-12-15", estimatedCost: "150.00", @@ -115,7 +115,7 @@ struct TasksSection: View { description: "Kitchen sink fixed", category: TaskCategory(id: 2, name: "plumbing", description: ""), priority: TaskPriority(id: 3, name: "high", displayName: "High", description: ""), - frequency: TaskFrequency(id: 6, name: "once", displayName: "One Time", daySpan: 0, notifyDays: 0), + frequency: TaskFrequency(id: 6, name: "once", lookupName: "", displayName: "One Time", daySpan: 0, notifyDays: 0), status: TaskStatus(id: 3, name: "completed", displayName: "Completed", description: ""), dueDate: "2024-11-01", estimatedCost: "200.00", diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index bf78387..1d350af 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -11,21 +11,21 @@ struct AllTasksView: View { @State private var showEditTask = false @State private var selectedTaskForEdit: TaskDetail? @State private var selectedTaskForComplete: TaskDetail? - + private var hasNoTasks: Bool { guard let response = tasksResponse else { return true } return response.columns.allSatisfy { $0.tasks.isEmpty } } - + private var hasTasks: Bool { !hasNoTasks } - + var body: some View { ZStack { Color(.systemGroupedBackground) .ignoresSafeArea() - + if isLoadingTasks { ProgressView() } else if let error = tasksError { @@ -37,20 +37,20 @@ struct AllTasksView: View { // Empty state with big button VStack(spacing: 24) { Spacer() - + Image(systemName: "checklist") .font(.system(size: 64)) .foregroundStyle(.blue.opacity(0.6)) - + Text("No tasks yet") .font(.title2) .fontWeight(.semibold) - + Text("Create your first task to get started") .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) - + Button(action: { showAddTask = true }) { @@ -66,13 +66,13 @@ struct AllTasksView: View { .controlSize(.large) .padding(.horizontal, 48) .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) - + if residenceViewModel.myResidences?.residences.isEmpty ?? true { Text("Add a property first from the Residences tab") .font(.caption) .foregroundColor(.red) } - + Spacer() } .padding() @@ -175,13 +175,13 @@ struct AllTasksView: View { residenceViewModel.loadMyResidences() } } - + private func loadAllTasks() { guard let token = TokenStorage.shared.getToken() else { return } - + isLoadingTasks = true tasksError = nil - + let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient()) taskApi.getTasks(token: token, days: 30) { result, error in if let successResult = result as? ApiResultSuccess { @@ -208,16 +208,16 @@ struct DynamicTaskColumnView: View { let onCompleteTask: (TaskDetail) -> Void let onArchiveTask: (Int32) -> Void let onUnarchiveTask: (Int32) -> Void - + // Get icon from API response, with fallback private var columnIcon: String { column.icons["ios"] ?? "list.bullet" } - + private var columnColor: Color { Color(hex: column.color) ?? .primary } - + var body: some View { VStack(spacing: 0) { ScrollView { @@ -227,13 +227,13 @@ struct DynamicTaskColumnView: View { Image(systemName: columnIcon) .font(.headline) .foregroundColor(columnColor) - + Text(column.displayName) .font(.headline) .foregroundColor(columnColor) - + Spacer() - + Text("\(column.count)") .font(.caption) .fontWeight(.semibold) @@ -243,13 +243,13 @@ struct DynamicTaskColumnView: View { .background(columnColor) .cornerRadius(12) } - + if column.tasks.isEmpty { VStack(spacing: 8) { Image(systemName: columnIcon) .font(.system(size: 40)) .foregroundColor(columnColor.opacity(0.3)) - + Text("No tasks") .font(.caption) .foregroundColor(.secondary) @@ -288,7 +288,7 @@ struct DynamicTaskCard: View { let onComplete: () -> Void let onArchive: () -> Void let onUnarchive: () -> Void - + var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -296,39 +296,41 @@ struct DynamicTaskCard: View { Text(task.title) .font(.headline) .foregroundColor(.primary) - + if let status = task.status { StatusBadge(status: status.name) } } - + Spacer() - + PriorityBadge(priority: task.priority.name) } - + if let description = task.description_, !description.isEmpty { Text(description) .font(.subheadline) .foregroundColor(.secondary) .lineLimit(2) } - + HStack { Label(task.frequency.displayName, systemImage: "repeat") .font(.caption) .foregroundColor(.secondary) - + Spacer() - - Label(formatDate(task.dueDate), systemImage: "calendar") - .font(.caption) - .foregroundColor(.secondary) + + if let due_date = task.dueDate { + Label(formatDate(due_date), systemImage: "calendar") + .font(.caption) + .foregroundColor(.secondary) + } } - + if task.completions.count > 0 { Divider() - + HStack { Image(systemName: "checkmark.circle") .foregroundColor(.green) @@ -337,7 +339,7 @@ struct DynamicTaskCard: View { .foregroundColor(.secondary) } } - + // Render buttons based on buttonTypes array VStack(spacing: 8) { ForEach(Array(buttonTypes.enumerated()), id: \.offset) { index, buttonType in @@ -350,7 +352,7 @@ struct DynamicTaskCard: View { .cornerRadius(12) .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 2) } - + private func formatDate(_ dateString: String) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" @@ -360,7 +362,7 @@ struct DynamicTaskCard: View { } return dateString } - + @ViewBuilder private func renderButton(for buttonType: String) -> some View { switch buttonType { @@ -436,7 +438,7 @@ extension View { struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners - + func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, @@ -505,7 +507,7 @@ extension Color { default: return nil } - + self.init( .sRGB, red: Double(r) / 255, diff --git a/iosApp/iosApp/Task/EditTaskView.swift b/iosApp/iosApp/Task/EditTaskView.swift index c90527e..73c2fa4 100644 --- a/iosApp/iosApp/Task/EditTaskView.swift +++ b/iosApp/iosApp/Task/EditTaskView.swift @@ -31,7 +31,7 @@ struct EditTaskView: View { _selectedFrequency = State(initialValue: task.frequency) _selectedPriority = State(initialValue: task.priority) _selectedStatus = State(initialValue: task.status) - _dueDate = State(initialValue: task.dueDate) + _dueDate = State(initialValue: task.dueDate ?? "") _estimatedCost = State(initialValue: task.estimatedCost ?? "") } diff --git a/iosApp/iosAppUITests/AuthenticationUITests.swift b/iosApp/iosAppUITests/AuthenticationUITests.swift new file mode 100644 index 0000000..001a225 --- /dev/null +++ b/iosApp/iosAppUITests/AuthenticationUITests.swift @@ -0,0 +1,269 @@ +import XCTest + +/// Comprehensive tests for authentication flows +final class AuthenticationUITests: BaseUITest { + + // MARK: - Login Tests + + func testLoginWithValidCredentials() { + // Given: User is on login screen + XCTAssertTrue(app.staticTexts["MyCrib"].exists) + + // When: User enters valid credentials and taps login + login(username: "testuser", password: "TestPass123!") + + // Then: User should be navigated to main screen + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Should navigate to main tab view") + } + + func testLoginWithInvalidCredentials() { + // Given: User is on login screen + XCTAssertTrue(app.staticTexts["MyCrib"].exists) + + // When: User enters invalid credentials + login(username: "invaliduser", password: "WrongPassword!") + + // Then: Error message should be displayed + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Invalid username or password'")) + XCTAssertTrue(errorMessage.firstMatch.waitForExistence(timeout: 5), "Should show error message") + + // And: User should remain on login screen + XCTAssertTrue(app.staticTexts["MyCrib"].exists) + } + + func testLoginWithEmptyFields() { + // Given: User is on login screen + let loginButton = app.buttons["Login"] + + // When: User taps login without entering credentials + loginButton.tap() + + // Then: Validation error should be shown + let usernameError = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Username is required'")) + XCTAssertTrue(usernameError.firstMatch.waitForExistence(timeout: 3), "Should show username required error") + } + + func testPasswordVisibilityToggle() { + // Given: User has typed password + let passwordField = app.secureTextFields["Password"] + let textField = app.textFields["Password"] + let toggleButton = app.buttons.matching(identifier: "eye").firstMatch + + passwordField.tap() + passwordField.typeText("TestPassword") + + // When: User taps the visibility toggle + toggleButton.tap() + + // Then: Password should be visible as text + XCTAssertTrue(textField.exists, "Password should be visible") + + // When: User taps toggle again + toggleButton.tap() + + // Then: Password should be hidden again + XCTAssertTrue(passwordField.exists, "Password should be secure") + } + + // MARK: - Registration Tests + + func testRegistrationWithValidData() { + // Given: User is on login screen + let signUpButton = app.buttons["Sign Up"] + + // When: User taps Sign Up + signUpButton.tap() + + // Then: Registration screen should be displayed + assertNavigatedTo(title: "Create Account", timeout: 3) + + // When: User fills in valid registration data + let timestamp = Int(Date().timeIntervalSince1970) + register( + username: "newuser\(timestamp)", + email: "newuser\(timestamp)@test.com", + password: "TestPass123!", + firstName: "Test", + lastName: "User" + ) + + // Then: User should be registered and shown verification screen + let verificationTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")) + XCTAssertTrue(verificationTitle.firstMatch.waitForExistence(timeout: 10), "Should show verification screen") + } + + func testRegistrationWithExistingUsername() { + // Given: User is on registration screen + let signUpButton = app.buttons["Sign Up"] + signUpButton.tap() + + // When: User registers with existing username + register( + username: "existinguser", + email: "newemail@test.com", + password: "TestPass123!" + ) + + // Then: Error message should be displayed + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'username' OR label CONTAINS[c] 'already exists'")) + XCTAssertTrue(errorMessage.firstMatch.waitForExistence(timeout: 5), "Should show username exists error") + } + + func testRegistrationWithInvalidEmail() { + // Given: User is on registration screen + let signUpButton = app.buttons["Sign Up"] + signUpButton.tap() + + // When: User enters invalid email + let emailField = app.textFields["Email"] + let registerButton = app.buttons["Register"] + + emailField.tap() + emailField.typeText("invalidemail") + + registerButton.tap() + + // Then: Email validation error should be shown + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'valid email'")) + XCTAssertTrue(errorMessage.firstMatch.waitForExistence(timeout: 3), "Should show email validation error") + } + + func testRegistrationWithWeakPassword() { + // Given: User is on registration screen + let signUpButton = app.buttons["Sign Up"] + signUpButton.tap() + + // When: User enters weak password + let timestamp = Int(Date().timeIntervalSince1970) + let usernameField = app.textFields["Username"] + let emailField = app.textFields["Email"] + let passwordField = app.secureTextFields["Password"] + let registerButton = app.buttons["Register"] + + usernameField.tap() + usernameField.typeText("testuser\(timestamp)") + + emailField.tap() + emailField.typeText("test\(timestamp)@test.com") + + passwordField.tap() + passwordField.typeText("weak") + + registerButton.tap() + + // Then: Password validation error should be shown + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'password' AND (label CONTAINS[c] 'strong' OR label CONTAINS[c] 'at least')")) + XCTAssertTrue(errorMessage.firstMatch.waitForExistence(timeout: 3), "Should show password strength error") + } + + // MARK: - Logout Tests + + func testLogoutFlow() { + // Given: User is logged in + login(username: "testuser", password: "TestPass123!") + + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10)) + + // When: User logs out + logout() + + // Then: User should be returned to login screen + XCTAssertTrue(app.staticTexts["MyCrib"].waitForExistence(timeout: 5), "Should return to login screen") + XCTAssertTrue(app.buttons["Login"].exists, "Login button should be visible") + } + + func testLogoutClearsUserData() { + // Given: User is logged in and has viewed some data + login(username: "testuser", password: "TestPass123!") + + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10)) + + navigateToTab("Residences") + wait(seconds: 2) // Wait for data to load + + // When: User logs out + logout() + + // And: User logs back in + login(username: "testuser", password: "TestPass123!") + + // Then: Fresh data should be loaded (not cached) + let loadingIndicator = app.activityIndicators.firstMatch + XCTAssertTrue(loadingIndicator.exists || app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Loading'")).firstMatch.exists, + "Should show loading state for fresh data") + } + + // MARK: - Session Management Tests + + func testSessionPersistence() { + // Given: User logs in + login(username: "testuser", password: "TestPass123!") + + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10)) + + // When: App is terminated and relaunched + app.terminate() + app.launch() + + // Then: User should still be logged in + XCTAssertTrue(residencesTab.waitForExistence(timeout: 5), "User session should persist") + } + + func testLoginRedirectsVerifiedUser() { + // Given: Verified user logs in + login(username: "verifieduser", password: "TestPass123!") + + // Then: User should go directly to main screen (not verification) + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Verified user should skip verification") + } + + func testLoginRedirectsUnverifiedUser() { + // Given: Unverified user logs in + login(username: "unverifieduser", password: "TestPass123!") + + // Then: User should be shown verification screen + let verificationTitle = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")) + XCTAssertTrue(verificationTitle.firstMatch.waitForExistence(timeout: 10), "Unverified user should see verification screen") + } + + // MARK: - Error Handling Tests + + func testLoginWithNetworkError() { + // Note: This test requires network simulation or mocking + // For now, it's a placeholder for future implementation + + // Given: Network is unavailable + // When: User attempts to login + // Then: Network error should be displayed + } + + func testLoginRetryAfterError() { + // Given: User encountered a login error + login(username: "invaliduser", password: "WrongPassword!") + + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Invalid'")) + XCTAssertTrue(errorMessage.firstMatch.waitForExistence(timeout: 5)) + + // When: User enters correct credentials + let usernameField = app.textFields["Username"] + let passwordField = app.secureTextFields["Password"] + let loginButton = app.buttons["Login"] + + app.clearText(in: usernameField) + usernameField.typeText("testuser") + + app.clearText(in: passwordField) + passwordField.typeText("TestPass123!") + + loginButton.tap() + + // Then: Login should succeed + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10), "Should login successfully after retry") + } +} diff --git a/iosApp/iosAppUITests/MultiUserUITests.swift b/iosApp/iosAppUITests/MultiUserUITests.swift new file mode 100644 index 0000000..12dc3ef --- /dev/null +++ b/iosApp/iosAppUITests/MultiUserUITests.swift @@ -0,0 +1,470 @@ +import XCTest + +/// Comprehensive tests for multi-user residence features +final class MultiUserUITests: BaseUITest { + + override func setUp() { + super.setUp() + // Login as primary owner + login(username: "testowner", password: "TestPass123!") + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10)) + } + + // MARK: - Manage Users Tests + + func testManageUsersButtonVisibleForOwner() { + // Given: User owns a residence + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // Then: Manage users button should be visible + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + XCTAssertTrue(manageUsersButton.exists, "Owner should see manage users button") + } + } + + func testManageUsersButtonHiddenForSharedUser() { + // Given: User is a shared user (not owner) + logout() + login(username: "shareduser", password: "TestPass123!") + + navigateToTab("Residences") + wait(seconds: 2) + + // Find a shared residence + let sharedResidence = app.cells.firstMatch + if sharedResidence.exists { + sharedResidence.tap() + wait(seconds: 2) + + // Then: Manage users button should NOT be visible + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + XCTAssertFalse(manageUsersButton.exists, "Shared user should not see manage users button") + } + } + + func testOpenManageUsersScreen() { + // Given: Owner is viewing a residence + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // When: Owner taps manage users button + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // Then: Manage users screen should be displayed + let manageUsersTitle = app.navigationBars["Manage Users"] + XCTAssertTrue(manageUsersTitle.waitForExistence(timeout: 3), "Should show manage users screen") + + // And: User list should be visible + let usersList = app.scrollViews.firstMatch + XCTAssertTrue(usersList.exists, "Should show users list") + } + } + } + + // MARK: - Share Code Tests + + func testShareCodeInitiallyBlank() { + // Given: Owner opens manage users screen + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // Then: Share code should be blank initially + let noActiveCode = app.staticTexts["No active code"] + XCTAssertTrue(noActiveCode.exists, "Share code should start blank") + } + } + } + + func testGenerateShareCode() { + // Given: Owner is on manage users screen + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // When: Owner taps generate code button + let generateButton = app.buttons["Generate"] + if !generateButton.exists { + // Button might say "New Code" if there's an existing code + let newCodeButton = app.buttons["New Code"] + if newCodeButton.exists { + newCodeButton.tap() + } + } else { + generateButton.tap() + } + + // Then: Share code should be generated and displayed + wait(seconds: 2) + let shareCodeTexts = app.staticTexts.matching(NSPredicate(format: "label.length == 6 AND label MATCHES %@", "[A-Z0-9]{6}")) + XCTAssertTrue(shareCodeTexts.count > 0, "Should display 6-character share code") + } + } + } + + func testRegenerateShareCode() { + // Given: Owner has generated a share code + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // Generate first code + let generateButton = app.buttons["Generate"] + if generateButton.exists { + generateButton.tap() + wait(seconds: 2) + + let firstCode = app.staticTexts.matching(NSPredicate(format: "label.length == 6 AND label MATCHES %@", "[A-Z0-9]{6}")).firstMatch.label + + // When: Owner generates new code + let newCodeButton = app.buttons["New Code"] + if newCodeButton.exists { + newCodeButton.tap() + wait(seconds: 2) + + // Then: A different code should be generated + let secondCode = app.staticTexts.matching(NSPredicate(format: "label.length == 6 AND label MATCHES %@", "[A-Z0-9]{6}")).firstMatch.label + XCTAssertNotEqual(firstCode, secondCode, "New code should be different") + } + } + } + } + } + + // MARK: - Join Residence Tests + + func testJoinResidenceWithValidCode() { + // This test requires coordination between two accounts + // Given: Owner generates a share code + var shareCode: String = "" + + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + let generateButton = app.buttons["Generate"] + if generateButton.exists { + generateButton.tap() + wait(seconds: 2) + + shareCode = app.staticTexts.matching(NSPredicate(format: "label.length == 6 AND label MATCHES %@", "[A-Z0-9]{6}")).firstMatch.label + } + + // Close manage users screen + let closeButton = app.buttons["Close"] + if closeButton.exists { + closeButton.tap() + } + } + } + + // When: Different user joins with code + if !shareCode.isEmpty { + logout() + login(username: "newuser", password: "TestPass123!") + + navigateToTab("Residences") + wait(seconds: 1) + + // Find join residence button + let joinButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Join'")).firstMatch + if joinButton.exists { + joinButton.tap() + wait(seconds: 1) + + // Enter share code + let codeField = app.textFields.firstMatch + if codeField.exists { + codeField.tap() + codeField.typeText(shareCode) + + // Submit + let submitButton = app.buttons["Join"] + if submitButton.exists { + submitButton.tap() + wait(seconds: 2) + + // Then: User should be added to residence + let successMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Successfully joined'")) + XCTAssertTrue(successMessage.firstMatch.exists || app.cells.count > 0, + "Should join residence successfully") + } + } + } + } + } + + func testJoinResidenceWithInvalidCode() { + // Given: User is on join residence screen + navigateToTab("Residences") + + let joinButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Join'")).firstMatch + if joinButton.exists { + joinButton.tap() + wait(seconds: 1) + + // When: User enters invalid code + let codeField = app.textFields.firstMatch + if codeField.exists { + codeField.tap() + codeField.typeText("INVALID") + + let submitButton = app.buttons["Join"] + if submitButton.exists { + submitButton.tap() + wait(seconds: 2) + + // Then: Error message should be shown + let errorMessage = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Invalid' OR label CONTAINS[c] 'not found'")) + XCTAssertTrue(errorMessage.firstMatch.exists, "Should show invalid code error") + } + } + } + } + + // MARK: - User List Tests + + func testUserListShowsAllUsers() { + // Given: Residence has multiple users + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // Then: User count should be displayed + let userCountLabel = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'Users'")) + XCTAssertTrue(userCountLabel.count > 0, "Should show user count") + + // And: Individual users should be listed + let usersList = app.scrollViews.firstMatch + XCTAssertTrue(usersList.exists, "Should show users list") + } + } + } + + func testOwnerLabelDisplayed() { + // Given: Owner is viewing manage users screen + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // Then: Owner badge should be visible next to owner's name + let ownerBadge = app.staticTexts["Owner"] + XCTAssertTrue(ownerBadge.exists, "Should show Owner badge") + } + } + } + + // MARK: - Remove User Tests + + func testRemoveUserAsOwner() { + // Given: Owner has residence with multiple users + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // When: Owner taps remove button on a user + let removeButtons = app.buttons.matching(identifier: "trash") + if removeButtons.count > 0 { + let initialUserCount = removeButtons.count + removeButtons.firstMatch.tap() + + // Confirm removal if prompted + let confirmButton = app.alerts.buttons["Remove"] + if confirmButton.exists { + confirmButton.tap() + } + + // Then: User should be removed + wait(seconds: 2) + let newUserCount = app.buttons.matching(identifier: "trash").count + XCTAssertTrue(newUserCount < initialUserCount, "Should remove user") + } + } + } + } + + func testCannotRemoveOwner() { + // Given: Owner is viewing manage users + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + // Then: Owner row should NOT have remove button + let ownerLabel = app.staticTexts["Owner"] + if ownerLabel.exists { + // Check if there's a remove button in the same container + // Owner should not have a remove button next to their name + } + } + } + } + + // MARK: - Shared User Access Tests + + func testSharedUserCanViewResidence() { + // Given: User is a shared user + logout() + login(username: "shareduser", password: "TestPass123!") + + // Then: Shared residences should appear in list + navigateToTab("Residences") + wait(seconds: 2) + + let residencesList = app.cells.count + XCTAssertTrue(residencesList > 0, "Shared user should see shared residences") + } + + func testSharedUserCanCreateTasks() { + // Given: Shared user is viewing a shared residence + logout() + login(username: "shareduser", password: "TestPass123!") + + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // When: Shared user tries to create a task + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + XCTAssertTrue(addButton.exists, "Shared user should be able to add tasks") + + if addButton.exists { + addButton.tap() + wait(seconds: 1) + + // Then: Add task form should be displayed + let titleField = app.textFields.firstMatch + XCTAssertTrue(titleField.exists, "Shared user should be able to create tasks") + } + } + } + + func testSharedUserCanEditTasks() { + // Given: Shared user is viewing tasks + logout() + login(username: "shareduser", password: "TestPass123!") + + navigateToTab("Tasks") + wait(seconds: 2) + + // When: Shared user tries to edit a task + let editButtons = app.buttons.matching(identifier: "pencil") + if editButtons.count > 0 { + // Then: Edit buttons should be available + XCTAssertTrue(editButtons.firstMatch.exists, "Shared user should be able to edit tasks") + } + } + + func testUserCountDisplayed() { + // Given: Owner is viewing a residence + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // Then: User count should be visible somewhere on the screen + // This depends on your UI design - adjust as needed + let manageUsersButton = app.navigationBars.buttons.matching(identifier: "person.2").firstMatch + if manageUsersButton.exists { + manageUsersButton.tap() + wait(seconds: 1) + + let userCountText = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'Users'")) + XCTAssertTrue(userCountText.count > 0, "Should display user count") + } + } + } +} diff --git a/iosApp/iosAppUITests/ResidenceUITests.swift b/iosApp/iosAppUITests/ResidenceUITests.swift new file mode 100644 index 0000000..7c74c50 --- /dev/null +++ b/iosApp/iosAppUITests/ResidenceUITests.swift @@ -0,0 +1,346 @@ +import XCTest + +/// Comprehensive tests for residence management +final class ResidenceUITests: BaseUITest { + + override func setUp() { + super.setUp() + // Login before each test + login(username: "testuser", password: "TestPass123!") + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10)) + } + + // MARK: - List View Tests + + func testResidenceListDisplays() { + // Given: User is on residences tab + navigateToTab("Residences") + + // Then: Residences list should be displayed + let navigationBar = app.navigationBars["Residences"] + XCTAssertTrue(navigationBar.exists, "Should show residences navigation bar") + } + + func testResidenceListShowsProperties() { + // Given: User has residences + navigateToTab("Residences") + + // Then: Residence cards should be visible + let residenceCards = app.scrollViews.descendants(matching: .other).matching(NSPredicate(format: "identifier CONTAINS 'ResidenceCard'")) + XCTAssertTrue(residenceCards.count > 0 || app.staticTexts["No residences yet"].exists, + "Should show either residence cards or empty state") + } + + func testEmptyStateDisplays() { + // Given: User has no residences (requires test account with no data) + navigateToTab("Residences") + + // Then: Empty state should be shown + // Note: This test assumes test user has no residences + let emptyStateText = app.staticTexts["No residences yet"] + let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add'")).firstMatch + + // Should show either residences or empty state with add button + XCTAssertTrue(emptyStateText.exists || app.cells.count > 0, "Should show content or empty state") + } + + // MARK: - Create Residence Tests + + func testCreateResidenceFlow() { + // Given: User is on residences screen + navigateToTab("Residences") + + // When: User taps add residence button + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + if !addButton.exists { + // Try finding add button in other locations + let fabButton = app.buttons["Add Residence"] + if fabButton.exists { + fabButton.tap() + } else { + XCTFail("Could not find add residence button") + } + } else { + addButton.tap() + } + + // Then: Add residence form should be displayed + wait(seconds: 1) + let nameField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'name' OR label CONTAINS[c] 'Name'")).firstMatch + XCTAssertTrue(nameField.exists, "Should show residence name field") + + // When: User fills in residence details + let timestamp = Int(Date().timeIntervalSince1970) + nameField.tap() + nameField.typeText("Test House \(timestamp)") + + let addressField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'address' OR label CONTAINS[c] 'Address'")).firstMatch + if addressField.exists { + addressField.tap() + addressField.typeText("123 Test Street") + } + + let cityField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'city' OR label CONTAINS[c] 'City'")).firstMatch + if cityField.exists { + cityField.tap() + cityField.typeText("Test City") + } + + // When: User saves the residence + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: User should be returned to list view + // And: New residence should appear in the list + wait(seconds: 2) + let residenceTitle = app.staticTexts["Test House \(timestamp)"] + XCTAssertTrue(residenceTitle.waitForExistence(timeout: 5) || app.navigationBars["Residences"].exists, + "Should show new residence or navigate back to list") + } + + func testCreateResidenceValidation() { + // Given: User is on add residence screen + navigateToTab("Residences") + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + addButton.tap() + + wait(seconds: 1) + + // When: User attempts to save without required fields + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Validation errors should be shown + let errorMessages = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required'")) + XCTAssertTrue(errorMessages.count > 0 || !app.navigationBars["Residences"].exists, + "Should show validation errors or prevent saving") + } + + // MARK: - View Residence Details Tests + + func testViewResidenceDetails() { + // Given: User has residences + navigateToTab("Residences") + wait(seconds: 2) + + // When: User taps on a residence + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + + // Then: Residence details should be displayed + wait(seconds: 2) + let detailView = app.scrollViews.firstMatch + XCTAssertTrue(detailView.exists || app.navigationBars.element(boundBy: 1).exists, + "Should show residence details") + } + } + + func testResidenceDetailsShowsAllSections() { + // Given: User is viewing residence details + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // Then: All sections should be visible (after scrolling) + let scrollView = app.scrollViews.firstMatch + if scrollView.exists { + // Check for address section + let addressSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Address'")).firstMatch + + // Check for tasks section + scrollView.swipeUp() + let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch + + XCTAssertTrue(addressSection.exists || tasksSection.exists, "Should show residence sections") + } + } + } + + // MARK: - Edit Residence Tests + + func testEditResidenceFlow() { + // Given: User is viewing residence details + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // When: User taps edit button + let editButton = app.navigationBars.buttons["Edit"] + if editButton.exists { + editButton.tap() + wait(seconds: 1) + + // Then: Edit form should be displayed + let nameField = app.textFields.containing(NSPredicate(format: "value != nil AND value != ''")).firstMatch + XCTAssertTrue(nameField.exists, "Should show edit form with current values") + + // When: User updates the name + if nameField.exists { + app.clearText(in: nameField) + nameField.typeText("Updated House Name") + + // When: User saves changes + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Changes should be saved and details view updated + wait(seconds: 2) + let updatedName = app.staticTexts["Updated House Name"] + XCTAssertTrue(updatedName.waitForExistence(timeout: 5) || app.navigationBars.element(boundBy: 1).exists, + "Should show updated residence name or details view") + } + } + } + } + + func testCancelEditingResidence() { + // Given: User is editing a residence + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + let editButton = app.navigationBars.buttons["Edit"] + if editButton.exists { + editButton.tap() + wait(seconds: 1) + + // When: User makes changes + let nameField = app.textFields.firstMatch + let originalValue = nameField.value as? String + + if nameField.exists { + app.clearText(in: nameField) + nameField.typeText("Temporary Change") + + // When: User cancels + let cancelButton = app.buttons["Cancel"] + if cancelButton.exists { + cancelButton.tap() + } + + // Then: Changes should be discarded + wait(seconds: 1) + if let original = originalValue { + let originalText = app.staticTexts[original] + XCTAssertTrue(originalText.exists || app.navigationBars.element(boundBy: 1).exists, + "Should discard changes") + } + } + } + } + } + + // MARK: - Delete Residence Tests + + func testDeleteResidence() { + // Given: User has a residence to delete + navigateToTab("Residences") + wait(seconds: 2) + + let initialResidenceCount = app.cells.count + + // When: User swipes to delete (if supported) + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.swipeLeft() + + let deleteButton = app.buttons["Delete"] + if deleteButton.exists { + deleteButton.tap() + + // Confirm deletion if alert appears + let confirmButton = app.alerts.buttons["Delete"] + if confirmButton.exists { + confirmButton.tap() + } + + // Then: Residence should be removed + wait(seconds: 2) + let newCount = app.cells.count + XCTAssertTrue(newCount < initialResidenceCount || app.staticTexts["No residences yet"].exists, + "Should remove residence from list") + } + } + } + + // MARK: - Navigation Tests + + func testNavigateBackFromDetails() { + // Given: User is viewing residence details + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // When: User taps back button + navigateBack() + + // Then: User should return to residences list + let navigationBar = app.navigationBars["Residences"] + XCTAssertTrue(navigationBar.waitForExistence(timeout: 3), "Should navigate back to residences list") + } + } + + // MARK: - Search and Filter Tests (if implemented) + + func testSearchResidences() { + // Given: User has multiple residences + navigateToTab("Residences") + wait(seconds: 2) + + // When: User searches for a residence + let searchField = app.searchFields.firstMatch + if searchField.exists { + searchField.tap() + searchField.typeText("Test") + + // Then: Results should be filtered + wait(seconds: 1) + let visibleResidences = app.cells.count + XCTAssertTrue(visibleResidences >= 0, "Should show filtered results") + } + } + + // MARK: - Pull to Refresh Tests + + func testPullToRefreshResidences() { + // Given: User is on residences list + navigateToTab("Residences") + wait(seconds: 2) + + // When: User pulls down to refresh + let scrollView = app.scrollViews.firstMatch + if scrollView.exists { + let startPoint = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) + let endPoint = scrollView.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + startPoint.press(forDuration: 0.1, thenDragTo: endPoint) + + // Then: Loading indicator should appear + let loadingIndicator = app.activityIndicators.firstMatch + XCTAssertTrue(loadingIndicator.exists || scrollView.exists, "Should trigger refresh") + } + } +} diff --git a/iosApp/iosAppUITests/TaskUITests.swift b/iosApp/iosAppUITests/TaskUITests.swift new file mode 100644 index 0000000..1603f33 --- /dev/null +++ b/iosApp/iosAppUITests/TaskUITests.swift @@ -0,0 +1,431 @@ +import XCTest + +/// Comprehensive tests for task management +final class TaskUITests: BaseUITest { + + override func setUp() { + super.setUp() + // Login before each test + login(username: "testuser", password: "TestPass123!") + let residencesTab = app.tabBars.buttons["Residences"] + XCTAssertTrue(residencesTab.waitForExistence(timeout: 10)) + } + + // MARK: - Task List Tests + + func testTasksTabDisplays() { + // When: User navigates to tasks tab + navigateToTab("Tasks") + + // Then: Tasks screen should be displayed + let navigationBar = app.navigationBars["All Tasks"] + XCTAssertTrue(navigationBar.waitForExistence(timeout: 5), "Should show tasks navigation bar") + } + + func testTaskColumnsDisplay() { + // Given: User is on tasks tab + navigateToTab("Tasks") + wait(seconds: 2) + + // Then: Task columns should be visible + let upcomingLabel = app.staticTexts["Upcoming"] + let inProgressLabel = app.staticTexts["In Progress"] + let doneLabel = app.staticTexts["Done"] + + XCTAssertTrue(upcomingLabel.exists || inProgressLabel.exists || doneLabel.exists || + app.staticTexts["No tasks yet"].exists, + "Should show task columns or empty state") + } + + func testEmptyTasksState() { + // Given: User has no tasks (requires account with no tasks) + navigateToTab("Tasks") + wait(seconds: 2) + + // Then: Empty state should be shown + let emptyState = app.staticTexts["No tasks yet"] + let addButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Add Task'")).firstMatch + + // Should show either tasks or empty state + XCTAssertTrue(emptyState.exists || app.scrollViews.firstMatch.exists, + "Should show content or empty state") + } + + // MARK: - Create Task Tests + + func testCreateTaskFromTasksTab() { + // Given: User is on tasks tab + navigateToTab("Tasks") + + // When: User taps add task button + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + if addButton.exists { + addButton.tap() + wait(seconds: 1) + + // Then: Add task form should be displayed + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'title' OR label CONTAINS[c] 'Title'")).firstMatch + XCTAssertTrue(titleField.exists, "Should show add task form") + + // When: User fills in task details + let timestamp = Int(Date().timeIntervalSince1970) + titleField.tap() + titleField.typeText("Test Task \(timestamp)") + + let descriptionField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'description' OR label CONTAINS[c] 'Description'")).firstMatch + if descriptionField.exists { + descriptionField.tap() + descriptionField.typeText("Test task description") + } + + // When: User saves the task + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Task should be created and user returned to tasks list + wait(seconds: 2) + XCTAssertTrue(app.navigationBars["All Tasks"].exists || app.staticTexts["Test Task \(timestamp)"].exists, + "Should create task and return to list") + } + } + + func testCreateTaskFromResidenceDetails() { + // Given: User is viewing a residence + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // When: User taps add task button + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + if addButton.exists { + addButton.tap() + wait(seconds: 1) + + // Then: Add task form should be displayed + let titleField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'title' OR label CONTAINS[c] 'Title'")).firstMatch + XCTAssertTrue(titleField.exists, "Should show add task form from residence") + + // When: User creates the task + let timestamp = Int(Date().timeIntervalSince1970) + titleField.tap() + titleField.typeText("Residence Task \(timestamp)") + + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Task should be created + wait(seconds: 2) + XCTAssertTrue(app.staticTexts["Residence Task \(timestamp)"].exists || app.navigationBars.element(boundBy: 1).exists, + "Should create task for residence") + } + } + } + + func testCreateTaskValidation() { + // Given: User is on add task form + navigateToTab("Tasks") + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + if addButton.exists { + addButton.tap() + wait(seconds: 1) + + // When: User tries to save without required fields + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Validation errors should be shown + let errorMessages = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'required'")) + XCTAssertTrue(errorMessages.count > 0 || !app.navigationBars["All Tasks"].exists, + "Should show validation errors") + } + } + + // MARK: - View Task Details Tests + + func testViewTaskDetails() { + // Given: User has tasks + navigateToTab("Tasks") + wait(seconds: 2) + + // When: User taps on a task card + let taskCard = app.otherElements.containing(NSPredicate(format: "identifier CONTAINS 'TaskCard'")).firstMatch + if !taskCard.exists { + // Try finding by task title + let taskTitle = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] 'task'")).firstMatch + if taskTitle.exists { + taskTitle.tap() + } + } else { + taskCard.tap() + } + + // Note: Depending on implementation, tapping a task might show details or edit form + wait(seconds: 1) + // Verify some form of task interaction occurred + } + + // MARK: - Edit Task Tests + + func testEditTaskFlow() { + // Given: User is viewing/editing a task + navigateToTab("Tasks") + wait(seconds: 2) + + // Find and tap edit button on a task + let editButtons = app.buttons.matching(identifier: "pencil") + if editButtons.count > 0 { + editButtons.firstMatch.tap() + wait(seconds: 1) + + // When: User modifies task details + let titleField = app.textFields.containing(NSPredicate(format: "value != nil AND value != ''")).firstMatch + if titleField.exists { + app.clearText(in: titleField) + titleField.typeText("Updated Task Title") + + // When: User saves changes + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Changes should be saved + wait(seconds: 2) + let updatedTitle = app.staticTexts["Updated Task Title"] + XCTAssertTrue(updatedTitle.waitForExistence(timeout: 5) || app.navigationBars["All Tasks"].exists, + "Should save task changes") + } + } + } + + // MARK: - Complete Task Tests + + func testCompleteTaskFlow() { + // Given: User has an incomplete task + navigateToTab("Tasks") + wait(seconds: 2) + + // When: User taps complete button on a task + let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete'")).firstMatch + if completeButton.exists { + completeButton.tap() + wait(seconds: 1) + + // Then: Complete task dialog should be shown + let completionDialog = app.sheets.firstMatch + XCTAssertTrue(completionDialog.exists || app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Complete'")).firstMatch.exists, + "Should show completion dialog") + + // When: User confirms completion + let confirmButton = app.buttons["Complete"] + if confirmButton.exists { + confirmButton.tap() + } + + // Then: Task should be marked as complete + wait(seconds: 2) + // Task should move to completed column or show completion status + } + } + + func testCompleteTaskWithDetails() { + // Given: User is completing a task + navigateToTab("Tasks") + wait(seconds: 2) + + let completeButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Complete'")).firstMatch + if completeButton.exists { + completeButton.tap() + wait(seconds: 1) + + // When: User adds completion details + let notesField = app.textViews.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'notes' OR label CONTAINS[c] 'Notes'")).firstMatch + if notesField.exists { + notesField.tap() + notesField.typeText("Task completed successfully") + } + + let costField = app.textFields.containing(NSPredicate(format: "placeholderValue CONTAINS[c] 'cost' OR label CONTAINS[c] 'Cost'")).firstMatch + if costField.exists { + costField.tap() + costField.typeText("100") + } + + // When: User saves completion + let saveButton = app.buttons["Save"] + if saveButton.exists { + saveButton.tap() + } + + // Then: Task should be completed with details + wait(seconds: 2) + } + } + + // MARK: - Task Status Changes Tests + + func testMarkTaskInProgress() { + // Given: User has a pending task + navigateToTab("Tasks") + wait(seconds: 2) + + // When: User marks task as in progress + let inProgressButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'In Progress'")).firstMatch + if inProgressButton.exists { + inProgressButton.tap() + wait(seconds: 2) + + // Then: Task should move to In Progress column + let inProgressColumn = app.staticTexts["In Progress"] + XCTAssertTrue(inProgressColumn.exists, "Should have In Progress column") + } + } + + func testCancelTask() { + // Given: User has an active task + navigateToTab("Tasks") + wait(seconds: 2) + + // When: User cancels a task + let cancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Cancel'")).firstMatch + if cancelButton.exists { + cancelButton.tap() + + // Confirm cancellation if prompted + let confirmButton = app.alerts.buttons["Cancel Task"] + if confirmButton.exists { + confirmButton.tap() + } + + // Then: Task should be cancelled + wait(seconds: 2) + } + } + + func testUncancelTask() { + // Given: User has a cancelled task + navigateToTab("Tasks") + wait(seconds: 2) + + // When: User uncancels a task + let uncancelButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Restore'")).firstMatch + if uncancelButton.exists { + uncancelButton.tap() + wait(seconds: 2) + + // Then: Task should be restored + } + } + + // MARK: - Archive Task Tests + + func testArchiveTask() { + // Given: User has a completed task + navigateToTab("Tasks") + wait(seconds: 2) + + // When: User archives a task + let archiveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Archive'")).firstMatch + if archiveButton.exists { + archiveButton.tap() + wait(seconds: 2) + + // Then: Task should be archived (moved to archived column or hidden) + let archivedColumn = app.staticTexts["Archived"] + // Task may be in archived column or removed from view + } + } + + func testUnarchiveTask() { + // Given: User has archived tasks + navigateToTab("Tasks") + wait(seconds: 2) + + // Scroll to archived column if it exists + let scrollView = app.scrollViews.firstMatch + if scrollView.exists { + scrollView.swipeLeft() + scrollView.swipeLeft() + } + + // When: User unarchives a task + let unarchiveButton = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Unarchive'")).firstMatch + if unarchiveButton.exists { + unarchiveButton.tap() + wait(seconds: 2) + + // Then: Task should be restored from archive + } + } + + // MARK: - Task Filtering and Viewing Tests + + func testSwipeBetweenTaskColumns() { + // Given: User is viewing tasks + navigateToTab("Tasks") + wait(seconds: 2) + + let scrollView = app.scrollViews.firstMatch + if scrollView.exists { + // When: User swipes to view different columns + scrollView.swipeLeft() + wait(seconds: 0.5) + + // Then: Next column should be visible + scrollView.swipeLeft() + wait(seconds: 0.5) + + // User can navigate between columns + scrollView.swipeRight() + wait(seconds: 0.5) + } + } + + func testTasksByResidence() { + // Given: User is viewing a residence + navigateToTab("Residences") + wait(seconds: 2) + + let firstResidence = app.cells.firstMatch + if firstResidence.exists { + firstResidence.tap() + wait(seconds: 2) + + // Then: Tasks for that residence should be shown + let tasksSection = app.staticTexts["Tasks"] + XCTAssertTrue(tasksSection.exists || app.scrollViews.firstMatch.exists, + "Should show tasks section in residence details") + } + } + + // MARK: - Task Recurrence Tests + + func testCreateRecurringTask() { + // Given: User is creating a new task + navigateToTab("Tasks") + let addButton = app.navigationBars.buttons.matching(identifier: "plus").firstMatch + + if addButton.exists { + addButton.tap() + wait(seconds: 1) + + // When: User selects recurring frequency + let frequencyPicker = app.pickers.firstMatch + if frequencyPicker.exists { + // Select a frequency (e.g., Monthly) + let monthlyOption = app.pickerWheels.element.adjust(toPickerWheelValue: "Monthly") + // Task creation with recurrence + } + } + } +} diff --git a/iosApp/iosAppUITests/TestHelpers.swift b/iosApp/iosAppUITests/TestHelpers.swift new file mode 100644 index 0000000..9580561 --- /dev/null +++ b/iosApp/iosAppUITests/TestHelpers.swift @@ -0,0 +1,180 @@ +import XCTest + +/// Helper extensions and utilities for UI tests +extension XCUIApplication { + /// Launch the app and reset to initial state + func launchAndReset() { + launchArguments = ["--uitesting"] + launch() + } + + /// Clear all text from a text field + func clearText(in textField: XCUIElement) { + textField.tap() + textField.press(forDuration: 1.2) + menuItems["Select All"].tap() + textField.typeText(XCUIKeyboardKey.delete.rawValue) + } +} + +/// Base test class with common setup and teardown +class BaseUITest: XCTestCase { + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + app = XCUIApplication() + app.launchAndReset() + } + + override func tearDown() { + app = nil + super.tearDown() + } + + // MARK: - Authentication Helpers + + func login(username: String, password: String) { + let usernameField = app.textFields["Username"] + let passwordField = app.secureTextFields["Password"] + let loginButton = app.buttons["Login"] + + if usernameField.exists { + usernameField.tap() + usernameField.typeText(username) + } + + if passwordField.exists { + passwordField.tap() + passwordField.typeText(password) + } + + if loginButton.exists { + loginButton.tap() + } + } + + func logout() { + let profileTab = app.tabBars.buttons["Profile"] + profileTab.tap() + + let logoutButton = app.buttons["Log Out"] + XCTAssertTrue(logoutButton.waitForExistence(timeout: 5)) + logoutButton.tap() + } + + func register(username: String, email: String, password: String, firstName: String = "", lastName: String = "") { + let signUpButton = app.buttons["Sign Up"] + XCTAssertTrue(signUpButton.waitForExistence(timeout: 3)) + signUpButton.tap() + + let usernameField = app.textFields["Username"] + let emailField = app.textFields["Email"] + let passwordField = app.secureTextFields["Password"] + let registerButton = app.buttons["Register"] + + usernameField.tap() + usernameField.typeText(username) + + emailField.tap() + emailField.typeText(email) + + passwordField.tap() + passwordField.typeText(password) + + if !firstName.isEmpty { + let firstNameField = app.textFields["First Name"] + firstNameField.tap() + firstNameField.typeText(firstName) + } + + if !lastName.isEmpty { + let lastNameField = app.textFields["Last Name"] + lastNameField.tap() + lastNameField.typeText(lastName) + } + + registerButton.tap() + } + + // MARK: - Navigation Helpers + + func navigateToTab(_ tabName: String) { + let tab = app.tabBars.buttons[tabName] + XCTAssertTrue(tab.waitForExistence(timeout: 3)) + tab.tap() + } + + func navigateBack() { + app.navigationBars.buttons.element(boundBy: 0).tap() + } + + // MARK: - Assertion Helpers + + func assertElementExists(_ identifier: String, timeout: TimeInterval = 5) { + let element = app.descendants(matching: .any).matching(identifier: identifier).firstMatch + XCTAssertTrue(element.waitForExistence(timeout: timeout), "Element '\(identifier)' does not exist") + } + + func assertElementDoesNotExist(_ identifier: String, timeout: TimeInterval = 2) { + let element = app.descendants(matching: .any).matching(identifier: identifier).firstMatch + XCTAssertFalse(element.waitForExistence(timeout: timeout), "Element '\(identifier)' should not exist") + } + + func assertNavigatedTo(title: String, timeout: TimeInterval = 5) { + let navigationBar = app.navigationBars[title] + XCTAssertTrue(navigationBar.waitForExistence(timeout: timeout), "Did not navigate to '\(title)'") + } + + // MARK: - Wait Helpers + + func wait(seconds: TimeInterval) { + Thread.sleep(forTimeInterval: seconds) + } + + func waitForElementToAppear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool { + return element.waitForExistence(timeout: timeout) + } + + func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + return result == .completed + } +} + +/// Identifiers for UI elements (for better testability) +struct Identifiers { + struct Authentication { + static let usernameField = "UsernameField" + static let passwordField = "PasswordField" + static let loginButton = "LoginButton" + static let registerButton = "RegisterButton" + static let logoutButton = "LogoutButton" + } + + struct Residence { + static let addButton = "AddResidenceButton" + static let editButton = "EditResidenceButton" + static let deleteButton = "DeleteResidenceButton" + static let nameField = "ResidenceNameField" + static let addressField = "ResidenceAddressField" + } + + struct Task { + static let addButton = "AddTaskButton" + static let completeButton = "CompleteTaskButton" + static let editButton = "EditTaskButton" + static let titleField = "TaskTitleField" + static let descriptionField = "TaskDescriptionField" + } + + struct MultiUser { + static let manageUsersButton = "ManageUsersButton" + static let generateCodeButton = "GenerateCodeButton" + static let joinButton = "JoinResidenceButton" + static let shareCodeField = "ShareCodeField" + } +}