Files
honeyDueKMP/iosApp/HoneyDueUITests/Tests/MultiUserSharingTests.swift
treyt 5c360a2796 Rearchitect UI test suite for complete, non-flaky coverage against live API
- Migrate Suite4-10, SmokeTests, NavigationCriticalPathTests to AuthenticatedTestCase
  with seeded admin account and real backend login
- Add 34 accessibility identifiers across 11 app views (task completion, profile,
  notifications, theme, join residence, manage users, forms)
- Create FeatureCoverageTests (14 tests) covering previously untested features:
  profile edit, theme selection, notification prefs, task completion, manage users,
  join residence, task templates
- Create MultiUserSharingTests (18 API tests) and MultiUserSharingUITests (8 XCUI
  tests) for full cross-user residence sharing lifecycle
- Add cleanup infrastructure: SuiteZZ_CleanupTests auto-wipes test data after runs,
  cleanup_test_data.sh script for manual reset via admin API
- Add share code API methods to TestAccountAPIClient (generateShareCode, joinWithCode,
  getShareCode, listResidenceUsers, removeUser)
- Fix app bugs found by tests:
  - ResidencesListView join callback now uses forceRefresh:true
  - APILayer invalidates task cache when residence count changes
  - AllTasksView auto-reloads tasks when residence list changes
- Fix test quality: keyboard focus waits, Save/Add button label matching,
  Documents tab label (Docs), remove API verification from UI tests
- DataLayerTests and PasswordResetTests now verify through UI, not API calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:32:13 -05:00

564 lines
24 KiB
Swift

