Files
honeyDueKMP/iosApp/HoneyDueUITests/Task/TaskCRUDUITests.swift
T
Trey T c52ce4d497 Re-architect iOS XCUITest suite: per-test isolation + domain organization
Migrate the XCUITest suite off the legacy shared-account model (and the
prior Django-style auth assumptions) to a parallel-safe, domain-organized
architecture, validated end-to-end against the live Kratos stack.

Isolation (parallel-safe by construction):
- Core/Fixtures/TestAccount.swift: each test mints its own pre-verified
  Kratos identity (uit_<domain>_<uuid>@test.honeydue.local), logs in, seeds
  under its own token, and deletes the identity in teardown (cascading all
  data + clearing Kratos). No shared testuser; parallel workers no longer race.
- AuthenticatedUITestCase rewritten to that model (member surface preserved);
  adds requiresResidence / seedAccountPreconditions to seed UI-gated data
  BEFORE login (a fresh account is empty at login).

Organization (255 tests preserved, none dropped):
- 21 domain suites under Auth/ Onboarding/ Residence/ Task/ Contractor/
  Document/ Sharing/ Navigation/ Smoke/ CrossCutting/ E2E/, consistent
  <Domain>UITests naming. Removes the Suite1..11 / AAA_ / ZZ_ / Tests/Rebuild
  naming chaos and the overlapping task/residence/auth suites.

Runner + test plans:
- run_ui_tests.sh: Smoke gate -> Seed -> Parallel(8 workers) -> Sweep. The
  parallel phase runs the whole target minus phase-managed suites via
  -skip-testing, so new suites auto-include (no hand-maintained list to drift).
  Drops the 2-worker cap and Suite6 isolation (isolation made them moot).
- HoneyDueUITests.xctestplan skips the 4 phase-managed suites; adds Smoke.xctestplan.

Kratos auth fixes folded in (login/verify/reset endpoints removed under Kratos):
real Mailpit verification codes replace the obsolete fixed "123456"; teardown
deletes Kratos identities; admin-panel login uses the correct seeded password.

