| 1 | // Tests for the saved-spaces list helpers (star + independent sort), run in Node. |
| 2 | // Same harness style as home/test/crypto.test.mjs. |
| 3 | // node home/test/serverlist.test.mjs |
| 4 | import assert from "node:assert/strict"; |
| 5 | import { |
| 6 | partitionServers, |
| 7 | partitionArchived, |
| 8 | spaceRestoreURL, |
| 9 | setArchived, |
| 10 | toggleStar, |
| 11 | moveInGroup, |
| 12 | orderServers, |
| 13 | filterServers, |
| 14 | fuzzyMatch, |
| 15 | soleAdminHold, |
| 16 | addTombstone, |
| 17 | clearTombstone, |
| 18 | tombstonedAt, |
| 19 | mergeRemoved, |
| 20 | pruneTombstones, |
| 21 | } from "../web/static/serverlist.js"; |
| 22 | |
| 23 | let failures = 0; |
| 24 | async function test(name, fn) { |
| 25 | try { |
| 26 | await fn(); |
| 27 | console.log("PASS " + name); |
| 28 | } catch (e) { |
| 29 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 30 | failures++; |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | const urls = (list) => list.map((s) => s.url); |
| 35 | |
| 36 | await test("space links request automatic restore and preserve their query", () => { |
| 37 | assert.equal(spaceRestoreURL("https://chat.example.com"), "https://chat.example.com/?restore=1"); |
| 38 | assert.equal(spaceRestoreURL("https://chat.example.com/?thread=7"), "https://chat.example.com/?thread=7&restore=1"); |
| 39 | assert.equal(spaceRestoreURL("not a url"), "not a url"); |
| 40 | }); |
| 41 | |
| 42 | await test("partitionServers splits starred from the rest, keeping array order", () => { |
| 43 | const servers = [ |
| 44 | { url: "a", starred: true }, |
| 45 | { url: "b" }, |
| 46 | { url: "c", starred: true }, |
| 47 | { url: "d" }, |
| 48 | ]; |
| 49 | const { starred, others } = partitionServers(servers); |
| 50 | assert.deepEqual(urls(starred), ["a", "c"]); |
| 51 | assert.deepEqual(urls(others), ["b", "d"]); |
| 52 | }); |
| 53 | |
| 54 | await test("partitionServers tolerates null/empty", () => { |
| 55 | assert.deepEqual(partitionServers(null), { starred: [], others: [] }); |
| 56 | assert.deepEqual(partitionServers([]), { starred: [], others: [] }); |
| 57 | }); |
| 58 | |
| 59 | await test("toggleStar adds then removes the flag, and doesn't mutate the input", () => { |
| 60 | const servers = [{ url: "a" }, { url: "b" }]; |
| 61 | const on = toggleStar(servers, "a"); |
| 62 | assert.equal(on.find((s) => s.url === "a").starred, true); |
| 63 | assert.equal(servers[0].starred, undefined, "input array/entry must be untouched"); |
| 64 | |
| 65 | const off = toggleStar(on, "a"); |
| 66 | assert.equal("starred" in off.find((s) => s.url === "a"), false, "unstarred entry carries no flag"); |
| 67 | }); |
| 68 | |
| 69 | await test("starring sends the item to the END of the Starred list", () => { |
| 70 | // x, y already starred; star 'b' → it lands after them (end of Starred). |
| 71 | const servers = [ |
| 72 | { url: "x", starred: true }, |
| 73 | { url: "y", starred: true }, |
| 74 | { url: "a" }, |
| 75 | { url: "b" }, |
| 76 | { url: "c" }, |
| 77 | ]; |
| 78 | const { starred, others } = partitionServers(toggleStar(servers, "b")); |
| 79 | assert.deepEqual(urls(starred), ["x", "y", "b"], "b appended to Starred"); |
| 80 | assert.deepEqual(urls(others), ["a", "c"], "others keep order minus b"); |
| 81 | }); |
| 82 | |
| 83 | await test("unstarring sends the item to the TOP of the rest", () => { |
| 84 | // x, y starred; unstar 'x' → it becomes the FIRST of the rest. |
| 85 | const servers = [ |
| 86 | { url: "x", starred: true }, |
| 87 | { url: "y", starred: true }, |
| 88 | { url: "a" }, |
| 89 | { url: "b" }, |
| 90 | ]; |
| 91 | const { starred, others } = partitionServers(toggleStar(servers, "x")); |
| 92 | assert.deepEqual(urls(starred), ["y"], "x left Starred"); |
| 93 | assert.deepEqual(urls(others), ["x", "a", "b"], "x is now the top of the rest"); |
| 94 | }); |
| 95 | |
| 96 | await test("toggleStar leaves unknown urls unchanged", () => { |
| 97 | const servers = [{ url: "a", name: "A" }]; |
| 98 | assert.deepEqual(toggleStar(servers, "zzz"), servers); |
| 99 | }); |
| 100 | |
| 101 | await test("moveInGroup reorders within a group and never disturbs the other", () => { |
| 102 | // starred: [a, c] others: [b, d] (interleaved in the master array) |
| 103 | const servers = [ |
| 104 | { url: "a", starred: true }, |
| 105 | { url: "b" }, |
| 106 | { url: "c", starred: true }, |
| 107 | { url: "d" }, |
| 108 | ]; |
| 109 | // Move starred 'c' up: it swaps with 'a' (its nearest starred neighbour), |
| 110 | // jumping over the non-starred 'b'. Others keep their order. |
| 111 | const moved = moveInGroup(servers, "c", -1); |
| 112 | assert.deepEqual(urls(partitionServers(moved).starred), ["c", "a"]); |
| 113 | assert.deepEqual(urls(partitionServers(moved).others), ["b", "d"], "other list unchanged"); |
| 114 | }); |
| 115 | |
| 116 | await test("moveInGroup down within the 'others' group skips over starred entries", () => { |
| 117 | const servers = [ |
| 118 | { url: "b" }, |
| 119 | { url: "a", starred: true }, |
| 120 | { url: "d" }, |
| 121 | ]; |
| 122 | const moved = moveInGroup(servers, "b", +1); // b swaps with d, over starred a |
| 123 | assert.deepEqual(urls(partitionServers(moved).others), ["d", "b"]); |
| 124 | assert.deepEqual(urls(partitionServers(moved).starred), ["a"]); |
| 125 | }); |
| 126 | |
| 127 | await test("moveInGroup at a group boundary is a no-op", () => { |
| 128 | const servers = [{ url: "a", starred: true }, { url: "b" }]; |
| 129 | assert.deepEqual(moveInGroup(servers, "a", -1), servers, "top of its group can't move up"); |
| 130 | assert.deepEqual(moveInGroup(servers, "b", +1), servers, "bottom of its group can't move down"); |
| 131 | }); |
| 132 | |
| 133 | await test("orderServers reorders to match the given full order, preserving entries", () => { |
| 134 | const servers = [ |
| 135 | { url: "a", starred: true }, |
| 136 | { url: "b" }, |
| 137 | { url: "c", starred: true }, |
| 138 | ]; |
| 139 | // A drag result: starred list became [c, a], others [b]. |
| 140 | const next = orderServers(servers, ["c", "a", "b"]); |
| 141 | assert.deepEqual(urls(next), ["c", "a", "b"]); |
| 142 | assert.equal(next[0].starred, true, "entry objects (with starred) are preserved"); |
| 143 | assert.equal(servers[0].url, "a", "input is not mutated"); |
| 144 | }); |
| 145 | |
| 146 | await test("orderServers ignores unknown urls and keeps unlisted entries at the end", () => { |
| 147 | const servers = [{ url: "a" }, { url: "b" }, { url: "c" }]; |
| 148 | const next = orderServers(servers, ["c", "zzz"]); // 'zzz' unknown; a,b unlisted |
| 149 | assert.deepEqual(urls(next), ["c", "a", "b"]); |
| 150 | }); |
| 151 | |
| 152 | await test("partitionArchived splits archived from active, keeping array order", () => { |
| 153 | const servers = [ |
| 154 | { url: "a", starred: true }, |
| 155 | { url: "b", archived: true }, |
| 156 | { url: "c" }, |
| 157 | { url: "d", archived: true }, |
| 158 | ]; |
| 159 | const { active, archived } = partitionArchived(servers); |
| 160 | assert.deepEqual(urls(active), ["a", "c"]); |
| 161 | assert.deepEqual(urls(archived), ["b", "d"]); |
| 162 | }); |
| 163 | |
| 164 | await test("partitionArchived tolerates null/empty", () => { |
| 165 | assert.deepEqual(partitionArchived(null), { active: [], archived: [] }); |
| 166 | assert.deepEqual(partitionArchived([]), { active: [], archived: [] }); |
| 167 | }); |
| 168 | |
| 169 | await test("setArchived archives: sets the flag, drops starred, doesn't mutate input", () => { |
| 170 | const servers = [{ url: "a", starred: true }, { url: "b" }]; |
| 171 | const next = setArchived(servers, "a", true); |
| 172 | const entry = next.find((s) => s.url === "a"); |
| 173 | assert.equal(entry.archived, true); |
| 174 | assert.equal("starred" in entry, false, "an archived space leaves the rail"); |
| 175 | assert.equal(servers[0].starred, true, "input entry untouched"); |
| 176 | assert.equal("archived" in servers[0], false, "input entry untouched"); |
| 177 | }); |
| 178 | |
| 179 | await test("archiving sends the item to the TOP of the archived group, active kept in order", () => { |
| 180 | // x active, y already archived; archive 'x' → it lands above y (top of archive). |
| 181 | const servers = [ |
| 182 | { url: "s", starred: true }, |
| 183 | { url: "x" }, |
| 184 | { url: "z" }, |
| 185 | { url: "y", archived: true }, |
| 186 | ]; |
| 187 | const { active, archived } = partitionArchived(setArchived(servers, "x", true)); |
| 188 | assert.deepEqual(urls(active), ["s", "z"], "active keeps order minus x"); |
| 189 | assert.deepEqual(urls(archived), ["x", "y"], "x is the top of the archive"); |
| 190 | }); |
| 191 | |
| 192 | await test("unarchiving sends the item to the BOTTOM of the active list", () => { |
| 193 | const servers = [ |
| 194 | { url: "s", starred: true }, |
| 195 | { url: "a" }, |
| 196 | { url: "z", archived: true }, |
| 197 | { url: "y", archived: true }, |
| 198 | ]; |
| 199 | const next = setArchived(servers, "z", false); |
| 200 | const entry = next.find((s) => s.url === "z"); |
| 201 | assert.equal("archived" in entry, false, "unarchived entry carries no flag"); |
| 202 | const { active, archived } = partitionArchived(next); |
| 203 | assert.deepEqual(urls(active), ["s", "a", "z"], "z rejoins at the bottom, after kept spaces"); |
| 204 | assert.deepEqual(urls(archived), ["y"], "y stays archived"); |
| 205 | }); |
| 206 | |
| 207 | await test("setArchived leaves unknown urls unchanged", () => { |
| 208 | const servers = [{ url: "a", name: "A" }]; |
| 209 | assert.deepEqual(setArchived(servers, "zzz", true), servers); |
| 210 | }); |
| 211 | |
| 212 | await test("fuzzyMatch is a case-insensitive subsequence match", () => { |
| 213 | assert.equal(fuzzyMatch("fam", "family space"), true); |
| 214 | assert.equal(fuzzyMatch("fly", "family"), true, "f-l-y appear in order"); |
| 215 | assert.equal(fuzzyMatch("xyz", "family"), false); |
| 216 | assert.equal(fuzzyMatch("", "anything"), true, "empty query matches"); |
| 217 | }); |
| 218 | |
| 219 | await test("filterServers keeps fuzzy matches over name + host, order preserved", () => { |
| 220 | const servers = [ |
| 221 | { url: "https://family.example.com", name: "Family", starred: true }, |
| 222 | { url: "https://work.example.com", name: "Work" }, |
| 223 | { url: "https://a4f.11ch.at" }, |
| 224 | ]; |
| 225 | assert.deepEqual(urls(filterServers(servers, "fam")), ["https://family.example.com"]); |
| 226 | assert.deepEqual(urls(filterServers(servers, "11ch")), ["https://a4f.11ch.at"], "matches the host"); |
| 227 | assert.deepEqual(urls(filterServers(servers, "")), urls(servers), "empty query = everything"); |
| 228 | assert.deepEqual(filterServers(servers, "zzzz"), [], "no matches"); |
| 229 | }); |
| 230 | |
| 231 | await test("soleAdminHold engages only on an explicit sole-admin answer (fail-open)", () => { |
| 232 | assert.equal(soleAdminHold({ role: "admin", sole_admin: true }), true, "the one positive case"); |
| 233 | assert.equal(soleAdminHold({ role: "admin", sole_admin: false }), false, "other admins exist"); |
| 234 | assert.equal(soleAdminHold({ role: "admin" }), false, "older build without the field"); |
| 235 | assert.equal(soleAdminHold({ role: "member", sole_admin: true }), false, "field without the role"); |
| 236 | assert.equal(soleAdminHold({ role: "admin", sole_admin: "true" }), false, "malformed answer"); |
| 237 | assert.equal(soleAdminHold(null), false, "no answer at all"); |
| 238 | }); |
| 239 | |
| 240 | await test("addTombstone upserts newest-per-url without mutating the input", () => { |
| 241 | const removed = [{ url: "a", ts: 100 }]; |
| 242 | const next = addTombstone(removed, "b", 200); |
| 243 | assert.deepEqual( |
| 244 | next.map((t) => t.url).sort(), |
| 245 | ["a", "b"], |
| 246 | ); |
| 247 | assert.equal(removed.length, 1, "input untouched"); |
| 248 | const bumped = addTombstone(next, "a", 300); |
| 249 | assert.equal(tombstonedAt(bumped, "a"), 300, "re-removal keeps the newest ts"); |
| 250 | assert.equal(bumped.filter((t) => t.url === "a").length, 1, "one tombstone per url"); |
| 251 | }); |
| 252 | |
| 253 | await test("clearTombstone removes the url; an emptied list drops out entirely", () => { |
| 254 | const removed = [{ url: "a", ts: 100 }, { url: "b", ts: 200 }]; |
| 255 | assert.deepEqual(clearTombstone(removed, "a").map((t) => t.url), ["b"]); |
| 256 | assert.equal(clearTombstone([{ url: "a", ts: 100 }], "a"), undefined, |
| 257 | "empty list → undefined, so the blob key disappears"); |
| 258 | }); |
| 259 | |
| 260 | await test("tombstonedAt returns the removal time, 0 when absent or malformed", () => { |
| 261 | assert.equal(tombstonedAt([{ url: "a", ts: 42 }], "a"), 42); |
| 262 | assert.equal(tombstonedAt([{ url: "a", ts: 42 }], "b"), 0); |
| 263 | assert.equal(tombstonedAt(null, "a"), 0); |
| 264 | assert.equal(tombstonedAt([{ url: "a", ts: "soon" }], "a"), 0, "malformed ts reads as never"); |
| 265 | }); |
| 266 | |
| 267 | await test("mergeRemoved unions two lists, newest tombstone per url", () => { |
| 268 | const merged = mergeRemoved( |
| 269 | [{ url: "a", ts: 100 }, { url: "b", ts: 500 }], |
| 270 | [{ url: "a", ts: 300 }, { url: "c", ts: 200 }], |
| 271 | ); |
| 272 | assert.equal(tombstonedAt(merged, "a"), 300, "newer side wins"); |
| 273 | assert.equal(tombstonedAt(merged, "b"), 500); |
| 274 | assert.equal(tombstonedAt(merged, "c"), 200); |
| 275 | assert.equal(merged.length, 3); |
| 276 | }); |
| 277 | |
| 278 | await test("pruneTombstones drops year-old entries and caps the list at the newest 200", () => { |
| 279 | const now = 1_000_000_000_000; |
| 280 | const year = 365 * 24 * 60 * 60 * 1000; |
| 281 | const fresh = { url: "fresh", ts: now - 1000 }; |
| 282 | const stale = { url: "stale", ts: now - year - 1 }; |
| 283 | assert.deepEqual(pruneTombstones([fresh, stale], now), [fresh]); |
| 284 | |
| 285 | const many = Array.from({ length: 250 }, (_, i) => ({ url: "u" + i, ts: now - i })); |
| 286 | const capped = pruneTombstones(many, now); |
| 287 | assert.equal(capped.length, 200); |
| 288 | assert.equal(tombstonedAt(capped, "u0"), now, "newest kept"); |
| 289 | assert.equal(tombstonedAt(capped, "u249"), 0, "oldest dropped"); |
| 290 | }); |
| 291 | |
| 292 | console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED"); |
| 293 | process.exit(failures ? 1 : 0); |
| 294 | |