const { test } = require('node:test') const assert = require('node:assert/strict') const uoLinkClient = require('../utils/uoLinkClient') const bridge = require('../utils/clilocBridge') // The walk over `GET /cliloc`, driven against a stubbed sidecar client. // // Everything asserted here is a way the shard can be wrong that leaves the // website holding a table it believes is complete. That is the failure worth // testing, because it is invisible downstream: a truncated cliloc table renders // some items with names and some with ids, which is exactly what NO table looks // like. None of these are hypothetical shapes — each corresponds to a field the // paging envelope carries specifically so this side can tell the difference // (docs/link/v8.md §3.4). const saved = {} function stub({ sources, pages }) { saved.getAssetSources = uoLinkClient.getAssetSources saved.getClilocTable = uoLinkClient.getClilocTable const calls = [] uoLinkClient.getAssetSources = async () => sources uoLinkClient.getClilocTable = async ({ lang, cursor } = {}) => { calls.push({ lang, cursor: cursor ?? null }) const next = pages.shift() if (!next) throw new Error('the walk asked for more pages than the test supplied') return next } return calls } function restore() { if (saved.getAssetSources) uoLinkClient.getAssetSources = saved.getAssetSources if (saved.getClilocTable) uoLinkClient.getClilocTable = saved.getClilocTable } const ok = (data) => ({ ok: true, status: 200, data }) /** One page of rows, with the source fingerprint every page echoes. */ const page = (rows, extra = {}) => ok({ kind: 'cliloc.table.ok', lang: 'enu', file: 'cliloc.enu', size: 4989921, mtime: 1757462400000, total: 3, rows, more: false, cut: 'end', ...extra, }) const sourcesReply = (file = {}) => ok({ kind: 'assets.sources.ok', extractorVersion: 1, imaging: { ok: true }, hashing: false, complete: true, files: [ { name: 'cliloc.enu', path: '/uo/cliloc.enu', size: 4989921, mtime: 1757462400000, sha256: 'abc', ...file }, { name: 'art.mul', path: '/uo/art.mul', size: 148000000, mtime: 1, sha256: null }, ], }) // ── Stage 1: the fingerprint ─────────────────────────────────────────────── test('fingerprint picks the cliloc file out of the client manifest', async (t) => { stub({ sources: sourcesReply(), pages: [] }) t.after(restore) const fp = await bridge.fingerprint() assert.equal(fp.file, 'cliloc.enu') assert.equal(fp.size, 4989921) assert.equal(fp.sha256, 'abc') assert.equal(fp.extractorVersion, 1) }) test('a client with no cliloc file is NO_SOURCE, not a crash', async (t) => { stub({ sources: ok({ extractorVersion: 1, files: [{ name: 'art.mul', size: 1, mtime: 1 }] }), pages: [], }) t.after(restore) await assert.rejects(bridge.fingerprint(), (err) => { assert.equal(err.code, 'NO_SOURCE') return true }) }) test('the asset plane being switched off reads as a refusal, not a bug', async (t) => { stub({ sources: { ok: false, status: 403, data: { reason: 'asset extraction is disabled on this shard' } }, pages: [], }) t.after(restore) await assert.rejects(bridge.fingerprint(), (err) => { assert.equal(err.code, 'DISABLED') return true }) }) // A hash that has not been computed yet is the shard's ordinary first answer: // hashing the 343 MB of art and animation it also serves cannot fit in a 10 s // reply, so it happens off the request path. Treating a null hash as a CHANGE // would make the panel show drift forever on a shard nobody has imported from. test('a missing hash falls back to (size, mtime) rather than reading as drift', () => { const before = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 } const after = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 } assert.equal(bridge.sameSource(before, after), true) assert.equal(bridge.sameSource(before, { ...after, mtime: 21 }), false) }) test('a hash on both sides beats size and mtime, which a patched-in-place file can preserve', () => { const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 } assert.equal(bridge.sameSource(a, { ...a, sha256: 'bbb' }), false) assert.equal(bridge.sameSource(a, { ...a, size: 11, mtime: 99 }), true) }) test('the extractor version is part of the fingerprint, so a corrected reader drifts', () => { const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 } assert.equal(bridge.sameSource(a, { ...a, extractorVersion: 2 }), false) }) // ── Stage 2: the walk ────────────────────────────────────────────────────── test('a one-page table comes back whole', async (t) => { const calls = stub({ sources: sourcesReply(), pages: [page([{ n: 3, f: 0, t: 'c' }, { n: 1, f: 2, t: 'a' }])], }) t.after(restore) const { entries, source } = await bridge.readCliloc() assert.deepEqual(entries, [ { number: 3, flag: 0, text: 'c' }, { number: 1, flag: 2, text: 'a' }, ]) assert.equal(source.pages, 1) assert.equal(source.received, 2) assert.equal(source.reported, 3) assert.deepEqual(calls, [{ lang: 'enu', cursor: null }]) }) test('pages are walked by echoing the cursor back until more is false', async (t) => { const calls = stub({ sources: sourcesReply(), pages: [ page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }), page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:2', cut: 'budget' }), page([{ n: 3, f: 0, t: 'c' }]), ], }) t.after(restore) const { entries, source } = await bridge.readCliloc() assert.equal(entries.length, 3) assert.equal(source.pages, 3) assert.deepEqual( calls.map((c) => c.cursor), [null, 'n:1', 'n:2'], ) }) // `cut` is the field that is easy to omit and expensive not to have. A short // page means the source ended, the byte budget was spent, or the family hit its // own limit — and only the first means finished. test('a last page that did not end the table is refused, not imported', async (t) => { stub({ sources: sourcesReply(), pages: [page([{ n: 1, f: 0, t: 'a' }], { more: false, cut: 'limit' })], }) t.after(restore) await assert.rejects(bridge.readCliloc(), (err) => { assert.equal(err.code, 'INCOMPLETE') return true }) }) test('a shard that does not advance its cursor is stopped rather than spun on', async (t) => { stub({ sources: sourcesReply(), pages: [ page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }), page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:1', cut: 'budget' }), ], }) t.after(restore) await assert.rejects(bridge.readCliloc(), (err) => { assert.equal(err.code, 'STUCK') return true }) }) test('more:true with no cursor at all is the same refusal', async (t) => { stub({ sources: sourcesReply(), pages: [page([{ n: 1, f: 0, t: 'a' }], { more: true, cut: 'budget' })], }) t.after(restore) await assert.rejects(bridge.readCliloc(), (err) => { assert.equal(err.code, 'STUCK') return true }) }) // The one failure a count cannot catch: an operator patches their client while // the import is walking it. Half of what arrived is from a file that no longer // exists, and nothing later can tell which half. test('a client patched mid-walk aborts the whole import', async (t) => { stub({ sources: sourcesReply(), pages: [ page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }), page([{ n: 2, f: 0, t: 'b' }], { size: 5000000, mtime: 1757470000000 }), ], }) t.after(restore) await assert.rejects(bridge.readCliloc(), (err) => { assert.equal(err.code, 'SOURCE_CHANGED') return true }) }) // 425 is flow control and the ORDINARY answer during an import — the shard's // asset plane serves one request at a time on purpose — so it is retried rather // than failed. (The backoff is real time, so this exercises one retry only.) test('a busy shard is retried, because the work is happening', async (t) => { saved.getAssetSources = uoLinkClient.getAssetSources saved.getClilocTable = uoLinkClient.getClilocTable t.after(restore) let attempts = 0 uoLinkClient.getAssetSources = async () => sourcesReply() uoLinkClient.getClilocTable = async () => { attempts++ if (attempts === 1) return { ok: false, status: 425, data: { kind: 'bridge.busy' } } return page([{ n: 1, f: 0, t: 'a' }]) } const { entries } = await bridge.readCliloc() assert.equal(attempts, 2) assert.equal(entries.length, 1) }) test('a page with no rows array is malformed, not an empty table', async (t) => { stub({ sources: sourcesReply(), pages: [ok({ kind: 'cliloc.table.ok', more: false, cut: 'end' })] }) t.after(restore) await assert.rejects(bridge.readCliloc(), (err) => { assert.equal(err.code, 'MALFORMED') return true }) }) test('a shard that never ends the table is bounded by the page cap', async (t) => { saved.getAssetSources = uoLinkClient.getAssetSources saved.getClilocTable = uoLinkClient.getClilocTable t.after(restore) let n = 0 uoLinkClient.getAssetSources = async () => sourcesReply() uoLinkClient.getClilocTable = async () => { n++ return page([{ n, f: 0, t: 'x' }], { more: true, cursor: `n:${n}`, cut: 'budget' }) } await assert.rejects(bridge.readCliloc(), (err) => { assert.equal(err.code, 'TOO_LARGE') return true }) assert.equal(n, bridge.MAX_PAGES) }) test('rows with an unusable id are dropped rather than stored as NaN', async (t) => { stub({ sources: sourcesReply(), pages: [page([{ n: 'nonsense', f: 0, t: 'a' }, { n: 7, f: 0, t: 'b' }])], }) t.after(restore) const { entries } = await bridge.readCliloc() assert.deepEqual(entries, [{ number: 7, flag: 0, text: 'b' }]) })