oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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.
14import { test } from 'node:test'
15import assert from 'node:assert/strict'
16import { 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.
21function concatUpdates(updates) {
22 const out = []
23 for (const u of updates) out.push(...u)
24 return new Uint8Array(out)
25}
26
27function 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
42test('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
73test('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
87test('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