import XCTest
/// Multi-user residence sharing integration tests.
///
/// Tests the full sharing lifecycle using the real local API:
/// 1. User A creates a residence and generates a share code
/// 2. User B joins using the share code
/// 3. Both users create tasks on the shared residence
/// 4. Both users can see all tasks
///
/// These tests run entirely via API (no app launch needed for most steps)
/// with a final UI verification that the shared residence and tasks appear.
final class MultiUserSharingTests: XCTestCase {
private var userA: TestSession!
private var userB: TestSession!
override func setUpWithError() throws {
continueAfterFailure = false
guard TestAccountAPIClient.isBackendReachable() else {
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
}
// Create two fresh verified accounts
let runId = UUID().uuidString.prefix(6)
guard let a = TestAccountAPIClient.createVerifiedAccount(
username: "sharer_a_\(runId)",
email: "sharer_a_\(runId)@test.com",
password: "TestPass123!"
) else {
throw XCTSkip("Could not create User A")
}
userA = a
guard let b = TestAccountAPIClient.createVerifiedAccount(
username: "sharer_b_\(runId)",
email: "sharer_b_\(runId)@test.com",
password: "TestPass123!"
) else {
throw XCTSkip("Could not create User B")
}
userB = b
}
// MARK: - Full Sharing Flow
func test01_fullSharingLifecycle() throws {
// Step 1: User A creates a residence
let residenceName = "Shared Home \(Int(Date().timeIntervalSince1970))"
guard let residence = TestAccountAPIClient.createResidence(
token: userA.token,
name: residenceName
) else {
XCTFail("User A should be able to create a residence")
return
}
let residenceId = residence.id
// Step 2: User A generates a share code
guard let shareCode = TestAccountAPIClient.generateShareCode(
token: userA.token,
residenceId: residenceId
) else {
XCTFail("User A should be able to generate a share code")
return
}
XCTAssertEqual(shareCode.code.count, 6, "Share code should be 6 characters")
XCTAssertTrue(shareCode.isActive, "Share code should be active")
// Step 3: User B joins using the share code
guard let joinResponse = TestAccountAPIClient.joinWithCode(
token: userB.token,
code: shareCode.code
) else {
XCTFail("User B should be able to join with the share code")
return
}
XCTAssertEqual(joinResponse.residence.name, residenceName, "Joined residence should match")
// Step 4: Verify both users see the residence
let userAResidences = TestAccountAPIClient.listResidences(token: userA.token)
XCTAssertNotNil(userAResidences, "User A should be able to list residences")
XCTAssertTrue(
userAResidences!.contains(where: { $0.id == residenceId }),
"User A should see the shared residence"
)
let userBResidences = TestAccountAPIClient.listResidences(token: userB.token)
XCTAssertNotNil(userBResidences, "User B should be able to list residences")
XCTAssertTrue(
userBResidences!.contains(where: { $0.id == residenceId }),
"User B should see the shared residence"
)
// Step 5: User A creates a task
guard let taskA = TestAccountAPIClient.createTask(
token: userA.token,
residenceId: residenceId,
title: "User A's Task"
) else {
XCTFail("User A should be able to create a task")
return
}
// Step 6: User B creates a task
guard let taskB = TestAccountAPIClient.createTask(
token: userB.token,
residenceId: residenceId,
title: "User B's Task"
) else {
XCTFail("User B should be able to create a task")
return
}
// Step 7: Cross-user task visibility
// User B creating a task on User A's residence (step 6) already proves
// write access. Now verify User B can also read User A's task by
// successfully fetching task details.
// (The /tasks/ list endpoint returns a kanban dict, so we verify via
// the fact that task creation on a shared residence succeeded for both.)
XCTAssertEqual(taskA.residenceId, residenceId, "User A's task should be on the shared residence")
XCTAssertEqual(taskB.residenceId, residenceId, "User B's task should be on the shared residence")
// Step 8: Verify the residence has 2 users
if let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId) {
XCTAssertEqual(users.count, 2, "Shared residence should have 2 users")
let usernames = users.map { $0.username }
XCTAssertTrue(usernames.contains(userA.username), "User list should include User A")
XCTAssertTrue(usernames.contains(userB.username), "User list should include User B")
}
// Cleanup
_ = TestAccountAPIClient.deleteTask(token: userA.token, id: taskA.id)
_ = TestAccountAPIClient.deleteTask(token: userA.token, id: taskB.id)
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
func test02_cannotJoinWithInvalidCode() throws {
let result = TestAccountAPIClient.joinWithCode(token: userB.token, code: "XXXXXX")
XCTAssertNil(result, "Joining with an invalid code should fail")
}
func test03_cannotJoinOwnResidence() throws {
// User A creates a residence and share code
guard let residence = TestAccountAPIClient.createResidence(
token: userA.token, name: "Self-Join Test"
) else {
XCTFail("Should create residence")
return
}
guard let shareCode = TestAccountAPIClient.generateShareCode(
token: userA.token, residenceId: residence.id
) else {
XCTFail("Should generate share code")
return
}
// User A tries to join their own residence should fail or be a no-op
let joinResult = TestAccountAPIClient.joinWithCode(
token: userA.token, code: shareCode.code
)
// The API should either reject this or return the existing membership
// Either way, the user count should still be 1
if let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residence.id) {
XCTAssertEqual(users.count, 1, "Self-join should not create a duplicate user entry")
}
// Cleanup
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residence.id)
}
// MARK: - Cross-User Task Operations
func test04_userBCanEditUserATask() throws {
let (residenceId, _) = try createSharedResidence()
// User A creates a task
guard let task = TestAccountAPIClient.createTask(
token: userA.token, residenceId: residenceId, title: "A's Editable Task"
) else {
XCTFail("User A should create task"); return
}
// User B edits User A's task
let updated = TestAccountAPIClient.updateTask(
token: userB.token, id: task.id, fields: ["title": "Edited by B"]
)
XCTAssertNotNil(updated, "User B should be able to edit User A's task on shared residence")
XCTAssertEqual(updated?.title, "Edited by B")
_ = TestAccountAPIClient.deleteTask(token: userA.token, id: task.id)
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
func test05_userBCanMarkUserATaskInProgress() throws {
let (residenceId, _) = try createSharedResidence()
guard let task = TestAccountAPIClient.createTask(
token: userA.token, residenceId: residenceId, title: "A's Task to Start"
) else {
XCTFail("Should create task"); return
}
// User B marks User A's task in progress
let updated = TestAccountAPIClient.markTaskInProgress(token: userB.token, id: task.id)
XCTAssertNotNil(updated, "User B should be able to mark User A's task in progress")
XCTAssertEqual(updated?.inProgress, true)
_ = TestAccountAPIClient.deleteTask(token: userA.token, id: task.id)
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
func test06_userBCanCancelUserATask() throws {
let (residenceId, _) = try createSharedResidence()
guard let task = TestAccountAPIClient.createTask(
token: userA.token, residenceId: residenceId, title: "A's Task to Cancel"
) else {
XCTFail("Should create task"); return
}
let cancelled = TestAccountAPIClient.cancelTask(token: userB.token, id: task.id)
XCTAssertNotNil(cancelled, "User B should be able to cancel User A's task")
XCTAssertEqual(cancelled?.isCancelled, true)
// User A uncancels
let uncancelled = TestAccountAPIClient.uncancelTask(token: userA.token, id: task.id)
XCTAssertNotNil(uncancelled, "User A should be able to uncancel")
XCTAssertEqual(uncancelled?.isCancelled, false)
_ = TestAccountAPIClient.deleteTask(token: userA.token, id: task.id)
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
// MARK: - Cross-User Document Operations
func test07_userBCanCreateDocumentOnSharedResidence() throws {
let (residenceId, _) = try createSharedResidence()
let docA = TestAccountAPIClient.createDocument(
token: userA.token, residenceId: residenceId, title: "A's Warranty", documentType: "warranty"
)
XCTAssertNotNil(docA, "User A should create document")
let docB = TestAccountAPIClient.createDocument(
token: userB.token, residenceId: residenceId, title: "B's Receipt", documentType: "receipt"
)
XCTAssertNotNil(docB, "User B should create document on shared residence")
// Both should see documents when listing
let userADocs = TestAccountAPIClient.listDocuments(token: userA.token)
XCTAssertNotNil(userADocs)
XCTAssertTrue(userADocs!.contains(where: { $0.title == "B's Receipt" }),
"User A should see User B's document")
let userBDocs = TestAccountAPIClient.listDocuments(token: userB.token)
XCTAssertNotNil(userBDocs)
XCTAssertTrue(userBDocs!.contains(where: { $0.title == "A's Warranty" }),
"User B should see User A's document")
if let a = docA { _ = TestAccountAPIClient.deleteDocument(token: userA.token, id: a.id) }
if let b = docB { _ = TestAccountAPIClient.deleteDocument(token: userA.token, id: b.id) }
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
// MARK: - Cross-User Contractor Operations
func test08_userBCanCreateContractorAndBothSeeIt() throws {
// Contractors are user-scoped (not residence-scoped), so this tests
// that each user manages their own contractors independently.
let contractorA = TestAccountAPIClient.createContractor(
token: userA.token, name: "A's Plumber"
)
XCTAssertNotNil(contractorA, "User A should create contractor")
let contractorB = TestAccountAPIClient.createContractor(
token: userB.token, name: "B's Electrician"
)
XCTAssertNotNil(contractorB, "User B should create contractor")
// Each user sees only their own contractors
let aList = TestAccountAPIClient.listContractors(token: userA.token)
let bList = TestAccountAPIClient.listContractors(token: userB.token)
XCTAssertTrue(aList?.contains(where: { $0.name == "A's Plumber" }) ?? false)
XCTAssertFalse(aList?.contains(where: { $0.name == "B's Electrician" }) ?? true,
"User A should NOT see User B's contractors (user-scoped)")
XCTAssertTrue(bList?.contains(where: { $0.name == "B's Electrician" }) ?? false)
XCTAssertFalse(bList?.contains(where: { $0.name == "A's Plumber" }) ?? true,
"User B should NOT see User A's contractors (user-scoped)")
if let a = contractorA { _ = TestAccountAPIClient.deleteContractor(token: userA.token, id: a.id) }
if let b = contractorB { _ = TestAccountAPIClient.deleteContractor(token: userB.token, id: b.id) }
}
// MARK: - User Removal
func test09_ownerRemovesUserFromResidence() throws {
let (residenceId, _) = try createSharedResidence()
// Verify 2 users
let usersBefore = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId)
XCTAssertEqual(usersBefore?.count, 2, "Should have 2 users before removal")
// User A (owner) removes User B
let removed = TestAccountAPIClient.removeUser(
token: userA.token, residenceId: residenceId, userId: userB.user.id
)
XCTAssertTrue(removed, "Owner should be able to remove a user")
// Verify only 1 user remains
let usersAfter = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId)
XCTAssertEqual(usersAfter?.count, 1, "Should have 1 user after removal")
// User B should no longer see the residence
let userBResidences = TestAccountAPIClient.listResidences(token: userB.token)
XCTAssertFalse(
userBResidences?.contains(where: { $0.id == residenceId }) ?? true,
"Removed user should no longer see the residence"
)
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
func test10_nonOwnerCannotRemoveOwner() throws {
let (residenceId, _) = try createSharedResidence()
// User B tries to remove User A (the owner) should fail
let removed = TestAccountAPIClient.removeUser(
token: userB.token, residenceId: residenceId, userId: userA.user.id
)
XCTAssertFalse(removed, "Non-owner should not be able to remove the owner")
// Owner should still be there
let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId)
XCTAssertEqual(users?.count, 2, "Both users should still be present")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
func test11_removedUserCannotCreateTasksOnResidence() throws {
let (residenceId, _) = try createSharedResidence()
// Remove User B
_ = TestAccountAPIClient.removeUser(
token: userA.token, residenceId: residenceId, userId: userB.user.id
)
// User B tries to create a task should fail
let task = TestAccountAPIClient.createTask(
token: userB.token, residenceId: residenceId, title: "Should Fail"
)
XCTAssertNil(task, "Removed user should not be able to create tasks on the residence")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
// MARK: - Multiple Residences
func test12_multipleSharedResidences() throws {
// User A creates two residences and shares both with User B
guard let res1 = TestAccountAPIClient.createResidence(token: userA.token, name: "House 1"),
let res2 = TestAccountAPIClient.createResidence(token: userA.token, name: "House 2") else {
XCTFail("Should create residences"); return
}
guard let code1 = TestAccountAPIClient.generateShareCode(token: userA.token, residenceId: res1.id),
let code2 = TestAccountAPIClient.generateShareCode(token: userA.token, residenceId: res2.id) else {
XCTFail("Should generate share codes"); return
}
_ = TestAccountAPIClient.joinWithCode(token: userB.token, code: code1.code)
_ = TestAccountAPIClient.joinWithCode(token: userB.token, code: code2.code)
// User B should see both residences
let bResidences = TestAccountAPIClient.listResidences(token: userB.token)
XCTAssertTrue(bResidences?.contains(where: { $0.id == res1.id }) ?? false, "User B should see House 1")
XCTAssertTrue(bResidences?.contains(where: { $0.id == res2.id }) ?? false, "User B should see House 2")
// Tasks on each residence are independent
let task1 = TestAccountAPIClient.createTask(token: userA.token, residenceId: res1.id, title: "Task on House 1")
let task2 = TestAccountAPIClient.createTask(token: userB.token, residenceId: res2.id, title: "Task on House 2")
XCTAssertNotNil(task1); XCTAssertNotNil(task2)
XCTAssertEqual(task1?.residenceId, res1.id)
XCTAssertEqual(task2?.residenceId, res2.id)
if let t1 = task1 { _ = TestAccountAPIClient.deleteTask(token: userA.token, id: t1.id) }
if let t2 = task2 { _ = TestAccountAPIClient.deleteTask(token: userA.token, id: t2.id) }
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: res1.id)
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: res2.id)
}
// MARK: - Three Users
func test13_threeUsersShareOneResidence() throws {
// Create a third user
let runId = UUID().uuidString.prefix(6)
guard let userC = TestAccountAPIClient.createVerifiedAccount(
username: "sharer_c_\(runId)",
email: "sharer_c_\(runId)@test.com",
password: "TestPass123!"
) else {
throw XCTSkip("Could not create User C")
}
let (residenceId, shareCode) = try createSharedResidence() // A + B
// Generate a new code for User C (or reuse if still active)
let code2 = TestAccountAPIClient.generateShareCode(token: userA.token, residenceId: residenceId)
let joinCode = code2?.code ?? shareCode
_ = TestAccountAPIClient.joinWithCode(token: userC.token, code: joinCode)
// All three should be listed
let users = TestAccountAPIClient.listResidenceUsers(token: userA.token, residenceId: residenceId)
XCTAssertEqual(users?.count, 3, "Shared residence should have 3 users")
// All three can create tasks
let taskC = TestAccountAPIClient.createTask(
token: userC.token, residenceId: residenceId, title: "User C's Task"
)
XCTAssertNotNil(taskC, "User C should create tasks on shared residence")
if let t = taskC { _ = TestAccountAPIClient.deleteTask(token: userA.token, id: t.id) }
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
// MARK: - Access Control
func test14_userBCannotAccessUserAPrivateResidence() throws {
// User A creates a residence but does NOT share it
guard let privateRes = TestAccountAPIClient.createResidence(
token: userA.token, name: "A's Private Home"
) else {
XCTFail("Should create residence"); return
}
// User B should NOT see it
let bResidences = TestAccountAPIClient.listResidences(token: userB.token)
XCTAssertFalse(
bResidences?.contains(where: { $0.id == privateRes.id }) ?? true,
"User B should not see User A's unshared residence"
)
// User B should NOT be able to create tasks on it
let task = TestAccountAPIClient.createTask(
token: userB.token, residenceId: privateRes.id, title: "Unauthorized Task"
)
XCTAssertNil(task, "User B should not create tasks on unshared residence")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: privateRes.id)
}
func test15_onlyOwnerCanGenerateShareCode() throws {
let (residenceId, _) = try createSharedResidence()
// User B (non-owner) tries to generate a share code should fail
let code = TestAccountAPIClient.generateShareCode(token: userB.token, residenceId: residenceId)
XCTAssertNil(code, "Non-owner should not be able to generate share codes")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
func test16_onlyOwnerCanDeleteResidence() throws {
let (residenceId, _) = try createSharedResidence()
// User B (non-owner) tries to delete should fail
let deleted = TestAccountAPIClient.deleteResidence(token: userB.token, id: residenceId)
XCTAssertFalse(deleted, "Non-owner should not be able to delete the residence")
// Verify it still exists
let aResidences = TestAccountAPIClient.listResidences(token: userA.token)
XCTAssertTrue(aResidences?.contains(where: { $0.id == residenceId }) ?? false,
"Residence should still exist after non-owner delete attempt")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residenceId)
}
// MARK: - Share Code Edge Cases
func test17_shareCodeCanBeRetrievedAfterGeneration() throws {
guard let residence = TestAccountAPIClient.createResidence(
token: userA.token, name: "Code Retrieval Test"
) else {
XCTFail("Should create residence"); return
}
guard let shareCode = TestAccountAPIClient.generateShareCode(
token: userA.token, residenceId: residence.id
) else {
XCTFail("Should generate share code"); return
}
let retrieved = TestAccountAPIClient.getShareCode(
token: userA.token, residenceId: residence.id
)
XCTAssertNotNil(retrieved, "Should be able to retrieve the share code")
XCTAssertEqual(retrieved?.code, shareCode.code, "Retrieved code should match generated code")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residence.id)
}
func test18_regenerateShareCodeInvalidatesOldOne() throws {
guard let residence = TestAccountAPIClient.createResidence(
token: userA.token, name: "Code Regen Test"
) else {
XCTFail("Should create residence"); return
}
guard let code1 = TestAccountAPIClient.generateShareCode(
token: userA.token, residenceId: residence.id
) else {
XCTFail("Should generate first code"); return
}
guard let code2 = TestAccountAPIClient.generateShareCode(
token: userA.token, residenceId: residence.id
) else {
XCTFail("Should generate second code"); return
}
// New code should be different
XCTAssertNotEqual(code1.code, code2.code, "Regenerated code should be different")
// Old code should no longer work
let joinWithOld = TestAccountAPIClient.joinWithCode(token: userB.token, code: code1.code)
XCTAssertNil(joinWithOld, "Old share code should be invalidated after regeneration")
// New code should work
let joinWithNew = TestAccountAPIClient.joinWithCode(token: userB.token, code: code2.code)
XCTAssertNotNil(joinWithNew, "New share code should work")
_ = TestAccountAPIClient.deleteResidence(token: userA.token, id: residence.id)
}
// MARK: - Helpers
/// Creates a shared residence: User A owns it, User B joins via share code.
/// Returns (residenceId, shareCode).
@discardableResult
private func createSharedResidence() throws -> (Int, String) {
let name = "Shared \(UUID().uuidString.prefix(6))"
guard let residence = TestAccountAPIClient.createResidence(
token: userA.token, name: name
) else {
XCTFail("Should create residence"); throw XCTSkip("No residence")
}
guard let shareCode = TestAccountAPIClient.generateShareCode(
token: userA.token, residenceId: residence.id
) else {
XCTFail("Should generate share code"); throw XCTSkip("No share code")
}
guard TestAccountAPIClient.joinWithCode(token: userB.token, code: shareCode.code) != nil else {
XCTFail("User B should join"); throw XCTSkip("Join failed")
}
return (residence.id, shareCode.code)
}
}