Files
honeyDueKMP/iosApp/HoneyDueUITests/Sharing/SharingUITests.swift
T
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

406 lines
17 KiB
Swift

import XCTest
/// XCUITests for multi-user residence sharing.
///
/// Pattern: TWO real users share a residence.
/// - The PRIMARY user (User B) is the per-test isolated account minted by
/// `AuthenticatedUITestCase` the app launches already logged in as User B.
/// - The PEER user (User A) is created explicitly here as a SECOND TestAccount
/// (`TestAccount.create(domain: "sharing-peer")`). User A owns the residence,
/// seeds a task + document on it, and generates a share code via the API.
/// - User B joins User A's residence through the UI and verifies the shared data.
///
/// ALL assertions check UI elements only. If the UI doesn't show the expected
/// data, that indicates a real app bug and the test should fail.
///
/// User A is cleaned up in `tearDownWithError`; User B is deleted by the base.
final class SharingUITests: AuthenticatedUITestCase {
/// Relaunch per test so the joined-residence + shared-document caches don't
/// bleed across tests (the documents/tasks tabs can show a stale empty list
/// on a reused session).
override var relaunchBetweenTests: Bool { true }
// User A (the PEER / owner) created explicitly per test
/// User A's isolated account (owner of the shared residence).
private var userA: TestAccount!
/// The shared residence ID (owned by User A).
private var sharedResidenceId: Int!
/// The share code User B will enter in the UI.
private var shareCode: String!
/// The residence name (to verify in UI).
private var sharedResidenceName: String!
/// Titles of task/document seeded by User A (to verify in UI).
private var userATaskTitle: String!
private var userADocTitle: String!
override func setUpWithError() throws {
// Base mints + logs in the PRIMARY account (User B) and launches the app.
try super.setUpWithError()
// Create User A (the peer/owner) as a second isolated account
let runId = UUID().uuidString.prefix(6)
userA = TestAccount.create(domain: "sharing-peer")
// User A creates a residence
sharedResidenceName = "Shared House \(runId)"
guard let residence = TestAccountAPIClient.createResidence(
token: userA.token,
name: sharedResidenceName
) else {
XCTFail("Could not create residence for User A"); return
}
sharedResidenceId = residence.id
// User A generates a share code
guard let code = TestAccountAPIClient.generateShareCode(
token: userA.token,
residenceId: sharedResidenceId
) else {
XCTFail("Could not generate share code"); return
}
shareCode = code.code
// User A seeds data on the residence
userATaskTitle = "Fix Roof \(runId)"
_ = TestAccountAPIClient.createTask(
token: userA.token,
residenceId: sharedResidenceId,
title: userATaskTitle
)
userADocTitle = "Home Warranty \(runId)"
_ = TestAccountAPIClient.createDocument(
token: userA.token,
residenceId: sharedResidenceId,
title: userADocTitle,
documentType: "warranty"
)
}
override func tearDownWithError() throws {
// Clean up User A (cascades its residence + seeded data). User B is
// deleted by the base class.
userA?.delete()
try super.tearDownWithError()
}
// MARK: - Test 01: Join Residence via UI Share Code
func test01_joinResidenceWithShareCode() {
navigateToResidences()
// Tap the join button (person.badge.plus icon in toolbar)
let joinButton = findJoinButton()
XCTAssertTrue(joinButton.waitForExistence(timeout: defaultTimeout), "Join button should exist")
joinButton.tap()
// Verify JoinResidenceView appeared
let codeField = app.textFields["JoinResidence.ShareCodeField"]
XCTAssertTrue(codeField.waitForExistence(timeout: defaultTimeout),
"Share code field should appear")
// Type the share code
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(shareCode)
// Tap Join
let joinAction = app.buttons["JoinResidence.JoinButton"]
XCTAssertTrue(joinAction.waitForExistence(timeout: defaultTimeout), "Join button should exist")
XCTAssertTrue(joinAction.isEnabled, "Join button should be enabled with 6-char code")
joinAction.tap()
// Wait for join to complete the sheet should dismiss
// Verify the join screen dismissed (code field should be gone)
let codeFieldGone = codeField.waitForNonExistence(timeout: 10)
XCTAssertTrue(codeFieldGone || !codeField.exists,
"Join sheet should dismiss after successful join")
// Verify the shared residence name appears in the Residences list
let residenceText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
pullToRefreshUntilVisible(residenceText, maxRetries: 3)
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list after joining")
}
// MARK: - Test 02: Joined Residence Shows Data in UI
func test02_joinedResidenceShowsSharedDocuments() {
// Join via UI
joinResidenceViaUI()
// Verify residence appears in Residences tab
let residenceText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
pullToRefreshUntilVisible(residenceText, maxRetries: 3)
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list")
// Navigate to Documents tab and verify User A's document title appears
navigateToDocuments()
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userADocTitle)
).firstMatch
pullToRefreshUntilVisible(docText, maxRetries: 3)
XCTAssertTrue(docText.exists,
"User A's document '\(userADocTitle!)' should be visible in Documents tab after joining the shared residence")
}
// MARK: - Test 03: Shared Tasks Visible in UI
/// Known issue: After joining a shared residence, the Tasks tab doesn't show
/// the shared tasks. The AllTasksView's residenceViewModel uses cached (empty)
/// data, which disables the refresh button and prevents task loading.
/// Fix: AllTasksView.onAppear should detect residence list changes or use
/// DataManager's already-refreshed cache.
func test03_sharedTasksVisibleInTasksTab() {
// Join via UI this lands on Residences tab which triggers forceRefresh
joinResidenceViaUI()
// Verify the residence appeared (confirms join + refresh worked)
let sharedRes = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
XCTAssertTrue(sharedRes.waitForExistence(timeout: defaultTimeout),
"Shared residence should be visible before navigating to Tasks")
// Navigate to Tasks tab
navigateToTasks()
// Tap the refresh button (arrow.clockwise) to force-reload tasks
let refreshButton = app.navigationBars.buttons.containing(
NSPredicate(format: "label CONTAINS 'arrow.clockwise'")
).firstMatch
for _ in 0..<5 {
if refreshButton.waitForExistence(timeout: 3) && refreshButton.isEnabled {
refreshButton.tap()
// Wait for task data to load
_ = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userATaskTitle)
).firstMatch.waitForExistence(timeout: defaultTimeout)
break
}
// If disabled, wait for residence data to propagate
_ = refreshButton.waitForExistence(timeout: 3)
}
// Search for User A's task title it may be in any kanban column
let taskText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userATaskTitle)
).firstMatch
// Kanban is a horizontal scroll swipe left through columns to find the task
for _ in 0..<5 {
if taskText.exists { break }
app.swipeLeft()
}
XCTAssertTrue(taskText.waitForExistence(timeout: defaultTimeout),
"User A's task '\(userATaskTitle!)' should be visible in Tasks tab after joining the shared residence")
}
// MARK: - Test 04: Shared Residence Shows in Documents Tab
func test04_sharedResidenceShowsInDocumentsTab() {
joinResidenceViaUI()
navigateToDocuments()
// Look for User A's document
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'Home Warranty'")
).firstMatch
pullToRefreshUntilVisible(docText, maxRetries: 3)
// Document may or may not show depending on filtering verify the tab loaded
let documentsTab = app.tabBars.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Doc'")
).firstMatch
XCTAssertTrue(documentsTab.isSelected, "Documents tab should be selected")
}
// MARK: - Test 05: Cross-User Document Visibility in UI
func test05_crossUserDocumentVisibleInUI() {
// Join via UI
joinResidenceViaUI()
// Navigate to Documents tab
navigateToDocuments()
// Verify User A's seeded document appears
let docText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", userADocTitle)
).firstMatch
pullToRefreshUntilVisible(docText, maxRetries: 3)
XCTAssertTrue(docText.exists,
"User A's document '\(userADocTitle!)' should be visible to User B in the Documents tab")
}
// MARK: - Test 06: Join Button Disabled With Short Code
func test06_joinResidenceButtonDisabledWithShortCode() {
navigateToResidences()
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button should exist"); return
}
joinButton.tap()
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field should appear"); return
}
// Type only 3 characters
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("ABC")
let joinAction = app.buttons["JoinResidence.JoinButton"]
XCTAssertTrue(joinAction.exists, "Join button should exist")
XCTAssertFalse(joinAction.isEnabled, "Join button should be disabled with < 6 chars")
// Dismiss
let dismissButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'xmark'")
).firstMatch
if dismissButton.exists { dismissButton.tap() }
}
// MARK: - Test 07: Invalid Code Shows Error
func test07_joinWithInvalidCodeShowsError() {
navigateToResidences()
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button should exist"); return
}
joinButton.tap()
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field should appear"); return
}
// Type an invalid 6-char code
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText("ZZZZZZ")
let joinAction = app.buttons["JoinResidence.JoinButton"]
joinAction.tap()
// Wait for API response - either error text appears or we stay on join screen
let errorText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] 'error' OR label CONTAINS[c] 'invalid' OR label CONTAINS[c] 'not found' OR label CONTAINS[c] 'expired'")
).firstMatch
_ = errorText.waitForExistence(timeout: defaultTimeout)
// Should show an error message (code field should still be visible = still on join screen)
let stillOnJoinScreen = codeField.exists
XCTAssertTrue(errorText.exists || stillOnJoinScreen,
"Should show error or remain on join screen with invalid code")
// Dismiss
let dismissButton = app.buttons.containing(
NSPredicate(format: "label CONTAINS[c] 'Cancel' OR label CONTAINS[c] 'xmark'")
).firstMatch
if dismissButton.exists { dismissButton.tap() }
}
// MARK: - Test 08: Residence Detail Shows After Join
func test08_residenceDetailAccessibleAfterJoin() {
// Join via UI
joinResidenceViaUI()
// Find and tap the shared residence in the list
let residenceText = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
pullToRefreshUntilVisible(residenceText, maxRetries: 3)
XCTAssertTrue(residenceText.exists,
"Shared residence '\(sharedResidenceName!)' should appear in Residences list")
residenceText.tap()
// Verify the residence detail view loads and shows the residence name
let detailTitle = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] %@", sharedResidenceName)
).firstMatch
XCTAssertTrue(detailTitle.waitForExistence(timeout: defaultTimeout),
"Residence detail should display the residence name '\(sharedResidenceName!)'")
// Look for indicators of multiple users (e.g. "2 users", "Members", user list)
let multiUserIndicator = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[c] '2 user' OR label CONTAINS[c] '2 member' OR label CONTAINS[c] 'Members' OR label CONTAINS[c] 'Manage Users' OR label CONTAINS[c] 'Users'")
).firstMatch
// If a user count or members section is visible, verify it
if multiUserIndicator.waitForExistence(timeout: 5) {
XCTAssertTrue(multiUserIndicator.exists,
"Residence detail should show information about multiple users")
}
// If no explicit user indicator is visible (non-owner may not see Manage Users),
// the test still passes because we verified the residence detail loaded successfully.
}
// MARK: - Helpers
/// Find the join residence button in the toolbar
private func findJoinButton() -> XCUIElement {
// Look for the person.badge.plus button in the navigation bar
let navButtons = app.navigationBars.buttons
for i in 0..<navButtons.count {
let button = navButtons.element(boundBy: i)
if button.label.contains("person.badge.plus") || button.label.contains("Join") {
return button
}
}
// Fallback: any button with person.badge.plus
return app.buttons.containing(
NSPredicate(format: "label CONTAINS 'person.badge.plus'")
).firstMatch
}
/// Join the shared residence via the UI (type share code, tap join).
/// After joining, verifies the join sheet dismissed and returns to the Residences list.
private func joinResidenceViaUI() {
navigateToResidences()
let joinButton = findJoinButton()
guard joinButton.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Join button not found"); return
}
joinButton.tap()
let codeField = app.textFields["JoinResidence.ShareCodeField"]
guard codeField.waitForExistence(timeout: defaultTimeout) else {
XCTFail("Share code field not found"); return
}
codeField.tap()
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
codeField.typeText(shareCode)
let joinAction = app.buttons["JoinResidence.JoinButton"]
guard joinAction.waitForExistence(timeout: defaultTimeout), joinAction.isEnabled else {
XCTFail("Join button not enabled"); return
}
joinAction.tap()
// After join, wait for the sheet to dismiss
_ = codeField.waitForNonExistence(timeout: loginTimeout)
// List should refresh
pullToRefresh()
}
}