| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // π€ The notification-tap resolution ladder (NotificationRoute): NSE origin |
| 5 | // stamp β parked key β snapshot scan β only-space β last-active space. The |
| 6 | // silent-drop bug this pins: a stale or missing SharedThreadKeys park used to |
| 7 | // strand the tap on the chat list. |
| 8 | final class NotificationRouteTests: XCTestCase { |
| 9 | |
| 10 | private func inst(_ origin: String, archived: Bool = false) -> SavedInstance { |
| 11 | var i = SavedInstance(origin: origin, deviceToken: "t", screenName: "s", |
| 12 | userPub: "pub", userPriv: "priv") |
| 13 | i.archived = archived |
| 14 | return i |
| 15 | } |
| 16 | |
| 17 | func testStampedOriginWinsOverEverything() { |
| 18 | // The NSE stamped the banner at delivery time β the route trusts it |
| 19 | // before consulting any on-device state (which can churn between |
| 20 | // delivery and tap). |
| 21 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 22 | let r = NotificationRoute.resolve( |
| 23 | threadID: "th1", origin: "https://b.example", instances: [a, b], |
| 24 | entryOrigin: { _ in XCTFail("park consulted despite stamp"); return "https://a.example" }, |
| 25 | snapshotHasThread: { _, _ in XCTFail("snapshot consulted despite stamp"); return false }) |
| 26 | guard case .stamped(let hit) = r else { return XCTFail("\(r)") } |
| 27 | XCTAssertEqual(hit.origin, b.origin) |
| 28 | } |
| 29 | |
| 30 | func testStaleStampFallsDownTheLadder() { |
| 31 | // A stamp for a space that's since been signed out must not dead-end |
| 32 | // the tap β the ladder still runs beneath it. |
| 33 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 34 | let r = NotificationRoute.resolve( |
| 35 | threadID: "th1", origin: "https://gone.example", instances: [a, b], |
| 36 | entryOrigin: { _ in "https://a.example" }, |
| 37 | snapshotHasThread: { _, _ in false }) |
| 38 | guard case .parkedKey(let hit) = r else { return XCTFail("\(r)") } |
| 39 | XCTAssertEqual(hit.origin, a.origin) |
| 40 | } |
| 41 | |
| 42 | func testParkedKeyWins() { |
| 43 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 44 | let r = NotificationRoute.resolve( |
| 45 | threadID: "th1", instances: [a, b], |
| 46 | entryOrigin: { _ in "https://b.example" }, |
| 47 | snapshotHasThread: { _, _ in XCTFail("snapshot consulted despite park"); return false }) |
| 48 | guard case .parkedKey(let hit) = r else { return XCTFail("\(r)") } |
| 49 | XCTAssertEqual(hit.origin, b.origin) |
| 50 | } |
| 51 | |
| 52 | func testStaleParkFallsBackToSnapshot() { |
| 53 | // The park points at an origin that's no longer signed in (re-added |
| 54 | // space) β the snapshot scan must still find the thread's real home. |
| 55 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 56 | let r = NotificationRoute.resolve( |
| 57 | threadID: "th1", instances: [a, b], |
| 58 | entryOrigin: { _ in "https://gone.example" }, |
| 59 | snapshotHasThread: { origin, thread in origin == "https://b.example" && thread == "th1" }) |
| 60 | guard case .snapshot(let hit) = r else { return XCTFail("\(r)") } |
| 61 | XCTAssertEqual(hit.origin, b.origin) |
| 62 | } |
| 63 | |
| 64 | func testMissingParkFallsBackToSnapshot() { |
| 65 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 66 | let r = NotificationRoute.resolve( |
| 67 | threadID: "th1", instances: [a, b], |
| 68 | entryOrigin: { _ in nil }, |
| 69 | snapshotHasThread: { origin, _ in origin == "https://a.example" }) |
| 70 | guard case .snapshot(let hit) = r else { return XCTFail("\(r)") } |
| 71 | XCTAssertEqual(hit.origin, a.origin) |
| 72 | } |
| 73 | |
| 74 | func testSingleSpaceAlwaysResolves() { |
| 75 | // Nothing recorded anywhere (wiped keychain, brand-new thread): with |
| 76 | // one signed-in space the tap still routes there. |
| 77 | let a = inst("https://a.example") |
| 78 | let r = NotificationRoute.resolve( |
| 79 | threadID: "new-thread", instances: [a], |
| 80 | entryOrigin: { _ in nil }, |
| 81 | snapshotHasThread: { _, _ in false }) |
| 82 | guard case .onlySpace(let hit) = r else { return XCTFail("\(r)") } |
| 83 | XCTAssertEqual(hit.origin, a.origin) |
| 84 | } |
| 85 | |
| 86 | func testSingleActiveSpaceSkipsArchived() { |
| 87 | let live = inst("https://live.example") |
| 88 | let parked = inst("https://old.example", archived: true) |
| 89 | let r = NotificationRoute.resolve( |
| 90 | threadID: "th1", instances: [live, parked], |
| 91 | entryOrigin: { _ in nil }, |
| 92 | snapshotHasThread: { _, _ in false }) |
| 93 | guard case .onlySpace(let hit) = r else { return XCTFail("\(r)") } |
| 94 | XCTAssertEqual(hit.origin, live.origin) |
| 95 | } |
| 96 | |
| 97 | func testMultiSpaceNoEvidenceFallsBackToLastActive() { |
| 98 | // Undecryptable push for a brand-new thread, several spaces: the |
| 99 | // space this device was last in is the optimistic guess β the tap |
| 100 | // opens a chat screen there instead of dropping on the list. |
| 101 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 102 | let r = NotificationRoute.resolve( |
| 103 | threadID: "th1", lastActiveID: "https://b.example", instances: [a, b], |
| 104 | entryOrigin: { _ in nil }, |
| 105 | snapshotHasThread: { _, _ in false }) |
| 106 | guard case .lastActive(let hit) = r else { return XCTFail("\(r)") } |
| 107 | XCTAssertEqual(hit.origin, b.origin) |
| 108 | } |
| 109 | |
| 110 | func testMultiSpaceUnknownThreadStaysUnresolved() { |
| 111 | // No last-active record either (or it names a space that's gone) β |
| 112 | // only then does the tap drop. |
| 113 | let a = inst("https://a.example"), b = inst("https://b.example") |
| 114 | for last in [nil, "https://gone.example"] { |
| 115 | let r = NotificationRoute.resolve( |
| 116 | threadID: "th1", lastActiveID: last, instances: [a, b], |
| 117 | entryOrigin: { _ in nil }, |
| 118 | snapshotHasThread: { _, _ in false }) |
| 119 | guard case .unresolved = r else { return XCTFail("\(r)") } |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |