c52ce4d497
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>
460 lines
19 KiB
Swift
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")
|
|
}
|
|
}
|