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>
This commit is contained in:
Trey T
2026-06-05 16:26:50 -05:00
parent 09120e9d9d
commit c52ce4d497
44 changed files with 3824 additions and 3057 deletions
@@ -0,0 +1,411 @@
import XCTest
/// Consolidated login authentication UI tests.
///
/// Merged from four legacy suites:
/// - SimpleLoginTest (basic login screen smoke tests)
/// - AuthCriticalPathTests (critical-path login / logout / entry navigation)
/// - AuthenticationTests (F201F209 login-screen element + navigation checks)
/// - Suite2_AuthenticationRebuildTests (R201R206 valid-credential landing / logout)
///
/// Logged-OUT suite: extends `BaseUITestCase` (no auth). Each test drives the
/// login / registration-entry / forgot-password screens itself via the existing
/// page objects and helpers (LoginScreenObject, UITestHelpers, TestFlows, ).
final class AuthLoginUITests: BaseUITestCase {
// Merged override: SimpleLogin and Suite2 both disabled reset-state; the
// other two relied on the default. Disabling reset-state is the safe union
// because every test here re-establishes its own starting screen state.
override var includeResetStateLaunchArgument: Bool { false }
// AuthCriticalPath, Authentication, and Suite2 all relaunched between tests.
override var relaunchBetweenTests: Bool { true }
// AuthCriticalPath, Authentication, and Suite2 all booted with onboarding
// completed so a successful login lands on the main tabs (a freshly-seeded
// user has no residence; without this it routes to onboarding).
override var completeOnboarding: Bool { true }
private let validUser = RebuildTestUserFactory.seeded
/// The seeded user's Kratos login identifier. Kratos keys honeyDue
/// identities by EMAIL, and the app sends the typed identifier straight to
/// Kratos (AuthApi.login), so login must use the email not the bare
/// display username.
private let seededLoginIdentifier = "testuser@honeydue.com"
private enum AuthLandingState {
case main
case verification
}
override func setUpWithError() throws {
// Force a clean app launch so no stale field text persists between tests
// (union of SimpleLogin + Suite2 setUp behavior).
app.terminate()
try super.setUpWithError()
// CRITICAL: Ensure we're logged out before each test
UITestHelpers.ensureLoggedOut(app: app)
}
override func tearDownWithError() throws {
try super.tearDownWithError()
}
// MARK: - Helpers (from SimpleLoginTest)
/// Ensures the user is logged out and on the login screen
private func ensureLoggedOut() {
UITestHelpers.ensureLoggedOut(app: app)
}
// MARK: - Helpers (from AuthCriticalPathTests)
private func navigateToLogin() {
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
if loginField.waitForExistence(timeout: defaultTimeout) { return }
// On onboarding tap login button
let onboardingLogin = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
if onboardingLogin.waitForExistence(timeout: navigationTimeout) {
onboardingLogin.tap()
}
loginField.waitForExistenceOrFail(timeout: navigationTimeout, message: "Login screen should appear")
}
private func loginAsTestUser() {
navigateToLogin()
let login = LoginScreenObject(app: app)
// Kratos uses the EMAIL as the login identifier (no username trait).
login.enterUsername("testuser@honeydue.com")
login.enterPassword("TestPass123!")
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].tap()
// Wait for main app or verification gate
let tabBar = app.tabBars.firstMatch
let verification = VerificationScreen(app: app)
let deadline = Date().addingTimeInterval(loginTimeout)
while Date() < deadline {
if tabBar.exists { return }
if verification.codeField.exists {
verification.enterCode(TestAccountAPIClient.debugVerificationCode)
verification.submitCode()
_ = tabBar.waitForExistence(timeout: loginTimeout)
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(tabBar.exists, "Should reach main app after login")
}
// MARK: - Helpers (from Suite2_AuthenticationRebuildTests)
private func loginFromLoginScreen(user: RebuildTestUser = RebuildTestUserFactory.seeded) {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.enterUsername(seededLoginIdentifier)
login.enterPassword(user.password)
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
loginButton.forceTap()
}
@discardableResult
private func loginAndWaitForAuthenticatedLanding(user: RebuildTestUser = RebuildTestUserFactory.seeded) -> AuthLandingState {
loginFromLoginScreen(user: user)
let mainRoot = app.otherElements[UITestID.Root.mainTabs]
if mainRoot.waitForExistence(timeout: loginTimeout) || app.tabBars.firstMatch.waitForExistence(timeout: 2) {
return .main
}
let verification = VerificationScreen(app: app)
if verification.codeField.waitForExistence(timeout: defaultTimeout) || verification.verifyButton.waitForExistence(timeout: 2) {
return .verification
}
XCTFail("Expected authenticated landing on main tabs or verification screen")
return .verification
}
private func logoutFromVerificationIfNeeded() {
let verification = VerificationScreen(app: app)
verification.waitForLoad(timeout: defaultTimeout)
verification.tapLogoutIfAvailable()
let toolbarLogout = app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Logout' OR label CONTAINS[c] 'Log Out'")).firstMatch
if toolbarLogout.waitForExistence(timeout: 3) {
toolbarLogout.forceTap()
}
}
private func logoutFromMainApp() {
UITestHelpers.logout(app: app)
}
// MARK: - Tests (from SimpleLoginTest)
/// Test 1: App launches and shows login screen (or logs out if needed)
func testAppLaunchesAndShowsLoginScreen() {
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(usernameField.exists, "Username field should be visible on login screen after logout")
}
/// Test 2: Can type in username and password fields
func testCanTypeInLoginFields() {
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
usernameField.waitForExistenceOrFail(timeout: defaultTimeout, message: "Username field should exist on login screen")
usernameField.focusAndType("testuser", app: app)
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField].exists
? app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
: app.textFields[AccessibilityIdentifiers.Authentication.passwordField]
XCTAssertTrue(passwordField.exists, "Password field should exist on login screen")
passwordField.focusAndType("testpass123", app: app)
let signInButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
XCTAssertTrue(signInButton.exists, "Login button should exist on login screen")
}
// MARK: - Tests (from AuthCriticalPathTests)
// MARK: Login
func testLoginWithValidCredentials() {
loginAsTestUser()
XCTAssertTrue(app.tabBars.firstMatch.exists, "Tab bar should be visible after login")
}
func testLoginWithInvalidCredentials() {
navigateToLogin()
let login = LoginScreenObject(app: app)
login.enterUsername("invaliduser")
login.enterPassword("wrongpassword")
app.buttons[AccessibilityIdentifiers.Authentication.loginButton].tap()
// Should stay on login screen
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(loginField.waitForExistence(timeout: navigationTimeout), "Should remain on login screen after invalid credentials")
XCTAssertFalse(app.tabBars.firstMatch.exists, "Tab bar should not appear after failed login")
}
// MARK: Logout
func testLogoutFlow() {
loginAsTestUser()
UITestHelpers.logout(app: app)
let loginField = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let onboardingLogin = app.buttons[AccessibilityIdentifiers.Onboarding.loginButton]
let loggedOut = loginField.waitForExistence(timeout: loginTimeout)
|| onboardingLogin.waitForExistence(timeout: navigationTimeout)
XCTAssertTrue(loggedOut, "Should return to login or onboarding after logout")
}
// MARK: Registration Entry
func testSignUpButtonNavigatesToRegistration() {
navigateToLogin()
app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].tap()
let registerUsername = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(registerUsername.waitForExistence(timeout: navigationTimeout), "Registration form should appear")
}
// MARK: Forgot Password
func testForgotPasswordButtonExists() {
navigateToLogin()
let forgotButton = app.buttons[AccessibilityIdentifiers.Authentication.forgotPasswordButton]
XCTAssertTrue(forgotButton.waitForExistence(timeout: defaultTimeout), "Forgot password button should exist")
}
// MARK: - Tests (from AuthenticationTests)
func testF201_OnboardingLoginEntryShowsLoginScreen() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
}
func testF202_LoginScreenCanTogglePasswordVisibility() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.enterUsername("u")
login.enterPassword("p")
login.tapPasswordVisibilityToggle()
login.assertPasswordFieldVisible()
}
func testF203_RegisterSheetCanOpenAndDismiss() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
register.tapCancel()
login.waitForLoad(timeout: navigationTimeout)
}
func testF204_RegisterFormAcceptsInput() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist on register form")
}
func testF205_LoginButtonDisabledWhenCredentialsAreEmpty() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitForExistenceOrFail(timeout: defaultTimeout)
XCTAssertFalse(loginButton.isEnabled, "Login button should be disabled when username/password are empty")
}
func testF206_ForgotPasswordButtonIsAccessible() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
let forgotButton = app.buttons[UITestID.Auth.forgotPasswordButton]
forgotButton.waitForExistenceOrFail(timeout: defaultTimeout)
XCTAssertTrue(forgotButton.isHittable, "Forgot password button should be hittable on login screen")
}
func testF207_LoginScreenShowsAllExpectedElements() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(app.textFields[UITestID.Auth.usernameField].exists, "Username field should exist")
XCTAssertTrue(
app.secureTextFields[UITestID.Auth.passwordField].exists || app.textFields[UITestID.Auth.passwordField].exists,
"Password field should exist"
)
XCTAssertTrue(app.buttons[UITestID.Auth.loginButton].exists, "Login button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.signUpButton].exists, "Sign up button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.forgotPasswordButton].exists, "Forgot password button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.passwordVisibilityToggle].exists, "Password visibility toggle should exist")
}
func testF208_RegisterFormShowsAllRequiredFields() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapSignUp()
let register = RegisterScreenObject(app: app)
register.waitForLoad(timeout: navigationTimeout)
XCTAssertTrue(app.textFields[UITestID.Auth.registerUsernameField].exists, "Register username field should exist")
XCTAssertTrue(app.textFields[UITestID.Auth.registerEmailField].exists, "Register email field should exist")
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerPasswordField].exists, "Register password field should exist")
XCTAssertTrue(app.secureTextFields[UITestID.Auth.registerConfirmPasswordField].exists, "Register confirm password field should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.registerButton].exists, "Register button should exist")
XCTAssertTrue(app.buttons[UITestID.Auth.registerCancelButton].exists, "Register cancel button should exist")
}
func testF209_ForgotPasswordNavigatesToResetFlow() {
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.tapForgotPassword()
// Verify forgot password screen loaded by checking for its email field (accessibility ID, not label)
let emailField = app.textFields[UITestID.PasswordReset.emailField]
let sendCodeButton = app.buttons[UITestID.PasswordReset.sendCodeButton]
let loaded = emailField.waitForExistence(timeout: navigationTimeout)
|| sendCodeButton.waitForExistence(timeout: navigationTimeout)
XCTAssertTrue(loaded, "Forgot password screen should appear with email field or send code button")
}
// MARK: - Tests (from Suite2_AuthenticationRebuildTests)
func testR201_loginScreenLoadsFromOnboardingEntry() {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
}
func testR202_validCredentialsSubmitFromLogin() {
UITestHelpers.ensureOnLoginScreen(app: app)
let login = LoginScreenObject(app: app)
login.waitForLoad(timeout: defaultTimeout)
login.enterUsername(seededLoginIdentifier)
login.enterPassword(validUser.password)
let loginButton = app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
XCTAssertTrue(loginButton.waitForExistence(timeout: defaultTimeout), "Login button must exist before submit")
XCTAssertTrue(loginButton.isHittable, "Login button must be tappable")
}
func testR203_validLoginTransitionsToMainAppRoot() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
case .verification:
RebuildSessionAssertions.assertOnVerification(app, timeout: loginTimeout)
}
}
func testR204_mainAppHasExpectedPrimaryTabsAfterLogin() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
RebuildSessionAssertions.assertOnMainApp(app, timeout: loginTimeout)
let tabBar = app.tabBars.firstMatch
if tabBar.waitForExistence(timeout: 5) {
let residences = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
let tasks = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Tasks'")).firstMatch
let contractors = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Contractors'")).firstMatch
let docs = tabBar.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Doc'")).firstMatch
XCTAssertTrue(residences.exists, "Residences tab should exist")
XCTAssertTrue(tasks.exists, "Tasks tab should exist")
XCTAssertTrue(contractors.exists, "Contractors tab should exist")
XCTAssertTrue(docs.exists, "Documents tab should exist")
} else {
XCTAssertTrue(app.otherElements[UITestID.Root.mainTabs].exists, "Main tabs root should exist")
}
case .verification:
let verify = VerificationScreen(app: app)
verify.waitForLoad(timeout: defaultTimeout)
XCTAssertTrue(verify.codeField.exists, "Verification code field should exist for unverified accounts")
}
}
func testR205_logoutFromMainAppReturnsToLoginRoot() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
logoutFromMainApp()
case .verification:
logoutFromVerificationIfNeeded()
}
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
}
func testR206_postLogoutMainAppIsNoLongerAccessible() {
let landing = loginAndWaitForAuthenticatedLanding(user: validUser)
switch landing {
case .main:
logoutFromMainApp()
case .verification:
logoutFromVerificationIfNeeded()
}
RebuildSessionAssertions.assertOnLogin(app, timeout: loginTimeout)
XCTAssertFalse(app.otherElements[UITestID.Root.mainTabs].exists, "Main app root should not be visible after logout")
}
}
@@ -0,0 +1,293 @@
import XCTest
/// Tests for the password reset flow against the local stack.
///
/// The app's reset flow is wired to a real Kratos recovery flow: the
/// forgot-password screen starts a Kratos recovery flow and submits the email
/// (AuthApi.kt:406 `forgotPassword`), which makes Kratos EMAIL a 6-digit
/// recovery code that lands in Mailpit locally. The verify screen submits that
/// emailed code back to the same flow (AuthApi.kt:448 `verifyResetCode`). So
/// these tests read the live code from Mailpit instead of any fixed code.
///
/// Test Plan IDs: AUTH-015, AUTH-016, AUTH-017
final class AuthPasswordResetUITests: BaseUITestCase {
override var relaunchBetweenTests: Bool { true }
private var testSession: TestSession?
private var cleaner: TestDataCleaner?
override func setUpWithError() throws {
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
}
guard let session = TestAccountManager.createVerifiedAccount() else {
throw XCTSkip("Could not create verified test account")
}
testSession = session
cleaner = TestDataCleaner(token: session.token)
// Force clean app launch password reset flow leaves complex screen state
app.terminate()
try super.setUpWithError()
}
override func tearDownWithError() throws {
cleaner?.cleanAll()
try super.tearDownWithError()
}
// MARK: - AUTH-015: Verify reset code reaches new password screen
func testAUTH015_VerifyResetCodeSuccessPath() throws {
let session = try XCTUnwrap(testSession)
// Capture the recovery email ONCE and reuse it for both the request and
// the Mailpit lookup, so the lookup address matches what we submitted.
let email = session.user.email
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Enter email and send code
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(email)
forgotScreen.tapSendCode()
// Read the REAL Kratos recovery code Kratos emailed to Mailpit.
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(code)
verifyScreen.tapVerify()
// Should reach the new password screen
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad(timeout: loginTimeout)
}
// MARK: - AUTH-016: Full reset password cycle + login with new password
func testAUTH016_ResetPasswordSuccess() throws {
let session = try XCTUnwrap(testSession)
let newPassword = "NewPass9876!"
// Capture the recovery email ONCE for both the request and Mailpit lookup.
let email = session.user.email
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow,
// which hardcodes the obsolete debug code) so we submit the REAL Kratos
// recovery code read from Mailpit.
try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword)
// After reset, the app auto-logs in with the new password.
// If auto-login succeeds app goes directly to main tabs (sheet dismissed).
// If auto-login fails success message + "Return to Login" button appear.
let tabBar = app.tabBars.firstMatch
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
let deadline = Date().addingTimeInterval(loginTimeout)
var reachedPostReset = false
while Date() < deadline {
if tabBar.exists {
// Auto-login succeeded password reset worked!
reachedPostReset = true
break
}
if returnButton.exists {
// Auto-login failed manual login needed
reachedPostReset = true
returnButton.forceTap()
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button (manual login) after password reset")
if tabBar.exists {
// Already logged in via auto-login test passed
return
}
// Manual login path: return button was tapped, now on login screen
let loginScreen = LoginScreenObject(app: app)
loginScreen.waitForLoad(timeout: loginTimeout)
loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL
loginScreen.enterPassword(newPassword)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: 10).tap()
XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password")
}
// MARK: - AUTH-015 (alias): Verify reset code reaches the new password screen
func test03_verifyResetCodeSuccess() throws {
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
let session = try XCTUnwrap(testSession)
// Capture the recovery email ONCE for both the request and Mailpit lookup.
let email = session.user.email
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Enter email and send the reset code
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(email)
forgotScreen.tapSendCode()
// Read the REAL Kratos recovery code Kratos emailed to Mailpit.
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(code)
verifyScreen.tapVerify()
// The reset password screen should now appear
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad(timeout: loginTimeout)
}
// MARK: - AUTH-016 (alias): Full reset flow + login with new password
func test04_resetPasswordSuccessAndLogin() throws {
try XCTSkipIf(!TestAccountAPIClient.isBackendReachable(), "Backend not reachable")
let session = try XCTUnwrap(testSession)
let newPassword = "NewPass9876!"
// Capture the recovery email ONCE for both the request and Mailpit lookup.
let email = session.user.email
// Navigate to forgot password, then drive the complete 3-step reset flow
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Drive the full reset flow inline (NOT TestFlows.completeForgotPasswordFlow,
// which hardcodes the obsolete debug code) so we submit the REAL Kratos
// recovery code read from Mailpit.
try completeForgotPasswordFlowWithRealCode(email: email, newPassword: newPassword)
// Wait for a success indication either a success message or the return-to-login button
let successText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'success' OR label CONTAINS[c] 'reset'")
).firstMatch
let returnButton = app.buttons[UITestID.PasswordReset.returnToLoginButton]
// After reset, the app auto-logs in with the new password.
// If auto-login succeeds app goes to main tabs. If fails return button appears.
let tabBar = app.tabBars.firstMatch
let deadline = Date().addingTimeInterval(loginTimeout)
var reachedPostReset = false
while Date() < deadline {
if tabBar.exists {
reachedPostReset = true
break
}
if returnButton.exists {
reachedPostReset = true
returnButton.forceTap()
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
XCTAssertTrue(reachedPostReset, "Expected main tabs (auto-login) or return button after password reset")
if tabBar.exists { return }
// Manual login fallback
let loginScreen = LoginScreenObject(app: app)
loginScreen.waitForLoad(timeout: loginTimeout)
loginScreen.enterUsername(email) // Kratos login identifier is the EMAIL
loginScreen.enterPassword(newPassword)
let loginButton = app.buttons[UITestID.Auth.loginButton]
loginButton.waitUntilHittable(timeout: 10).tap()
XCTAssertTrue(tabBar.waitForExistence(timeout: loginTimeout), "Should login successfully with new password")
}
// MARK: - AUTH-017: Mismatched passwords are blocked
func testAUTH017_MismatchedPasswordBlocked() throws {
let session = try XCTUnwrap(testSession)
// Capture the recovery email ONCE for both the request and Mailpit lookup.
let email = session.user.email
// Navigate to forgot password
let login = TestFlows.navigateToLoginFromOnboarding(app: app)
login.tapForgotPassword()
// Get to the reset password screen
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(email)
forgotScreen.tapSendCode()
// Read the REAL Kratos recovery code Kratos emailed to Mailpit.
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(code)
verifyScreen.tapVerify()
// Enter mismatched passwords
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad(timeout: loginTimeout)
resetScreen.enterNewPassword("ValidPass123!")
resetScreen.enterConfirmPassword("DifferentPass456!")
// The reset button should be disabled when passwords don't match
XCTAssertFalse(resetScreen.isResetButtonEnabled, "Reset button should be disabled when passwords don't match")
}
// MARK: - Helpers
/// Drive the full forgot-password verify-code reset-password UI flow using
/// the REAL Kratos recovery code read from Mailpit.
///
/// This is a local replacement for `TestFlows.completeForgotPasswordFlow`, which
/// still hardcodes the obsolete `debugVerificationCode` ("123456"). The caller is
/// expected to have already reached the forgot-password screen (e.g. via
/// `login.tapForgotPassword()`). The `email` MUST be the same captured value used
/// elsewhere in the test so the Mailpit lookup matches the address submitted.
private func completeForgotPasswordFlowWithRealCode(email: String, newPassword: String) throws {
// Step 1: Enter email and request the recovery code.
let forgotScreen = ForgotPasswordScreen(app: app)
forgotScreen.waitForLoad()
forgotScreen.enterEmail(email)
forgotScreen.tapSendCode()
// Step 2: Read the REAL Kratos recovery code Kratos emailed to Mailpit.
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
XCTAssertFalse(code.isEmpty, "No Kratos recovery code arrived in Mailpit for \(email)")
let verifyScreen = VerifyResetCodeScreen(app: app)
verifyScreen.waitForLoad()
verifyScreen.enterCode(code)
verifyScreen.tapVerify()
// Step 3: Set the new password.
let resetScreen = ResetPasswordScreen(app: app)
try resetScreen.waitForLoad()
resetScreen.enterNewPassword(newPassword)
resetScreen.enterConfirmPassword(newPassword)
resetScreen.tapReset()
}
}
@@ -0,0 +1,670 @@
import XCTest
/// Comprehensive registration flow tests with strict, failure-first assertions
/// Tests verify both positive AND negative conditions to ensure robust validation
///
/// Migrated verbatim from the legacy Suite1_RegistrationTests. The full
/// registration flow (test07) reads the REAL Kratos verification code from
/// Mailpit via `TestAccountAPIClient.latestVerificationCode` it does NOT use a
/// hardcoded "123456" code.
final class AuthRegistrationUITests: BaseUITestCase {
override var completeOnboarding: Bool { true }
override var relaunchBetweenTests: Bool { true }
// Test user credentials - using timestamp to ensure unique users
private var testUsername: String {
return "testuser_\(Int(Date().timeIntervalSince1970))"
}
private var testEmail: String {
return "test_\(Int(Date().timeIntervalSince1970))@example.com"
}
private let testPassword = "Pass1234"
override func setUpWithError() throws {
// Force clean app launch registration tests leave sheet state that persists
app.terminate()
try super.setUpWithError()
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
if !loginScreen.waitForExistence(timeout: 3) {
ensureLoggedOut()
}
XCTAssertTrue(loginScreen.waitForExistence(timeout: 10), "PRECONDITION FAILED: Must start on login screen")
}
override func tearDownWithError() throws {
ensureLoggedOut()
try super.tearDownWithError()
}
// MARK: - Strict Helper Methods
private func ensureLoggedOut() {
UITestHelpers.ensureLoggedOut(app: app)
}
/// Navigate to registration screen with strict verification
/// Note: Registration is presented as a sheet, so login screen elements still exist underneath
private func navigateToRegistration() {
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.exists, "PRECONDITION: Must be on login screen to navigate to registration")
let signUpButton = app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].firstMatch
XCTAssertTrue(signUpButton.waitForExistence(timeout: 5), "Sign Up button must exist on login screen")
// Sign Up button may be offscreen at bottom of ScrollView
if !signUpButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists {
signUpButton.scrollIntoView(in: scrollView)
}
}
signUpButton.tap()
// STRICT: Verify registration screen appeared (shown as sheet)
// Note: Login screen still exists underneath the sheet, so we verify registration elements instead
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(usernameField.waitForExistence(timeout: 5), "Registration username field must appear")
XCTAssertTrue(waitForElementToBeHittable(usernameField, timeout: 5), "Registration username field must be tappable")
// Keep action buttons visible for strict assertions and interactions.
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
if createAccountButton.exists && !createAccountButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists {
createAccountButton.scrollIntoView(in: scrollView, maxSwipes: 5)
}
}
// STRICT: The Sign Up button should no longer be hittable (covered by sheet)
XCTAssertFalse(signUpButton.isHittable, "Login Sign Up button should be covered by registration sheet")
}
/// Dismisses iOS Strong Password suggestion overlay
private func dismissStrongPasswordSuggestion() {
let chooseOwnPassword = app.buttons["Choose My Own Password"]
if chooseOwnPassword.waitForExistence(timeout: 1) {
chooseOwnPassword.tap()
return
}
let notNowButton = app.buttons["Not Now"]
if notNowButton.exists && notNowButton.isHittable {
notNowButton.tap()
return
}
// Dismiss by tapping elsewhere
let strongPasswordText = app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] 'Strong Password'")).firstMatch
if strongPasswordText.exists {
app.tap()
}
}
/// Wait for element to disappear - CRITICAL for strict testing
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == false"),
object: element
)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
/// Wait for element to become hittable (visible AND interactive)
private func waitForElementToBeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "isHittable == true"),
object: element
)
let result = XCTWaiter.wait(for: [expectation], timeout: timeout)
return result == .completed
}
/// Verification screen readiness check based on stable accessibility IDs.
private func waitForVerificationScreen(timeout: TimeInterval) -> Bool {
let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
let onboardingCodeField = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton]
let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
return authCodeField.waitForExistence(timeout: timeout)
|| onboardingCodeField.waitForExistence(timeout: timeout)
|| authVerifyButton.waitForExistence(timeout: timeout)
|| onboardingVerifyButton.waitForExistence(timeout: timeout)
}
private func verificationCodeField() -> XCUIElement {
let authCodeField = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
if authCodeField.exists {
return authCodeField
}
return app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
}
private func verificationButton() -> XCUIElement {
let authVerifyButton = app.buttons[AccessibilityIdentifiers.Authentication.verifyButton]
if authVerifyButton.exists {
return authVerifyButton
}
let onboardingVerifyButton = app.buttons[AccessibilityIdentifiers.Onboarding.verifyButton]
if onboardingVerifyButton.exists {
return onboardingVerifyButton
}
return app.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Verify'")).firstMatch
}
/// Submit the registration form after filling it. Uses keyboard "Go" button
/// or falls back to dismissing keyboard and tapping the register button.
private func submitRegistrationForm() {
let goButton = app.keyboards.buttons["Go"]
if goButton.waitForExistence(timeout: 2) && goButton.isHittable {
goButton.tap()
return
}
// Fallback: dismiss keyboard, then tap register button
dismissKeyboard()
let registerButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
registerButton.waitForExistenceOrFail(timeout: 5)
if !registerButton.isHittable {
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { registerButton.scrollIntoView(in: scrollView) }
}
registerButton.forceTap()
}
/// Dismiss keyboard safely by tapping a neutral area.
private func dismissKeyboard() {
guard app.keyboards.firstMatch.exists else { return }
// Try toolbar Done button first
let doneButton = app.toolbars.buttons["Done"]
if doneButton.exists && doneButton.isHittable {
doneButton.tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
return
}
// Try navigation bar (works on most screens)
let navBar = app.navigationBars.firstMatch
if navBar.exists && navBar.isHittable {
navBar.tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
return
}
// Fallback: tap top-center of the app
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.1)).tap()
_ = app.keyboards.firstMatch.waitForNonExistence(timeout: 2)
}
/// Fill registration form with given credentials.
/// Uses Return key (\n) to trigger SwiftUI's .onSubmit / @FocusState field
/// transitions. Direct field taps fail on iOS 26 when transitioning from
/// TextField to SecureTextField (keyboard never appears).
private func fillRegistrationForm(username: String, email: String, password: String, confirmPassword: String) {
// iOS 26 bug: SecureTextField won't gain keyboard focus when tapped directly.
// Workaround: toggle password visibility first to convert SecureField TextField.
let scrollView = app.scrollViews.firstMatch
if scrollView.exists { scrollView.swipeUp() }
let toggleButtons = app.buttons.matching(NSPredicate(format: "label == 'Toggle password visibility'"))
for i in 0..<toggleButtons.count {
let toggle = toggleButtons.element(boundBy: i)
if toggle.exists && toggle.isHittable { toggle.tap() }
}
// Don't swipeDown it dismisses the sheet. focusAndType() auto-scrolls via tap().
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
let passwordField = app.textFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
usernameField.focusAndType(username, app: app)
emailField.focusAndType(email, app: app)
passwordField.focusAndType(password, app: app)
confirmPasswordField.focusAndType(confirmPassword, app: app)
}
// MARK: - 1. UI/Element Tests (no backend, pure UI verification)
func test01_registrationScreenElements() {
navigateToRegistration()
// STRICT: All form elements must exist AND be hittable
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
let emailField = app.textFields[AccessibilityIdentifiers.Authentication.registerEmailField]
let passwordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerPasswordField]
let confirmPasswordField = app.secureTextFields[AccessibilityIdentifiers.Authentication.registerConfirmPasswordField]
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
XCTAssertTrue(usernameField.exists && usernameField.isHittable, "Username field must be visible and tappable")
XCTAssertTrue(emailField.exists && emailField.isHittable, "Email field must be visible and tappable")
XCTAssertTrue(passwordField.exists && passwordField.isHittable, "Password field must be visible and tappable")
XCTAssertTrue(confirmPasswordField.exists && confirmPasswordField.isHittable, "Confirm password field must be visible and tappable")
XCTAssertTrue(createAccountButton.exists && createAccountButton.isHittable, "Create Account button must be visible and tappable")
XCTAssertTrue(cancelButton.exists && cancelButton.isHittable, "Cancel button must be visible and tappable")
// NEGATIVE CHECK: Should NOT see verification screen elements as hittable
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists && verifyTitle.isHittable, "Verification screen should NOT be visible on registration form")
// NEGATIVE CHECK: Login Sign Up button should not be hittable (covered by sheet)
let loginSignUpButton = app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].firstMatch
// Note: The button might still exist but should not be hittable due to sheet coverage
if loginSignUpButton.exists {
XCTAssertFalse(loginSignUpButton.isHittable, "Login screen's Sign Up button should be covered by registration sheet")
}
}
func test02_cancelRegistration() {
navigateToRegistration()
// Capture that we're on registration screen
let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
XCTAssertTrue(usernameField.isHittable, "PRECONDITION: Must be on registration screen")
let cancelButton = app.buttons[AccessibilityIdentifiers.Authentication.registerCancelButton]
XCTAssertTrue(cancelButton.isHittable, "Cancel button must be tappable")
dismissKeyboard()
cancelButton.tap()
// STRICT: Registration sheet must dismiss - username field should no longer be hittable
XCTAssertTrue(waitForElementToDisappear(usernameField, timeout: 5), "Registration form must disappear after cancel")
// STRICT: Login screen must now be interactive again
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Login screen must be visible after cancel")
// STRICT: Sign Up button should be hittable again (sheet dismissed)
let signUpButton = app.buttons[AccessibilityIdentifiers.Authentication.signUpButton].firstMatch
XCTAssertTrue(waitForElementToBeHittable(signUpButton, timeout: 5), "Sign Up button must be tappable after cancel")
}
// MARK: - 2. Client-Side Validation Tests (no API calls, fail locally)
func test03_registrationWithEmptyFields() {
navigateToRegistration()
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
XCTAssertTrue(createAccountButton.isHittable, "Create Account button must be tappable")
// Capture current state
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "PRECONDITION: Should not be on verification screen")
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show error message
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'required' OR label CONTAINS[c] 'Username'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for empty fields")
// NEGATIVE CHECK: Should NOT navigate away from registration
// XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification screen with empty fields")
// STRICT: Registration form should still be visible and interactive
// let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
// XCTAssertTrue(usernameField.isHittable, "Username field should still be tappable after error")
}
func test04_registrationWithInvalidEmail() {
navigateToRegistration()
fillRegistrationForm(
username: "testuser",
email: "invalid-email", // Invalid format
password: testPassword,
confirmPassword: testPassword
)
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show email-specific error
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'email' OR label CONTAINS[c] 'invalid'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for invalid email format")
// NEGATIVE CHECK: Should NOT proceed to verification
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with invalid email")
}
func test05_registrationWithMismatchedPasswords() {
navigateToRegistration()
fillRegistrationForm(
username: "testuser",
email: "test@example.com",
password: "Password123!",
confirmPassword: "DifferentPassword123!" // Mismatched
)
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show password mismatch error
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'match' OR label CONTAINS[c] 'password'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for mismatched passwords")
// NEGATIVE CHECK: Should NOT proceed to verification
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with mismatched passwords")
}
func test06_registrationWithWeakPassword() {
navigateToRegistration()
fillRegistrationForm(
username: "testuser",
email: "test@example.com",
password: "weak", // Too weak
confirmPassword: "weak"
)
let createAccountButton = app.buttons[AccessibilityIdentifiers.Authentication.registerButton]
dismissKeyboard()
createAccountButton.tap()
// STRICT: Must show password strength error
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'password' OR label CONTAINS[c] 'character' OR label CONTAINS[c] 'strong' OR label CONTAINS[c] '8'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 3), "Error message must appear for weak password")
// NEGATIVE CHECK: Should NOT proceed
let verifyTitle = app.staticTexts["Verify Your Email"]
XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with weak password")
}
// MARK: - 3. Full Registration Flow Tests (creates new users - MUST RUN BEFORE tests that need existing users)
func test07_successfulRegistrationAndVerification() throws {
// This test reads the REAL Kratos verification code from Mailpit, which
// requires the local stack (backend + Kratos + Mailpit) to be running.
try XCTSkipUnless(
TestAccountAPIClient.isBackendReachable(),
"Local backend not reachable at \(TestAccountAPIClient.baseURL) — Kratos/Mailpit required for live verification code"
)
// Capture the timestamp-based email ONCE: the `testEmail` computed property
// regenerates a new value on every access (uses Date().timeIntervalSince1970),
// so the same local `let` MUST be used for both registration and the Mailpit
// lookup, otherwise the lookup address won't match what was registered.
let username = testUsername
let email = testEmail
// Use the same proven flow as tests 09-12
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
submitRegistrationForm()
// Wait for verification screen to appear (registration form may still exist underneath)
XCTAssertTrue(waitForVerificationScreen(timeout: 15), "Verification screen must appear after registration")
// NEGATIVE CHECK: Tab bar should NOT be hittable while on verification
let tabBar = app.tabBars.firstMatch
if tabBar.exists {
XCTAssertFalse(tabBar.isHittable, "Tab bar should NOT be interactive while verification is required")
}
// Enter verification code the verification screen auto-submits when 6 digits are typed.
// IMPORTANT: Do NOT use focusAndType() here it taps the nav bar to dismiss the keyboard,
// which can accidentally hit the logout button in the toolbar.
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5), "Verification code field must exist")
// The app's registration uses Kratos's real email verification flow (NOT the
// API DEBUG fixed code), so read the live code from Mailpit for the exact
// address we registered with above (the captured `email` local).
// The verify screen's onAppear sends the code asynchronously, so settle first.
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
let realCode = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
XCTAssertFalse(realCode.isEmpty, "No Kratos verification code arrived in Mailpit for \(email)")
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(realCode)
// Auto-submit: typing 6 digits triggers verifyEmail() and navigates to main app.
// Wait for the main app to appear (RootView sets ui.root.mainTabs when showing MainTabView).
let mainTabs = app.otherElements["ui.root.mainTabs"]
let mainAppAppeared = mainTabs.waitForExistence(timeout: 15)
if !mainAppAppeared {
// Diagnostic: capture what's on screen
let screenshot = XCTAttachment(screenshot: app.screenshot())
screenshot.name = "post-verification-no-main-tabs"
screenshot.lifetime = .keepAlways
add(screenshot)
// Check if we're stuck on verification screen or login
let stillOnVerify = codeField.exists
let onLogin = app.textFields[AccessibilityIdentifiers.Authentication.usernameField].exists
XCTFail("Main app did not appear after verification. StillOnVerify=\(stillOnVerify), OnLogin=\(onLogin)")
return
}
// STRICT: Tab bar must exist and be interactive
XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5), "Tab bar must exist in main app")
// NEGATIVE CHECK: Verification screen should be completely gone
XCTAssertFalse(codeField.exists, "Verification code field must NOT exist after successful verification")
// Cleanup: Logout via profile tab settings logout
dismissKeyboard()
ensureLoggedOut()
}
// MARK: - 4. Server-Side Validation Tests (NOW a user exists from test07)
// func test08_registrationWithExistingUsername() {
// // NOTE: test07 created a user, so now we can test duplicate username rejection
// // We use 'testuser' which should be seeded, OR we could use the username from test07
// navigateToRegistration()
//
// fillRegistrationForm(
// username: "testuser", // Existing username (seeded in test DB)
// email: "newemail_\(Int(Date().timeIntervalSince1970))@example.com",
// password: testPassword,
// confirmPassword: testPassword
// )
//
// dismissKeyboard()
// app.buttons[AccessibilityIdentifiers.Authentication.registerButton].tap()
//
// // STRICT: Must show "already exists" error
// let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'exists' OR label CONTAINS[c] 'already' OR label CONTAINS[c] 'taken'")
// let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
// XCTAssertTrue(errorMessage.waitForExistence(timeout: 5), "Error message must appear for existing username")
//
// // NEGATIVE CHECK: Should NOT proceed to verification
// let verifyTitle = app.staticTexts["Verify Your Email"]
// XCTAssertFalse(verifyTitle.exists, "Should NOT navigate to verification with existing username")
//
// // STRICT: Should still be on registration form
// let usernameField = app.textFields[AccessibilityIdentifiers.Authentication.registerUsernameField]
// XCTAssertTrue(usernameField.exists && usernameField.isHittable, "Registration form should still be active")
// }
// MARK: - 5. Verification Screen Tests
func test09_registrationWithInvalidVerificationCode() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
submitRegistrationForm()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
// Enter INVALID code auto-submits at 6 digits
// Don't use focusAndType() it taps nav bar which can hit the logout button
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("000000") // Wrong code auto-submit API error
// STRICT: Error message must appear (auto-submit verifies with wrong code)
let errorPredicate = NSPredicate(format: "label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'error' OR label CONTAINS[c] 'incorrect' OR label CONTAINS[c] 'wrong' OR label CONTAINS[c] 'expired'")
let errorMessage = app.staticTexts.containing(errorPredicate).firstMatch
XCTAssertTrue(errorMessage.waitForExistence(timeout: 10), "Error message MUST appear for invalid verification code")
}
func test10_verificationCodeFieldValidation() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
submitRegistrationForm()
XCTAssertTrue(waitForVerificationScreen(timeout: 10))
// Enter incomplete code (only 3 digits won't trigger auto-submit)
// Don't use focusAndType() it taps nav bar which can hit the logout button
let codeField = verificationCodeField()
XCTAssertTrue(codeField.waitForExistence(timeout: 5))
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("123") // Incomplete
// STRICT: Must still be on verification screen (3 digits won't auto-submit)
XCTAssertTrue(codeField.exists, "Must remain on verification screen with incomplete code")
// NEGATIVE CHECK: Should NOT have navigated to main app
let residencesTab = app.tabBars.buttons.containing(NSPredicate(format: "label CONTAINS[c] 'Residences'")).firstMatch
if residencesTab.exists {
XCTAssertFalse(residencesTab.isHittable, "Tab bar MUST NOT be accessible with incomplete verification")
}
}
func test11_appRelaunchWithUnverifiedUser() {
// This test verifies: user kills app on verification screen, relaunches, should see verification again
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
submitRegistrationForm()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 20), "Must reach verification screen")
// Relaunch WITHOUT --reset-state so the unverified session persists.
// Keep --ui-testing and --disable-animations but remove --reset-state and --complete-onboarding.
app.terminate()
app.launchArguments = ["--ui-testing", "--disable-animations"]
app.launch()
// Wait for app to fully initialize
app.otherElements["ui.app.ready"].waitForExistenceOrFail(timeout: 15)
// STRICT: After relaunch, unverified user MUST see verification screen, NOT main app
let authCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Authentication.verificationCodeField]
let onboardingCodeFieldAfterRelaunch = app.textFields[AccessibilityIdentifiers.Onboarding.verificationCodeField]
let loginScreen = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
let tabBar = app.tabBars.firstMatch
// Wait for one of the expected screens to appear
_ = authCodeFieldAfterRelaunch.waitForExistence(timeout: 10)
|| onboardingCodeFieldAfterRelaunch.waitForExistence(timeout: 5)
|| loginScreen.waitForExistence(timeout: 5)
// User should NEVER be on main app with unverified email
if tabBar.exists && tabBar.isHittable {
XCTFail("CRITICAL: Unverified user should NOT have access to main app after relaunch. Tab bar is hittable!")
}
// Acceptable states: verification screen OR login screen (if token expired)
let onVerificationScreen =
(authCodeFieldAfterRelaunch.exists && authCodeFieldAfterRelaunch.isHittable)
|| (onboardingCodeFieldAfterRelaunch.exists && onboardingCodeFieldAfterRelaunch.isHittable)
let onLoginScreen = loginScreen.exists && loginScreen.isHittable
XCTAssertTrue(onVerificationScreen || onLoginScreen,
"After relaunch, unverified user must be on verification screen or login screen, NOT main app")
// Cleanup: logout from whatever screen we're on
if onVerificationScreen {
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
if logoutButton.waitForExistence(timeout: 3) && logoutButton.isHittable {
dismissKeyboard()
logoutButton.tap()
}
}
}
func test12_logoutFromVerificationScreen() {
let username = testUsername
let email = testEmail
navigateToRegistration()
fillRegistrationForm(
username: username,
email: email,
password: testPassword,
confirmPassword: testPassword
)
submitRegistrationForm()
// Wait for verification screen
XCTAssertTrue(waitForVerificationScreen(timeout: 10), "Must navigate to verification screen")
// STRICT: Logout button must exist and be tappable (uses dedicated verify screen ID)
let logoutButton = app.buttons[AccessibilityIdentifiers.Authentication.verificationLogoutButton]
XCTAssertTrue(logoutButton.waitForExistence(timeout: 5), "Logout button MUST exist on verification screen")
logoutButton.waitUntilHittable(timeout: 5)
logoutButton.tap()
// STRICT: Verification screen must disappear
let codeField = verificationCodeField()
XCTAssertTrue(waitForElementToDisappear(codeField, timeout: 5), "Verification screen must disappear after logout")
// STRICT: Must return to login screen
let welcomeText = app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
XCTAssertTrue(welcomeText.waitForExistence(timeout: 5), "Must return to login screen after logout")
XCTAssertTrue(welcomeText.isHittable, "Login screen must be interactive")
// NEGATIVE CHECK: Verification screen elements should be gone
XCTAssertFalse(codeField.exists, "Verification code field should NOT exist after logout")
}
}
// MARK: - XCUIElement Extension
extension XCUIElement {
var hasKeyboardFocus: Bool {
return (value(forKey: "hasKeyboardFocus") as? Bool) ?? false
}
}