Files
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

460 lines
19 KiB
Swift

import XCTest
/// Residence READ / navigation / list / detail behaviour.
///
/// Merged from three legacy suites:
/// - ResidenceIntegrationTests (CRUD round-trips against the real backend)
/// - Suite3_ResidenceRebuildTests (rebuilt navigation/list/detail coverage
/// manual login scaffolding removed; the base now provides a logged-in session)
/// - Suite4_ComprehensiveResidenceTests (the view/navigation/refresh/persistence tests)
///
/// Per-test isolation: `AuthenticatedUITestCase` mints a fresh, pre-verified
/// account, logs in, and deletes it in teardown. A fresh account starts EMPTY,
/// so tests that need to SEE a pre-existing residence seed it in
/// `seedAccountPreconditions` (before login) and reference `seededResidence`.
final class ResidenceUITests: AuthenticatedUITestCase {
// MARK: - Page Objects
private var residenceList: ResidenceListScreen { ResidenceListScreen(app: app) }
private var residenceForm: ResidenceFormScreen { ResidenceFormScreen(app: app) }
// MARK: - Helpers
private func findResidence(name: String) -> XCUIElement {
app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", name)).firstMatch
}
// Suite3's createResidence helper, stripped of the manual login (the base
// now lands us on the main app already authenticated).
@discardableResult
private func createResidenceViaUI(name: String) -> String {
navigateToResidences()
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
}
// MARK: - Create (round-trip) from ResidenceIntegrationTests
func testRES_CreateResidenceAppearsInList() {
navigateToResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
list.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: loginTimeout),
"Newly created residence should appear in the list"
)
}
// MARK: - Edit (round-trip) from ResidenceIntegrationTests
func testRES_EditResidenceUpdatesInList() {
// Seed a residence via API so we have a known target to edit, then
// pull-to-refresh so the fresh account's empty list picks it up.
let seeded = cleaner.seedResidence(name: "Edit Target \(Int(Date().timeIntervalSince1970))")
navigateToResidences()
pullToRefresh()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let card = app.staticTexts[seeded.name]
pullToRefreshUntilVisible(card, maxRetries: 3)
card.waitForExistenceOrFail(timeout: loginTimeout)
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: loginTimeout),
"Updated residence name should appear after edit"
)
}
// MARK: - Set Primary (RES-007) from ResidenceIntegrationTests
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()
pullToRefresh()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
// Open the second residence's detail
let secondCard = app.staticTexts[secondResidence.name]
pullToRefreshUntilVisible(secondCard, maxRetries: 3)
secondCard.waitForExistenceOrFail(timeout: loginTimeout)
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: loginTimeout)
|| 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: - Double Submit Protection (OFF-004) from ResidenceIntegrationTests
func test19_doubleSubmitProtection() {
navigateToResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
list.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: loginTimeout)
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 (round-trip) from ResidenceIntegrationTests
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()
pullToRefresh()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
// Find and tap the seeded residence
let target = app.staticTexts[deleteName]
pullToRefreshUntilVisible(target, maxRetries: 3)
target.waitForExistenceOrFail(timeout: loginTimeout)
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: defaultTimeout) {
confirmButton.tap()
} else if alertDelete.waitForExistence(timeout: defaultTimeout) {
alertDelete.tap()
}
let deletedResidence = app.staticTexts[deleteName]
XCTAssertTrue(
deletedResidence.waitForNonExistence(timeout: loginTimeout),
"Deleted residence should no longer appear in the list"
)
}
// MARK: - Rebuilt navigation / list / detail from Suite3
//
// The original Suite3 ran on BaseUITestCase and logged in manually inside
// each test (a `loginAndOpenResidences` helper plus a verification-gate
// loop). The base class now provides a logged-in session, so that
// scaffolding is removed and only the residence assertions remain.
func testR301_authenticatedPreconditionCanReachMainApp() throws {
navigateToResidences()
RebuildSessionAssertions.assertOnMainApp(app, timeout: defaultTimeout)
}
func testR302_residencesTabIsPresentAndNavigable() throws {
navigateToResidences()
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
XCTAssertTrue(residencesTab.exists, "Residences tab should exist")
}
func testR303_residencesListLoadsAfterTabSelection() throws {
navigateToResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(list.addButton.exists, "Add residence button should be visible")
}
func testR304_openAddResidenceFormFromResidencesList() throws {
navigateToResidences()
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 {
navigateToResidences()
let list = ResidenceListScreen(app: app)
list.waitForLoad(timeout: defaultTimeout)
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))"
_ = createResidenceViaUI(name: name)
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "Created residence should appear in list")
}
func testR307_newResidenceAppearsInResidenceList() throws {
let name = "UITest Verify \(Int(Date().timeIntervalSince1970))"
_ = createResidenceViaUI(name: name)
let created = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
XCTAssertTrue(created.waitForExistence(timeout: loginTimeout), "New residence should be visible in residences list")
}
func testR308_openResidenceDetailsFromResidenceList() throws {
let name = "UITest Detail \(Int(Date().timeIntervalSince1970))"
_ = createResidenceViaUI(name: name)
let row = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", name)).firstMatch
row.waitForExistenceOrFail(timeout: loginTimeout).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 {
navigateToResidences()
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)
}
// MARK: - View / navigation / refresh / persistence from Suite4
func test13_viewResidenceDetails() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Detail View Test \(timestamp)"
// Create residence through the UI, then open its detail
_ = createResidenceViaUI(name: residenceName)
navigateToResidences()
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist")
residence.tap()
// Verify detail view appears with edit button or tasks section
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton].firstMatch
let tasksSection = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks' OR label CONTAINS[c] 'Maintenance'")).firstMatch
_ = editButton.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(editButton.exists || tasksSection.exists, "Detail view should show with edit button or tasks section")
}
func test14_navigateFromResidencesToOtherTabs() {
// From Residences tab
navigateToResidences()
// 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()
_ = tasksTab.waitForExistence(timeout: defaultTimeout)
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()
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
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()
_ = contractorsTab.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(contractorsTab.isSelected, "Should be on Contractors tab")
// Back to Residences
residencesTab.tap()
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(residencesTab.isSelected, "Should be back on Residences tab again")
}
func test15_refreshResidencesList() {
navigateToResidences()
// 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.waitForExistence(timeout: defaultTimeout) {
refreshButton.tap()
_ = app.activityIndicators.firstMatch.waitForNonExistence(timeout: defaultTimeout)
}
// 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")
}
func test16_residencePersistsAfterBackgroundingApp() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Persistence Test \(timestamp)"
// Create residence through the UI
_ = createResidenceViaUI(name: residenceName)
navigateToResidences()
// Verify residence exists
var residence = findResidence(name: residenceName)
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist before backgrounding")
// Background and reactivate app
XCUIDevice.shared.press(.home)
_ = app.wait(for: .runningForeground, timeout: 10)
// Navigate back to residences
navigateToResidences()
// Verify residence still exists
residence = findResidence(name: residenceName)
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should persist after backgrounding app")
}
}