| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // π€ Cross-platform event contract tests for the space-apps registry β the |
| 5 | // native fold (SpaceApps.fold, fed by SpaceAppsStore) must match the web's |
| 6 | // spaceapps reducers exactly. Pinned by test/event-vectors/spaceapps.json; |
| 7 | // the web side runs the same vectors in test/event-vectors.test.mjs. |
| 8 | final class SpaceAppsVectorTests: XCTestCase { |
| 9 | private func fold(seed: [SpaceApps.Item] = [], upTo: Int64 = 0, |
| 10 | _ events: [Int64: SpaceApps.Event]) -> [SpaceApps.Item] { |
| 11 | SpaceApps.fold(seed: seed, upTo: upTo, events: events) |
| 12 | } |
| 13 | |
| 14 | // spaceapps_install: a member installs an app for everyone in the space. |
| 15 | func testInstall() { |
| 16 | let out = fold([1: .init(type: "spaceapps.add", app: "bookmarks", by: "Ana", |
| 17 | ts: 1_700_000_000_000, uid: "u-ana")]) |
| 18 | XCTAssertEqual(out, [SpaceApps.Item(id: "bookmarks", by: "Ana", |
| 19 | ts: 1_700_000_000_000, uid: "u-ana")]) |
| 20 | } |
| 21 | |
| 22 | // spaceapps_install_duplicate: idempotent by app name β the original |
| 23 | // install's attribution (by/ts/uid) is kept, a re-install never rewrites it. |
| 24 | func testDuplicateInstallKeepsOriginalAttribution() { |
| 25 | let out = fold([ |
| 26 | 1: .init(type: "spaceapps.add", app: "bookmarks", by: "Ana", |
| 27 | ts: 1_700_000_000_000, uid: "u-ana"), |
| 28 | 2: .init(type: "spaceapps.add", app: "bookmarks", by: "Bob", |
| 29 | ts: 1_700_000_005_000, uid: "u-bob"), |
| 30 | ]) |
| 31 | XCTAssertEqual(out, [SpaceApps.Item(id: "bookmarks", by: "Ana", |
| 32 | ts: 1_700_000_000_000, uid: "u-ana")]) |
| 33 | } |
| 34 | |
| 35 | // spaceapps_install_unknown_app: an install naming an app this client |
| 36 | // doesn't know still folds β it stays inert at read time, so clients on |
| 37 | // different versions converge on the same log. |
| 38 | func testUnknownAppFoldsIn() { |
| 39 | let seed = [SpaceApps.Item(id: "bookmarks", by: "Ana", |
| 40 | ts: 1_700_000_000_000, uid: "u-ana")] |
| 41 | let out = fold(seed: seed, upTo: 1, [ |
| 42 | 2: .init(type: "spaceapps.add", app: "someday-app", by: "Ana", |
| 43 | ts: 1_700_000_010_000, uid: "u-ana"), |
| 44 | ]) |
| 45 | XCTAssertEqual(out, seed + [SpaceApps.Item(id: "someday-app", by: "Ana", |
| 46 | ts: 1_700_000_010_000, uid: "u-ana")]) |
| 47 | } |
| 48 | |
| 49 | // spaceapps_remove: any member removes a space-wide install. |
| 50 | func testRemove() { |
| 51 | let seed = [SpaceApps.Item(id: "bookmarks", by: "Ana", |
| 52 | ts: 1_700_000_000_000, uid: "u-ana")] |
| 53 | let out = fold(seed: seed, upTo: 1, [ |
| 54 | 2: .init(type: "spaceapps.remove", app: "bookmarks", by: "Bob", |
| 55 | ts: 1_700_000_020_000, uid: ""), |
| 56 | ]) |
| 57 | XCTAssertEqual(out, []) |
| 58 | } |
| 59 | |
| 60 | // spaceapps_remove_absent: removing an app that isn't installed is a no-op |
| 61 | // (a remove can race another member's remove). |
| 62 | func testRemoveAbsentIsNoop() { |
| 63 | let out = fold([1: .init(type: "spaceapps.remove", app: "bookmarks", by: "Bob", |
| 64 | ts: 1_700_000_030_000, uid: "")]) |
| 65 | XCTAssertEqual(out, []) |
| 66 | } |
| 67 | |
| 68 | // spaceapps_add_malformed: a hostile or malformed install (missing app) |
| 69 | // folds to a no-op, never a crash. The wire decode enforces the same rule |
| 70 | // one layer down: no spaceapps.* prefix / no app / undecryptable β nil. |
| 71 | func testMalformedAddIsNoop() { |
| 72 | let out = fold([1: .init(type: "spaceapps.add", app: "", by: "Mallory", |
| 73 | ts: 1_700_000_040_000, uid: "")]) |
| 74 | XCTAssertEqual(out, []) |
| 75 | } |
| 76 | |
| 77 | // Fold order is server-message-id order, whatever order pages arrived in. |
| 78 | func testFoldsInMessageIDOrder() { |
| 79 | let out = fold([ |
| 80 | 3: .init(type: "spaceapps.remove", app: "gif", by: "Ana", ts: 3, uid: "u-ana"), |
| 81 | 1: .init(type: "spaceapps.add", app: "gif", by: "Ana", ts: 1, uid: "u-ana"), |
| 82 | 2: .init(type: "spaceapps.add", app: "bookmarks", by: "Bob", ts: 2, uid: "u-bob"), |
| 83 | ]) |
| 84 | XCTAssertEqual(out, [SpaceApps.Item(id: "bookmarks", by: "Bob", ts: 2, uid: "u-bob")]) |
| 85 | } |
| 86 | |
| 87 | // Events at or below the snapshot horizon are already inside the seed β |
| 88 | // refolding them must not double-apply (apps.js foldInto's `mid <= upTo`). |
| 89 | func testSnapshotHorizonSkipsSeededEvents() { |
| 90 | let seed = [SpaceApps.Item(id: "gif", by: "Ana", ts: 1, uid: "u-ana")] |
| 91 | let out = fold(seed: seed, upTo: 10, [ |
| 92 | 9: .init(type: "spaceapps.remove", app: "gif", by: "Ana", ts: 9, uid: "u-ana"), |
| 93 | 11: .init(type: "spaceapps.add", app: "vault", by: "Bob", ts: 11, uid: "u-bob"), |
| 94 | ]) |
| 95 | XCTAssertEqual(out, [SpaceApps.Item(id: "gif", by: "Ana", ts: 1, uid: "u-ana"), |
| 96 | SpaceApps.Item(id: "vault", by: "Bob", ts: 11, uid: "u-bob")]) |
| 97 | } |
| 98 | |
| 99 | // The slot-"apps" snapshot decode. New snapshots hold the registry's state |
| 100 | // under the unified "installs" key (apps.js installsApp) β tolerant of |
| 101 | // other apps' states sharing the blob. |
| 102 | func testSnapshotParseUnifiedKey() { |
| 103 | let json = """ |
| 104 | {"states":{"gif":{"q":""},"installs":{"apps":[{"id":"gif","by":"Ana","ts":1700000000000,"uid":"u-ana"}]}},"upTo":42} |
| 105 | """.data(using: .utf8)! |
| 106 | let parsed = SpaceApps.parseSnapshot(json) |
| 107 | XCTAssertEqual(parsed?.upTo, 42) |
| 108 | XCTAssertEqual(parsed?.seed, [SpaceApps.Item(id: "gif", by: "Ana", |
| 109 | ts: 1_700_000_000_000, uid: "u-ana")]) |
| 110 | } |
| 111 | |
| 112 | // Snapshots written before the registry unification hold the state under |
| 113 | // the legacy per-family "spaceapps" key β read as a fallback, and a pre-uid |
| 114 | // row's missing uid decodes to "". |
| 115 | func testSnapshotParseLegacyKey() { |
| 116 | let json = """ |
| 117 | {"states":{"spaceapps":{"apps":[{"id":"bookmarks","by":"Ana","ts":7}]}},"upTo":7} |
| 118 | """.data(using: .utf8)! |
| 119 | let parsed = SpaceApps.parseSnapshot(json) |
| 120 | XCTAssertEqual(parsed?.upTo, 7) |
| 121 | XCTAssertEqual(parsed?.seed, [SpaceApps.Item(id: "bookmarks", by: "Ana", ts: 7, uid: "")]) |
| 122 | } |
| 123 | |
| 124 | func testSnapshotParseWithoutRegistryState() { |
| 125 | let json = #"{"states":{"gif":{"q":""}},"upTo":7}"#.data(using: .utf8)! |
| 126 | let parsed = SpaceApps.parseSnapshot(json) |
| 127 | XCTAssertEqual(parsed?.upTo, 7) |
| 128 | XCTAssertEqual(parsed?.seed, []) |
| 129 | } |
| 130 | |
| 131 | // The wire decode: a real encrypted spaceapps.add round-trips into an |
| 132 | // Event; a non-spaceapps type and a missing app both decode to nil (fold |
| 133 | // input is only ever well-formed installs/removes). |
| 134 | func testWireDecode() throws { |
| 135 | let key = try GroupKey(rawKey: Data(repeating: 7, count: 32)) |
| 136 | let add = #"{"type":"spaceapps.add","app":"bookmarks","name":"Ana","ts":1700000000000,"uid":"u-ana"}"# |
| 137 | let blob = try key.encryptData(Data(add.utf8)) |
| 138 | XCTAssertEqual(SpaceApps.event(blob, key: key), |
| 139 | SpaceApps.Event(type: "spaceapps.add", app: "bookmarks", by: "Ana", |
| 140 | ts: 1_700_000_000_000, uid: "u-ana")) |
| 141 | let chat = try key.encryptData(Data(#"{"type":"chat","text":"hi"}"#.utf8)) |
| 142 | XCTAssertNil(SpaceApps.event(chat, key: key)) |
| 143 | let missing = try key.encryptData(Data(#"{"type":"spaceapps.add","name":"Mallory"}"#.utf8)) |
| 144 | XCTAssertNil(SpaceApps.event(missing, key: key)) |
| 145 | } |
| 146 | } |
| 147 | |