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

129 lines
4.9 KiB
Swift

import XCTest
/// A throwaway, fully-isolated test account.
///
/// The unit of isolation that lets suites run in parallel without sharing
/// state: each test mints its own unique, pre-verified Kratos identity, drives
/// the app's login UI as that identity, seeds data under its own token, and
/// deletes the identity in teardown which cascades all of its data and
/// clears the Kratos identity in one call.
///
/// Email format is collision-proof so parallel workers never overlap, and
/// carries a recognizable prefix so `SweepFixture` can find leaked accounts:
/// uit_<domain>_<uuid>@test.honeydue.local
struct TestAccount {
let username: String
let email: String
let password: String
let session: TestSession
var token: String { session.token }
// MARK: - Identity generation
/// Recognizable prefix for every generated account, so leaks are findable.
static let emailPrefix = "uit_"
/// Domain used for all generated test accounts (never a real mailbox).
static let emailDomain = "test.honeydue.local"
static func uniqueEmail(domain: String) -> String {
let slug = domain.lowercased().replacingOccurrences(of: " ", with: "-")
let unique = UUID().uuidString.prefix(12).lowercased()
return "\(emailPrefix)\(slug)_\(unique)@\(emailDomain)"
}
/// True if an email belongs to the generated test-account namespace.
static func isGenerated(_ email: String) -> Bool {
email.hasPrefix(emailPrefix) && email.hasSuffix("@\(emailDomain)")
}
// MARK: - Lifecycle
/// Create a pre-verified, ready-to-use account via the Kratos admin API.
/// The identity is verified up front so login lands straight on the main
/// tabs (no email-verification gate). Fails the test if creation fails.
@discardableResult
static func create(
domain: String,
verified: Bool = true,
file: StaticString = #filePath,
line: UInt = #line
) -> TestAccount {
let email = uniqueEmail(domain: domain)
let username = String(email.split(separator: "@").first ?? "uituser")
let password = "UitPass123!"
let session: TestSession?
if verified {
session = TestAccountAPIClient.createVerifiedAccount(
username: username, email: email, password: password
)
} else {
session = TestAccountAPIClient.createUnverifiedAccount(
username: username, email: email, password: password
)
}
guard let session else {
XCTFail("Failed to create isolated test account \(email)", file: file, line: line)
preconditionFailure("account creation failed — see XCTFail above")
}
return TestAccount(username: username, email: email, password: password, session: session)
}
/// Delete the Kratos identity (cascades all app data). Best-effort
/// never fails a test, since teardown cleanup should not mask the result.
func delete() {
_ = TestAccountAPIClient.deleteKratosIdentity(email: email)
}
// MARK: - UI login
/// Drive the app's login screen as this account and wait for the main tabs.
/// Assumes the app is on (or can reach) the standalone login screen.
func login(
into app: XCUIApplication,
timeout: TimeInterval,
file: StaticString = #filePath,
line: UInt = #line
) {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: timeout)
login.enterUsername(email) // Kratos identifier is the email
login.enterPassword(password)
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
loginButton.waitForExistenceOrFail(timeout: timeout, file: file, line: line)
loginButton.tap()
}
// MARK: - Seeding (under this account's own token)
@discardableResult
func seedResidence(name: String? = nil) -> TestResidence {
TestDataSeeder.createResidence(token: token, name: name)
}
@discardableResult
func seedResidenceWithAddress(name: String? = nil) -> TestResidence {
TestDataSeeder.createResidenceWithAddress(token: token, name: name)
}
@discardableResult
func seedTask(residenceId: Int, title: String? = nil, fields: [String: Any] = [:]) -> TestTask {
TestDataSeeder.createTask(token: token, residenceId: residenceId, title: title, fields: fields)
}
@discardableResult
func seedContractor(name: String? = nil, fields: [String: Any] = [:]) -> TestContractor {
TestDataSeeder.createContractor(token: token, name: name, fields: fields)
}
@discardableResult
func seedDocument(residenceId: Int, title: String? = nil, documentType: String = "general") -> TestDocument {
TestDataSeeder.createDocument(token: token, residenceId: residenceId, title: title, documentType: documentType)
}
}