Files
honeyDueKMP/iosApp/HoneyDueUITests/Residence/ResidenceManagementUITests.swift
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

388 lines
16 KiB
Swift

import XCTest
/// Residence MUTATION coverage: validation, creation (incl. edge-case names and
/// addresses), and editing.
///
/// Migrated from the mutation half of Suite4_ComprehensiveResidenceTests. The
/// view/navigation/refresh/persistence tests from that suite live in
/// `ResidenceUITests`.
///
/// Per-test isolation comes from `AuthenticatedUITestCase` (fresh account per
/// test, deleted in teardown). These tests CREATE residences through the UI, so
/// they need no seeded precondition creation doesn't require existing data.
final class ResidenceManagementUITests: AuthenticatedUITestCase {
// Test data tracking names created through the UI, reconciled to IDs for
// API cleanup in tearDown.
var createdResidenceNames: [String] = []
override func setUpWithError() throws {
try super.setUpWithError()
// Dismiss any open form/sheet from a previous test
let cancelButton = app.buttons[AccessibilityIdentifiers.Residence.formCancelButton].firstMatch
if cancelButton.exists { cancelButton.tap() }
navigateToResidences()
residenceList.addButton.waitForExistenceOrFail(timeout: navigationTimeout, message: "Residence add button should appear after navigation")
}
override func tearDownWithError() throws {
// Ensure all UI-created residences are tracked for API cleanup
if !createdResidenceNames.isEmpty,
let allResidences = TestAccountAPIClient.listResidences(token: session.token) {
for name in createdResidenceNames {
if let res = allResidences.first(where: { $0.name.contains(name) }) {
cleaner.trackResidence(res.id)
}
}
}
createdResidenceNames.removeAll()
try super.tearDownWithError()
}
// MARK: - Page Objects
private var residenceList: ResidenceListScreen { ResidenceListScreen(app: app) }
private var residenceForm: ResidenceFormScreen { ResidenceFormScreen(app: app) }
private var residenceDetail: ResidenceDetailScreen { ResidenceDetailScreen(app: app) }
// MARK: - Helper Methods
private func openResidenceForm(file: StaticString = #filePath, line: UInt = #line) {
let addButton = residenceList.addButton
addButton.waitForExistenceOrFail(timeout: defaultTimeout, message: "Residence add button should exist", file: file, line: line)
XCTAssertTrue(addButton.isEnabled, "Residence add button should be enabled", file: file, line: line)
addButton.tap()
residenceForm.nameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Residence form should open", file: file, line: line)
}
/// Fill address fields. Dismisses keyboard between each field for clean focus.
private func fillAddressFields(street: String, city: String, state: String, postal: String) {
// Scroll address section into view may need multiple swipes on smaller screens
let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField].firstMatch
for _ in 0..<3 {
if streetField.exists && streetField.isHittable { break }
app.swipeUp()
}
streetField.waitForExistenceOrFail(timeout: navigationTimeout, message: "Street field should appear after scroll")
fillTextField(identifier: AccessibilityIdentifiers.Residence.streetAddressField, text: street)
dismissKeyboard()
fillTextField(identifier: AccessibilityIdentifiers.Residence.cityField, text: city)
dismissKeyboard()
fillTextField(identifier: AccessibilityIdentifiers.Residence.stateProvinceField, text: state)
dismissKeyboard()
fillTextField(identifier: AccessibilityIdentifiers.Residence.postalCodeField, text: postal)
dismissKeyboard()
}
private func selectPropertyType(type: String) {
let picker = app.buttons[AccessibilityIdentifiers.Residence.propertyTypePicker].firstMatch
guard picker.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Property type picker not found")
return
}
picker.tap()
// SwiftUI Picker in Form pushes a selection list find the option by text
let option = app.staticTexts[type]
option.waitForExistenceOrFail(timeout: navigationTimeout, message: "Property type '\(type)' should appear in picker list")
option.tap()
}
private func createResidence(
name: String,
propertyType: String? = nil,
street: String = "123 Test St",
city: String = "TestCity",
state: String = "TS",
postal: String = "12345"
) {
openResidenceForm()
residenceForm.enterName(name)
if let propertyType = propertyType {
selectPropertyType(type: propertyType)
}
dismissKeyboard()
fillAddressFields(street: street, city: city, state: state, postal: postal)
residenceForm.save()
createdResidenceNames.append(name)
// Track for API cleanup
if let items = TestAccountAPIClient.listResidences(token: session.token),
let created = items.first(where: { $0.name.contains(name) }) {
cleaner.trackResidence(created.id)
}
}
private func findResidence(name: String) -> XCUIElement {
return app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", name)).firstMatch
}
// MARK: - 1. Error / Validation Tests
func test01_cannotCreateResidenceWithEmptyName() {
openResidenceForm()
// Leave name empty, fill only address
fillAddressFields(street: "123 Test St", city: "TestCity", state: "TS", postal: "12345")
// Scroll to save button if needed
app.swipeUp()
// Submit button should be disabled when name is empty (may be labeled "Add" or "Save")
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton].firstMatch
_ = saveButton.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(saveButton.exists, "Submit button should exist")
XCTAssertFalse(saveButton.isEnabled, "Submit button should be disabled when name is empty")
// Clean up: dismiss the form so next test starts on the list
residenceForm.cancel()
}
func test02_cancelResidenceCreation() {
openResidenceForm()
// Fill some data
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField].firstMatch
nameField.tap()
// Wait for keyboard to appear before typing
let keyboard = app.keyboards.firstMatch
_ = keyboard.waitForExistence(timeout: 3)
nameField.typeText("This will be canceled")
// Tap cancel
let cancelButton = app.buttons[AccessibilityIdentifiers.Residence.formCancelButton].firstMatch
XCTAssertTrue(cancelButton.exists, "Cancel button should exist")
cancelButton.tap()
// Should be back on residences list
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
_ = residencesTab.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(residencesTab.exists, "Should be back on residences list")
// Residence should not exist
let residence = findResidence(name: "This will be canceled")
XCTAssertFalse(residence.exists, "Canceled residence should not exist")
}
// MARK: - 2. Creation Tests
func test03_createResidenceWithMinimalData() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Minimal Home \(timestamp)"
createResidence(name: residenceName)
let residenceInList = findResidence(name: residenceName)
XCTAssertTrue(residenceInList.waitForExistence(timeout: 10), "Residence should appear in list")
}
// test04_createResidenceWithAllPropertyTypes removed in source: backend has no seeded residence types
func test05_createMultipleResidencesInSequence() {
let timestamp = Int(Date().timeIntervalSince1970)
for i in 1...3 {
let residenceName = "Sequential Home \(i) - \(timestamp)"
createResidence(name: residenceName)
navigateToResidences()
}
// Verify all residences exist
for i in 1...3 {
let residenceName = "Sequential Home \(i) - \(timestamp)"
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.exists, "Residence \(i) should exist in list")
}
}
func test06_createResidenceWithVeryLongName() {
let timestamp = Int(Date().timeIntervalSince1970)
let longName = "This is an extremely long residence name that goes on and on and on to test how the system handles very long text input in the name field \(timestamp)"
createResidence(name: longName)
// Verify it appears (may be truncated in display)
let residence = app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'extremely long residence'")).firstMatch
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Long name residence should exist")
}
func test07_createResidenceWithSpecialCharacters() {
let timestamp = Int(Date().timeIntervalSince1970)
let specialName = "Special !@#$%^&*() Home \(timestamp)"
createResidence(name: specialName)
let residence = findResidence(name: "Special")
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with special chars should exist")
}
func test08_createResidenceWithEmojis() {
let timestamp = Int(Date().timeIntervalSince1970)
let emojiName = "Beach House \(timestamp)"
createResidence(name: emojiName)
let residence = findResidence(name: "Beach House")
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with emojis should exist")
}
func test09_createResidenceWithInternationalCharacters() {
let timestamp = Int(Date().timeIntervalSince1970)
let internationalName = "Chateau Montreal \(timestamp)"
createResidence(name: internationalName)
let residence = findResidence(name: "Chateau")
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with international chars should exist")
}
func test10_createResidenceWithVeryLongAddress() {
let timestamp = Int(Date().timeIntervalSince1970)
let residenceName = "Long Address Home \(timestamp)"
createResidence(
name: residenceName,
street: "123456789 Very Long Street Name That Goes On And On Boulevard Apartment Complex Unit 42B",
city: "VeryLongCityNameThatTestsTheLimit",
state: "CA",
postal: "12345-6789"
)
let residence = findResidence(name: residenceName)
XCTAssertTrue(residence.waitForExistence(timeout: 10), "Residence with long address should exist")
}
// MARK: - 3. Edit / Update Tests
func test11_editResidenceName() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalName = "Original Name \(timestamp)"
let newName = "Edited Name \(timestamp)"
// Create residence
createResidence(name: originalName)
navigateToResidences()
// Find and tap residence
let residence = findResidence(name: originalName)
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist")
residence.tap()
// Tap edit button
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton].firstMatch
if editButton.waitForExistence(timeout: defaultTimeout) {
editButton.tap()
// Edit name
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField].firstMatch
if nameField.waitForExistence(timeout: defaultTimeout) {
nameField.clearAndEnterText(newName, app: app)
// Save
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton].firstMatch
if saveButton.exists {
saveButton.tap()
// Track new name
createdResidenceNames.append(newName)
// Verify new name appears
navigateToResidences()
let updatedResidence = findResidence(name: newName)
XCTAssertTrue(updatedResidence.waitForExistence(timeout: defaultTimeout), "Residence should show updated name")
}
}
}
}
func test12_updateAllResidenceFields() {
let timestamp = Int(Date().timeIntervalSince1970)
let originalName = "Update All Fields \(timestamp)"
let newName = "All Fields Updated \(timestamp)"
let newStreet = "999 Updated Avenue"
let newCity = "NewCity"
let newState = "NC"
let newPostal = "99999"
// Create residence with initial values
createResidence(name: originalName, street: "123 Old St", city: "OldCity", state: "OC", postal: "11111")
navigateToResidences()
// Find and tap residence
let residence = findResidence(name: originalName)
XCTAssertTrue(residence.waitForExistence(timeout: defaultTimeout), "Residence should exist")
residence.tap()
// Tap edit button
let editButton = app.buttons[AccessibilityIdentifiers.Residence.editButton].firstMatch
XCTAssertTrue(editButton.waitForExistence(timeout: defaultTimeout), "Edit button should exist")
editButton.tap()
// Update name
let nameField = app.textFields[AccessibilityIdentifiers.Residence.nameField].firstMatch
XCTAssertTrue(nameField.waitForExistence(timeout: defaultTimeout), "Name field should exist")
nameField.clearAndEnterText(newName, app: app)
// Property type update skipped backend has no seeded residence types
// Dismiss keyboard from name edit, scroll to address fields
dismissKeyboard()
app.swipeUp()
// Update address fields
let streetField = app.textFields[AccessibilityIdentifiers.Residence.streetAddressField].firstMatch
if streetField.waitForExistence(timeout: defaultTimeout) {
streetField.clearAndEnterText(newStreet, app: app)
dismissKeyboard()
}
let cityField = app.textFields[AccessibilityIdentifiers.Residence.cityField].firstMatch
if cityField.waitForExistence(timeout: defaultTimeout) {
cityField.clearAndEnterText(newCity, app: app)
dismissKeyboard()
}
let stateField = app.textFields[AccessibilityIdentifiers.Residence.stateProvinceField].firstMatch
if stateField.waitForExistence(timeout: defaultTimeout) {
stateField.clearAndEnterText(newState, app: app)
dismissKeyboard()
}
// Update postal code
let postalField = app.textFields[AccessibilityIdentifiers.Residence.postalCodeField].firstMatch
if postalField.exists {
postalField.clearAndEnterText(newPostal, app: app)
}
// Scroll to save button
app.swipeUp()
// Save
let saveButton = app.buttons[AccessibilityIdentifiers.Residence.saveButton].firstMatch
_ = saveButton.waitForExistence(timeout: defaultTimeout)
XCTAssertTrue(saveButton.exists, "Save button should exist")
saveButton.tap()
// Wait for form to dismiss after API call
_ = saveButton.waitForNonExistence(timeout: defaultTimeout)
// Track new name
createdResidenceNames.append(newName)
// Verify updated residence appears in list with new name
navigateToResidences()
let updatedResidence = findResidence(name: newName)
XCTAssertTrue(updatedResidence.waitForExistence(timeout: defaultTimeout), "Residence should show updated name in list")
// Name update verified in list detail view doesn't display address fields
}
}