| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // π€ The joiner-side welcome landing (#1376): SpaceClaim marks a fresh |
| 5 | // member/DM-invite join pending and creates the welcome app-thread, so |
| 6 | // WelcomeLanding hands the thread to the first screen and the bundled flow |
| 7 | // asks the joiner's name. These pin the flag semantics (UserDefaults) and the |
| 8 | // parked-name store that bridges "name accepted" β "instance key arrived". |
| 9 | @MainActor |
| 10 | final class WelcomePendingTests: XCTestCase { |
| 11 | // A unique origin per test so UserDefaults flags never leak across runs. |
| 12 | private func freshInstance() -> SavedInstance { |
| 13 | SavedInstance(origin: "https://test-\(UUID().uuidString).invalid", |
| 14 | deviceToken: "tok", screenName: "", userPub: "pub", userPriv: "priv") |
| 15 | } |
| 16 | |
| 17 | private func welcomeThread(id: String = "t1", hidden: Bool = false, |
| 18 | key: String = "k") -> ChatThread { |
| 19 | ChatThread(id: id, kind: "self", title: "Eleven", preview: "", lastAt: 0, |
| 20 | unread: false, pinned: false, hidden: hidden, muted: nil, |
| 21 | members: ["u1"], threadKeyB64: key, avatar: nil, |
| 22 | isApp: true, appId: "welcome", glyph: nil, badge: nil) |
| 23 | } |
| 24 | |
| 25 | func testMarkPendingArmsAndRearmsTheLanding() async { |
| 26 | let inst = freshInstance() |
| 27 | XCTAssertFalse(WelcomeLanding.isPending(inst)) |
| 28 | |
| 29 | // A previous life of this space (same origin) already landed once β |
| 30 | // a FRESH join must land again, so markPending re-arms the flag. |
| 31 | UserDefaults.standard.set(true, forKey: WelcomeLanding.flagKey(inst)) |
| 32 | WelcomeLanding.markPending(inst) |
| 33 | XCTAssertTrue(WelcomeLanding.isPending(inst)) |
| 34 | XCTAssertFalse(UserDefaults.standard.bool(forKey: WelcomeLanding.flagKey(inst))) |
| 35 | |
| 36 | // The invalid origin means conversationFinished can't answer "done", |
| 37 | // so the thread lands β and only ONCE per device (the landed flag). |
| 38 | let landed = await WelcomeLanding.threadToLand(instance: inst, threads: [welcomeThread()]) |
| 39 | XCTAssertEqual(landed?.id, "t1") |
| 40 | let again = await WelcomeLanding.threadToLand(instance: inst, threads: [welcomeThread()]) |
| 41 | XCTAssertNil(again, "the landing is once per device+instance") |
| 42 | XCTAssertTrue(WelcomeLanding.isPending(inst), |
| 43 | "pending survives the landing β reopening resumes the conversation") |
| 44 | } |
| 45 | |
| 46 | func testThreadToLandSkipsHiddenAndKeylessThreadsAndNonWelcomeApps() async { |
| 47 | let inst = freshInstance() |
| 48 | let hidden = welcomeThread(hidden: true) |
| 49 | let keyless = welcomeThread(key: "") |
| 50 | var other = welcomeThread() |
| 51 | other.appId = "todo" |
| 52 | let noLanding = await WelcomeLanding.threadToLand(instance: inst, |
| 53 | threads: [hidden, keyless, other]) |
| 54 | XCTAssertNil(noLanding) |
| 55 | // And crucially: a no-thread answer must NOT burn the landed flag β |
| 56 | // the claim's thread can appear one refresh later. |
| 57 | let landed = await WelcomeLanding.threadToLand(instance: inst, threads: [welcomeThread()]) |
| 58 | XCTAssertEqual(landed?.id, "t1") |
| 59 | } |
| 60 | |
| 61 | func testPendingLabelParksPeeksAndClears() { |
| 62 | let id = "https://label-\(UUID().uuidString).invalid" |
| 63 | XCTAssertNil(PendingLabel.peek(instanceID: id)) |
| 64 | PendingLabel.park("Ada", instanceID: id) |
| 65 | XCTAssertEqual(PendingLabel.peek(instanceID: id), "Ada") |
| 66 | PendingLabel.park("Grace", instanceID: id) // a newer answer supersedes |
| 67 | XCTAssertEqual(PendingLabel.peek(instanceID: id), "Grace") |
| 68 | PendingLabel.clear(instanceID: id) |
| 69 | XCTAssertNil(PendingLabel.peek(instanceID: id)) |
| 70 | } |
| 71 | |
| 72 | func testPendingLabelTreatsEmptyAsNothingParked() { |
| 73 | let id = "https://label-\(UUID().uuidString).invalid" |
| 74 | PendingLabel.park("", instanceID: id) |
| 75 | XCTAssertNil(PendingLabel.peek(instanceID: id), |
| 76 | "an empty name must never publish as a blank label") |
| 77 | PendingLabel.clear(instanceID: id) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // π€ The admin-side twin: a space claimed through an admin-granting setup |
| 82 | // link lands its admin in Eleven's SETUP conversation β whether the space was |
| 83 | // created natively or on the web and handed over with "Open in the app". |
| 84 | @MainActor |
| 85 | final class SetupPendingTests: XCTestCase { |
| 86 | private func freshInstance() -> SavedInstance { |
| 87 | SavedInstance(origin: "https://setup-\(UUID().uuidString).invalid", |
| 88 | deviceToken: "tok", screenName: "", userPub: "pub", userPriv: "priv") |
| 89 | } |
| 90 | |
| 91 | private func setupThread(id: String = "s1", app: String = "setup") -> ChatThread { |
| 92 | ChatThread(id: id, kind: "self", title: "Eleven", preview: "", lastAt: 0, |
| 93 | unread: false, pinned: false, hidden: false, muted: nil, |
| 94 | members: ["u1"], threadKeyB64: "k", avatar: nil, |
| 95 | isApp: true, appId: app, glyph: nil, badge: nil) |
| 96 | } |
| 97 | |
| 98 | func testSetupLandsOnceWhenTheClaimArmedIt() async { |
| 99 | let inst = freshInstance() |
| 100 | // Unarmed: an admin whose setup was done long ago (before the flow |
| 101 | // recorded a finish marker) must never be dragged back into it. |
| 102 | let unarmed = await SetupLanding.threadToLand(instance: inst, threads: [setupThread()]) |
| 103 | XCTAssertNil(unarmed) |
| 104 | |
| 105 | SetupLanding.markPending(inst) |
| 106 | XCTAssertTrue(SetupLanding.isPending(inst)) |
| 107 | let landed = await SetupLanding.threadToLand(instance: inst, threads: [setupThread()]) |
| 108 | XCTAssertEqual(landed?.id, "s1") |
| 109 | let again = await SetupLanding.threadToLand(instance: inst, threads: [setupThread()]) |
| 110 | XCTAssertNil(again, "the landing is once per device+instance") |
| 111 | } |
| 112 | |
| 113 | func testSetupIgnoresOtherAppThreads() async { |
| 114 | let inst = freshInstance() |
| 115 | SetupLanding.markPending(inst) |
| 116 | let none = await SetupLanding.threadToLand(instance: inst, |
| 117 | threads: [setupThread(app: "welcome")]) |
| 118 | XCTAssertNil(none) |
| 119 | } |
| 120 | } |
| 121 | |