Build green; isolation, parallelism, and the precondition/sharing migrations
validated against the live stack (0 leaked accounts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:26:50 -05:00

410 lines
18 KiB
Swift

import XCTest
/// Task create/read/update/delete UI tests.
///
/// Merged from the former `Suite5_TaskTests` and `Tests/TaskIntegrationTests`.
/// Per-test isolation is provided by `AuthenticatedUITestCase`: every test mints
/// a fresh account, logs in, and tears it down. Task creation gates on a residence
/// existing, so `requiresResidence` seeds one BEFORE login (the fresh account is
/// otherwise empty and the Add-Task button would stay disabled).
///
/// Tests that must SEE a pre-existing task (uncancel flows) seed that task in
/// `seedAccountPreconditions` so the app loads it on its post-login fetch.
final class TaskCRUDUITests: AuthenticatedUITestCase {
// Task creation gates on a residence existing; seed one before login so the
// fresh account's app sees it (otherwise the Add-Task button stays disabled).
override var requiresResidence: Bool { true }
// MARK: - Preconditions
/// Cancelled task seeded before login for the uncancel flows. A fresh account
/// is empty at login, so a task seeded in the test body would be invisible to
/// the app without a manual refresh seed it here instead.
private(set) var seededCancelledTask_uncancelFlow: TestTask?
private(set) var seededCancelledTask_uncancelV2: TestTask?
override func seedAccountPreconditions(_ account: TestAccount) {
super.seedAccountPreconditions(account) // seeds seededResidence (requiresResidence)
guard let residence = seededResidence else { return }
// TASK-010: a cancelled task that the test will uncancel/reopen.
seededCancelledTask_uncancelFlow = TestDataSeeder.createCancelledTask(
token: account.token,
residenceId: residence.id
)
// TASK-010 (v2): a named residence+task, cancelled, that the test restores.
let v2Task = account.seedTask(
residenceId: residence.id,
title: "Uncancel Me \(Int(Date().timeIntervalSince1970))"
)
seededCancelledTask_uncancelV2 = TestAccountAPIClient.cancelTask(token: account.token, id: v2Task.id) ?? v2Task
}
override func setUpWithError() throws {
try super.setUpWithError()
// Dismiss any open form from a previous test
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
if cancelButton.exists { cancelButton.tap() }
navigateToTasks()
// Wait for task screen to load
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Task add button should appear")
}
// MARK: - Validation
func test01_cancelTaskCreation() {
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
addButton.tap()
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task form should open")
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
cancelButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Cancel button should exist")
cancelButton.tap()
// Verify we're back on the task list
let addButtonAgain = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
XCTAssertTrue(addButtonAgain.waitForExistence(timeout: navigationTimeout), "Should be back on tasks list after cancel")
}
// MARK: - View/List
func test02_tasksTabExists() {
let tabBar = app.tabBars.firstMatch
XCTAssertTrue(tabBar.exists, "Tab bar should exist")
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
XCTAssertTrue(addButton.exists, "Task add button should exist (proves we're on Tasks tab)")
}
func test03_viewTasksList() {
// Tasks screen should show verified by the add button existence from setUp
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
XCTAssertTrue(addButton.exists, "Tasks screen should be visible with add button")
}
func test04_addTaskButtonEnabled() {
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
XCTAssertTrue(addButton.isEnabled, "Task add button should be enabled when residence exists")
}
func test05_navigateToAddTask() {
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
addButton.tap()
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear in add form")
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
XCTAssertTrue(saveButton.exists, "Save button should exist in add task form")
// Clean up: dismiss form
let cancelButton = app.buttons[AccessibilityIdentifiers.Task.formCancelButton].firstMatch
if cancelButton.exists { cancelButton.tap() }
}
// MARK: - Creation
func test06_createBasicTask() {
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
addButton.tap()
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField].firstMatch
titleField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Task title field should appear")
let timestamp = Int(Date().timeIntervalSince1970)
let taskTitle = "UITest Task \(timestamp)"
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
dismissKeyboard()
app.swipeUp()
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
saveButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Save button should exist")
saveButton.tap()
// Wait for form to dismiss
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
// Verify task was created via API (also gives the server time to process)
if let items = TestAccountAPIClient.listTasks(token: session.token),
let created = items.first(where: { $0.title.contains(taskTitle) }) {
cleaner.trackTask(created.id)
}
// Navigate to tasks tab and refresh to pick up the newly created task
navigateToTasks()
refreshTasks()
let taskListScreen = TaskListScreen(app: app)
let newTask = taskListScreen.findTask(title: taskTitle)
XCTAssertTrue(newTask.waitForExistence(timeout: loginTimeout), "New task '\(taskTitle)' should appear in the list")
}
func testTASK_CreateTaskAppearsInList() {
// Residence is seeded before login (requiresResidence) so task creation
// has a valid target.
navigateToTasks()
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
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()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
titleField.typeText(uniqueTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
saveButton.scrollIntoView(in: scrollContainer)
saveButton.forceTap()
let newTask = app.staticTexts[uniqueTitle]
XCTAssertTrue(
newTask.waitForExistence(timeout: loginTimeout),
"Newly created task should appear"
)
}
// MARK: - View Details
func test07_viewTaskDetails() {
// Create a task first
let timestamp = Int(Date().timeIntervalSince1970)
let taskTitle = "UITest Detail \(timestamp)"
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
addButton.tap()
fillTextField(identifier: AccessibilityIdentifiers.Task.titleField, text: taskTitle)
dismissKeyboard()
app.swipeUp()
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton].firstMatch
saveButton.waitForExistenceOrFail(timeout: defaultTimeout)
saveButton.tap()
_ = saveButton.waitForNonExistence(timeout: navigationTimeout)
// Verify task was created via API (also gives the server time to process)
if let items = TestAccountAPIClient.listTasks(token: session.token),
let created = items.first(where: { $0.title.contains(taskTitle) }) {
cleaner.trackTask(created.id)
}
// Navigate to tasks tab and refresh to pick up the newly created task
navigateToTasks()
refreshTasks()
let taskListScreen = TaskListScreen(app: app)
let taskCard = taskListScreen.findTask(title: taskTitle)
taskCard.waitForExistenceOrFail(timeout: loginTimeout, message: "Created task should appear in list")
// Verify the task card is accessible and the actions menu exists
// (There is no task detail screen cards are self-contained with a context menu)
let actionsMenu = app.buttons["Task actions"].firstMatch
XCTAssertTrue(actionsMenu.waitForExistence(timeout: navigationTimeout), "Task actions menu should be accessible")
}
// MARK: - Navigation
func test08_navigateToContractors() {
navigateToContractors()
let addButton = app.buttons[AccessibilityIdentifiers.Contractor.addButton].firstMatch
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Contractors screen should load")
}
func test09_navigateToDocuments() {
navigateToDocuments()
let addButton = app.buttons[AccessibilityIdentifiers.Document.addButton].firstMatch
XCTAssertTrue(addButton.waitForExistence(timeout: navigationTimeout), "Documents screen should load")
}
func test10_navigateBetweenTabs() {
navigateToResidences()
let resAddButton = app.buttons[AccessibilityIdentifiers.Residence.addButton].firstMatch
XCTAssertTrue(resAddButton.waitForExistence(timeout: navigationTimeout), "Residences screen should load")
navigateToTasks()
let taskAddButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
XCTAssertTrue(taskAddButton.waitForExistence(timeout: navigationTimeout), "Tasks screen should load after navigating back")
}
// MARK: - TASK-010: Uncancel Task
func testTASK010_UncancelTaskFlow() throws {
// Cancelled task was seeded BEFORE login (seedAccountPreconditions) so the
// app's post-login fetch already has it.
guard let cancelledTask = seededCancelledTask_uncancelFlow else {
throw XCTSkip("Cancelled task precondition was not seeded")
}
navigateToTasks()
// Pull to refresh until the cancelled task is visible
let taskText = app.staticTexts[cancelledTask.title]
pullToRefreshUntilVisible(taskText)
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 {
// Residence + cancelled task were seeded BEFORE login
// (seedAccountPreconditions) so the app loads them on its post-login fetch.
guard let task = seededCancelledTask_uncancelV2 else {
throw XCTSkip("Cancelled task precondition was not seeded")
}
navigateToTasks()
// Pull to refresh until the cancelled task is visible
let taskText = app.staticTexts[task.title]
pullToRefreshUntilVisible(taskText)
guard taskText.waitForExistence(timeout: loginTimeout) 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-012: Delete Task
func testTASK012_DeleteTaskUpdatesViews() {
// Create a task via UI first (since Kanban board uses cached data).
// Residence is seeded before login (requiresResidence).
navigateToTasks()
let addButton = app.buttons[AccessibilityIdentifiers.Task.addButton].firstMatch
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, "Add task button should be visible")
if addButton.exists && addButton.isHittable {
addButton.forceTap()
} else {
emptyAddButton.forceTap()
}
let titleField = app.textFields[AccessibilityIdentifiers.Task.titleField]
titleField.waitForExistenceOrFail(timeout: defaultTimeout)
let uniqueTitle = "Delete Task \(Int(Date().timeIntervalSince1970))"
titleField.forceTap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
titleField.typeText(uniqueTitle)
let saveButton = app.buttons[AccessibilityIdentifiers.Task.saveButton]
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.collectionViews.firstMatch
if scrollContainer.exists {
saveButton.scrollIntoView(in: scrollContainer)
}
saveButton.forceTap()
// Wait for the task to appear in the Kanban board
let taskText = app.staticTexts[uniqueTitle]
taskText.waitForExistenceOrFail(timeout: loginTimeout)
// Tap the "Actions" menu on the task card to reveal cancel option
let actionsMenu = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Actions'")
).firstMatch
if actionsMenu.waitForExistence(timeout: defaultTimeout) {
actionsMenu.forceTap()
} else {
taskText.forceTap()
}
// Tap cancel (tasks use "Cancel Task" semantics)
let deleteButton = app.buttons[AccessibilityIdentifiers.Task.deleteButton]
if !deleteButton.waitForExistence(timeout: defaultTimeout) {
let cancelTask = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancel Task'")
).firstMatch
cancelTask.waitForExistenceOrFail(timeout: 5)
cancelTask.forceTap()
} else {
deleteButton.forceTap()
}
// Confirm cancellation
let confirmDelete = app.alerts.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Confirm' OR label CONTAINS[c] 'Yes' OR label CONTAINS[c] 'Cancel Task'")
).firstMatch
let alertConfirmButton = app.buttons[AccessibilityIdentifiers.Alert.confirmButton]
if alertConfirmButton.waitForExistence(timeout: defaultTimeout) {
alertConfirmButton.tap()
} else if confirmDelete.waitForExistence(timeout: defaultTimeout) {
confirmDelete.tap()
}
// Refresh the task list (kanban uses toolbar button, not pull-to-refresh)
refreshTasks()
// Verify the task is removed or moved to a different column
let deletedTask = app.staticTexts[uniqueTitle]
XCTAssertTrue(
deletedTask.waitForNonExistence(timeout: loginTimeout),
"Cancelled task should no longer appear in active views"
)
}
}