| 1 | // fixtures/save-queue.test.mjs — regression test for fix round 1's |
| 2 | // F1: a permanently-rejected batch used to retry forever AND absorb |
| 3 | // every later edit via Y.mergeUpdates, losing all of it silently. |
| 4 | // Reproduced against a real running server (a ~1.2MB paste got a |
| 5 | // real 413, every later flush also 413'd, and the paste plus both |
| 6 | // subsequent small edits were gone after reload) before the fix in |
| 7 | // save-queue.mjs went in. |
| 8 | // |
| 9 | // Runs under Node's own test runner (node --test), no framework |
| 10 | // added: `post`/`mergeUpdates`/timing are all injected by |
| 11 | // createSaveQueue, so this drives the real retry/drop control flow |
| 12 | // with tiny stand-in byte arrays and a short debounce — no DOM, no |
| 13 | // ProseMirror, no real Y.Doc, no browser needed. |
| 14 | import { test } from 'node:test' |
| 15 | import assert from 'node:assert/strict' |
| 16 | import { createSaveQueue } from './save-queue.mjs' |
| 17 | |
| 18 | // A fake mergeUpdates that behaves like Y.mergeUpdates for this |
| 19 | // test's purposes: concatenate, in order, so a merged batch's |
| 20 | // identity is checkable byte-for-byte. |
| 21 | function concatUpdates(updates) { |
| 22 | const out = [] |
| 23 | for (const u of updates) out.push(...u) |
| 24 | return new Uint8Array(out) |
| 25 | } |
| 26 | |
| 27 | function makePostStub(responses) { |
| 28 | const calls = [] |
| 29 | let i = 0 |
| 30 | return { |
| 31 | calls, |
| 32 | post: async (bytes) => { |
| 33 | calls.push(bytes) |
| 34 | const next = responses[Math.min(i, responses.length - 1)] |
| 35 | i++ |
| 36 | if (next.throws) throw new Error('network failure') |
| 37 | return { ok: next.status < 300, status: next.status } |
| 38 | }, |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | test('a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit', async () => { |
| 43 | const { calls, post } = makePostStub([{ status: 413 }, { status: 204 }]) |
| 44 | const statuses = [] |
| 45 | const q = createSaveQueue({ |
| 46 | post, |
| 47 | mergeUpdates: concatUpdates, |
| 48 | onStatus: (s) => statuses.push(s), |
| 49 | debounceMs: 5, |
| 50 | }) |
| 51 | |
| 52 | const bigPaste = new Uint8Array([9, 9, 9]) |
| 53 | q.enqueue(bigPaste) |
| 54 | await new Promise((r) => setTimeout(r, 40)) |
| 55 | |
| 56 | assert.equal(calls.length, 1, 'the oversized batch should be posted exactly once') |
| 57 | assert.deepEqual(calls[0], bigPaste) |
| 58 | assert.equal(q.status, 'failed', 'a permanent rejection must be reported, not swallowed') |
| 59 | assert.equal(q.pendingBytes(), null, 'a dropped batch must not sit in the queue forever') |
| 60 | |
| 61 | // The crux of F1: an edit typed after the permanent rejection must |
| 62 | // reach the server on its own, never merged with the dead batch. |
| 63 | const secondEdit = new Uint8Array([1, 2, 3]) |
| 64 | q.enqueue(secondEdit) |
| 65 | await new Promise((r) => setTimeout(r, 40)) |
| 66 | |
| 67 | assert.equal(calls.length, 2, 'the later edit must be posted, not swallowed') |
| 68 | assert.deepEqual(calls[1], secondEdit, 'the later edit must be posted on its own — merging it with the dead 413 batch would 413 it too') |
| 69 | assert.equal(statuses.filter((s) => s === 'failed').length >= 1, true) |
| 70 | assert.equal(q.status, 'failed', "the indicator must stay sticky — a later success must not hide that something was lost") |
| 71 | }) |
| 72 | |
| 73 | test('a network failure or a 5xx is retried, not dropped', async () => { |
| 74 | const { calls, post } = makePostStub([{ throws: true }, { status: 204 }]) |
| 75 | const q = createSaveQueue({ post, mergeUpdates: concatUpdates, debounceMs: 5 }) |
| 76 | |
| 77 | const edit = new Uint8Array([7, 7]) |
| 78 | q.enqueue(edit) |
| 79 | await new Promise((r) => setTimeout(r, 60)) |
| 80 | |
| 81 | assert.equal(calls.length, 2, 'a network failure must be retried automatically, not just left queued') |
| 82 | assert.deepEqual(calls[0], edit) |
| 83 | assert.deepEqual(calls[1], edit, 'the retried batch must be the exact same bytes, not lost or duplicated') |
| 84 | assert.equal(q.status, 'ok', 'once the retry lands, nothing was actually lost') |
| 85 | }) |
| 86 | |
| 87 | test('a 500 is retried the same way a network failure is, never dropped', async () => { |
| 88 | const { calls, post } = makePostStub([{ status: 500 }, { status: 204 }]) |
| 89 | const q = createSaveQueue({ post, mergeUpdates: concatUpdates, debounceMs: 5 }) |
| 90 | |
| 91 | q.enqueue(new Uint8Array([4, 4])) |
| 92 | await new Promise((r) => setTimeout(r, 60)) |
| 93 | |
| 94 | assert.equal(calls.length, 2, 'a 5xx is our own fault, not the client bytes — it must retry, not drop') |
| 95 | assert.equal(q.status, 'ok') |
| 96 | }) |
| 97 | |