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! private var cleanerA: TestDataCleaner! private var cleanerB: TestDataCleaner! 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 { XCTFail("Could not create User A"); return } userA = a cleanerA = TestDataCleaner(token: a.token) guard let b = TestAccountAPIClient.createVerifiedAccount( username: "sharer_b_\(runId)", email: "sharer_b_\(runId)@test.com", password: "TestPass123!" ) else { XCTFail("Could not create User B"); return } userB = b cleanerB = TestDataCleaner(token: b.token) } override func tearDownWithError() throws { // Clean up any resources tracked during tests (handles mid-test failures) cleanerA?.cleanAll() cleanerB?.cleanAll() try super.tearDownWithError() } // 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 { XCTFail("Could not create User C"); return } 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). private enum SetupError: Error { case failed(String) } @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 SetupError.failed("No residence") } guard let shareCode = TestAccountAPIClient.generateShareCode( token: userA.token, residenceId: residence.id ) else { XCTFail("Should generate share code"); throw SetupError.failed("No share code") } guard TestAccountAPIClient.joinWithCode(token: userB.token, code: shareCode.code) != nil else { XCTFail("User B should join"); throw SetupError.failed("Join failed") } return (residence.id, shareCode.code) } }