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__@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) } }