forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathoutbox.test.ts
More file actions
454 lines (367 loc) · 14.5 KB
/
Copy pathoutbox.test.ts
File metadata and controls
454 lines (367 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get, set } from 'idb-keyval'
import {
enqueue,
listQueued,
removeQueued,
retryQueued,
flushOutbox,
OUTBOX_KEY,
} from './outbox'
import { BUILD_INFO } from './buildInfo'
// TESTING.md item 13, and WIREFRAMES.md's rule that "every write (report,
// thanks, confirmation) queues in an outbox with its authored timestamp and
// syncs later. Nothing blocks on network."
//
// The authored timestamp is the whole point. A report written on Monday and
// flushed on Thursday must still say Monday - see the matching server-side
// change that added `authored_at` to the reports API. If the outbox let the
// send time win, a maintainer would read a three-day-old blowdown as fresh.
//
// Failure handling matters as much: a send that fails must leave the item
// queued, and a flush that runs twice must not create two reports. Both are
// the difference between an outbox and a way to lose someone's report.
vi.mock('idb-keyval', () => ({ get: vi.fn(), set: vi.fn() }))
const mockedGet = vi.mocked(get)
const mockedSet = vi.mocked(set)
/** Backs the mocked idb-keyval with a real in-memory value. */
function withStoredQueue(initial: unknown[] = []) {
let stored = initial
mockedGet.mockImplementation(async () => stored)
mockedSet.mockImplementation(async (_key, value) => {
stored = value as unknown[]
})
return () => stored
}
const DRAFT = {
type: 'blowdown' as const,
reporter_type: 'thru' as const,
note: 'Large tree across the trail near the gap.',
lat: 35.6,
lon: -83.5,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('enqueue', () => {
it('stores a draft under the outbox key', async () => {
const read = withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
expect(mockedSet).toHaveBeenCalledWith(OUTBOX_KEY, expect.any(Array))
expect(read()).toHaveLength(1)
})
it('records when the report was WRITTEN, not when it will be sent', async () => {
const read = withStoredQueue()
const written = new Date('2026-07-27T08:00:00Z')
await enqueue(DRAFT, written)
expect((read()[0] as { authoredAt: string }).authoredAt).toBe(written.toISOString())
})
it('gives each item a stable id, which is what makes a retry safe', async () => {
const read = withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
await enqueue(DRAFT, new Date('2026-07-27T09:00:00Z'))
const ids = (read() as Array<{ id: string }>).map((i) => i.id)
expect(new Set(ids).size).toBe(2)
})
it('keeps what is already queued rather than replacing it', async () => {
const read = withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
await enqueue(DRAFT, new Date('2026-07-28T08:00:00Z'))
expect(read()).toHaveLength(2)
})
})
describe('listQueued', () => {
it('returns an empty list on a fresh install rather than throwing', async () => {
mockedGet.mockResolvedValue(undefined)
expect(await listQueued()).toEqual([])
})
})
describe('removeQueued', () => {
it('deletes an item for good - a later flush must not resurrect it', async () => {
const read = withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
const [queued] = read() as Array<{ id: string }>
await removeQueued(queued.id)
await flushOutbox(vi.fn().mockResolvedValue(undefined))
expect(read()).toHaveLength(0)
})
})
describe('flushOutbox', () => {
it('sends every queued item, each with its own authored time', async () => {
withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-24T08:00:00Z'))
await enqueue(DRAFT, new Date('2026-07-26T08:00:00Z'))
await enqueue(DRAFT, new Date('2026-07-28T08:00:00Z'))
const send = vi.fn().mockResolvedValue(undefined)
await flushOutbox(send)
expect(send).toHaveBeenCalledTimes(3)
expect(send.mock.calls.map(([item]) => item.authoredAt)).toEqual([
'2026-07-24T08:00:00.000Z',
'2026-07-26T08:00:00.000Z',
'2026-07-28T08:00:00.000Z',
])
})
it('empties the queue once everything has been accepted', async () => {
const read = withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
await flushOutbox(vi.fn().mockResolvedValue(undefined))
expect(read()).toHaveLength(0)
})
it('leaves a failed item queued so it can be retried, and says so', async () => {
const read = withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
const send = vi.fn().mockRejectedValue(new Error('offline'))
const result = await flushOutbox(send)
expect(result).toMatchObject({ sent: 0, failed: 1 })
expect(read()).toHaveLength(1)
})
it('keeps only the failures when some succeed and some do not', async () => {
const read = withStoredQueue()
await enqueue({ ...DRAFT, note: 'first' }, new Date('2026-07-24T08:00:00Z'))
await enqueue({ ...DRAFT, note: 'second' }, new Date('2026-07-26T08:00:00Z'))
const send = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('offline'))
await flushOutbox(send)
const left = read() as Array<{ payload: { note: string } }>
expect(left).toHaveLength(1)
expect(left[0].payload.note).toBe('second')
})
it('does not send the same report twice when flushed again', async () => {
withStoredQueue()
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
const send = vi.fn().mockResolvedValue(undefined)
await flushOutbox(send)
await flushOutbox(send)
expect(send).toHaveBeenCalledTimes(1)
})
it('is a no-op on an empty queue', async () => {
withStoredQueue()
const send = vi.fn()
expect(await flushOutbox(send)).toMatchObject({ sent: 0, failed: 0 })
expect(send).not.toHaveBeenCalled()
})
})
// --- Delivery guarantees (#243) -----------------------------------------
//
// The header promises "a flush that half-succeeds neither loses the
// successes nor drops the failures - and flushing twice cannot file the
// same report twice." These are the three ways that was not true.
describe('a report written while a flush is running', () => {
it('is not overwritten by the flush finishing', async () => {
// The bug: flushOutbox read the queue, awaited every send, then wrote
// the whole key back from its own stale snapshot. Anything enqueued in
// between was appended to the key and then erased by that final write -
// gone from IndexedDB without ever being sent, which is the one thing an
// outbox exists to prevent.
const read = withStoredQueue([
{
id: 'first',
authoredAt: '2026-06-01T00:00:00.000Z',
payload: { type: 'blowdown' },
},
])
await flushOutbox(async () => {
// Mid-flight, exactly like a hiker writing a second report while the
// first is uploading.
await enqueue({ type: 'trash', reporter_type: 'day' })
})
expect(
read().map((item) => (item as { payload: { type: string } }).payload.type),
).toEqual(['trash'])
})
})
describe('a permanently refused report', () => {
const REFUSED = [
{
id: 'doomed',
authoredAt: '2026-06-01T00:00:00.000Z',
payload: { type: 'blowdown' },
},
]
it('is kept, marked, and reported as stuck', async () => {
const read = withStoredQueue([...REFUSED])
const result = await flushOutbox(
async () => {
throw new Error('422')
},
() => 'The server would not accept it.',
)
expect(result).toEqual({ sent: 0, failed: 1, stuck: 1 })
const stored = read()[0] as { failure?: { reason: string } }
// Kept, not deleted: it is still the only copy of what someone wrote.
expect(stored.failure?.reason).toBe('The server would not accept it.')
})
it('is not retried on the next flush', async () => {
withStoredQueue([...REFUSED])
const classify = () => 'nope'
await flushOutbox(async () => {
throw new Error('422')
}, classify)
const send = vi.fn()
const second = await flushOutbox(send, classify)
// Retrying would spend signal to be refused again, and would keep
// resetting a failure the hiker is currently being shown.
expect(send).not.toHaveBeenCalled()
expect(second).toEqual({ sent: 0, failed: 1, stuck: 1 })
})
it('goes again once its failure is cleared', async () => {
// The escape hatch for the cause a hiker can fix: a wrong phone clock
// has every report refused, and once it is right nothing is wrong with
// them.
withStoredQueue([...REFUSED])
await flushOutbox(
async () => {
throw new Error('422')
},
() => 'nope',
)
await retryQueued('doomed')
const send = vi.fn()
await flushOutbox(send)
expect(send).toHaveBeenCalledTimes(1)
})
it('records which build gave up on it', async () => {
// #412: the verdict belongs to a build, not to the report, so the build
// has to be part of the record for a later one to overturn it.
const read = withStoredQueue([...REFUSED])
await flushOutbox(
async () => {
throw new Error('422')
},
() => 'nope',
)
const stored = read()[0] as { failure?: { build?: string } }
expect(stored.failure?.build).toBe(BUILD_INFO.commit)
})
it('is retried once by a different build', async () => {
// The whole point. A 422 on a field the previous build did not send is
// fixed by the build that sends it - and a hiker who never opens More
// and presses "Try again" would otherwise lose the report to a verdict
// that has stopped being true.
const read = withStoredQueue([...REFUSED])
await flushOutbox(
async () => {
throw new Error('422')
},
() => 'nope',
)
// The same stored queue, now read by a build with a different commit.
const stored = read()[0] as { failure: { build?: string } }
stored.failure.build = 'a0000000000000000000000000000000000000ff'
const send = vi.fn()
const result = await flushOutbox(send)
expect(send).toHaveBeenCalledTimes(1)
expect(result.sent).toBe(1)
})
it('is not retried again by the build that re-marked it', async () => {
// Bounded to one retry per update: the rule is "a different build may
// disagree", not "keep resetting a failure the hiker is being shown".
const read = withStoredQueue([...REFUSED])
const refuse = async () => {
throw new Error('422')
}
await flushOutbox(refuse, () => 'nope')
const stored = read()[0] as { failure: { build?: string } }
stored.failure.build = 'a0000000000000000000000000000000000000ff'
// The new build tries, is refused, and re-marks it under its own commit.
await flushOutbox(refuse, () => 'nope')
const send = vi.fn()
const third = await flushOutbox(send, () => 'nope')
expect(send).not.toHaveBeenCalled()
expect(third).toEqual({ sent: 0, failed: 1, stuck: 1 })
})
it('retries a failure stored before builds were recorded', async () => {
// The shape on a phone that upgraded into this change: `failure` with no
// `build`. Absent is not this build, so it gets the same single retry an
// older build's verdict would - see storedShapes.fixtures.ts, which
// carries exactly this item.
withStoredQueue([
{
...REFUSED[0],
failure: { reason: 'Refused by an older build.', at: '2026-07-30T08:00:00.000Z' },
},
])
const send = vi.fn()
await flushOutbox(send)
expect(send).toHaveBeenCalledTimes(1)
})
})
describe('a transient failure', () => {
it('is left alone, with no failure recorded', async () => {
const read = withStoredQueue([
{
id: 'waiting',
authoredAt: '2026-06-01T00:00:00.000Z',
payload: { type: 'blowdown' },
},
])
const result = await flushOutbox(
async () => {
throw new Error('offline')
},
// The classifier's "retry this" answer.
() => null,
)
expect(result).toEqual({ sent: 0, failed: 1, stuck: 0 })
expect((read()[0] as { failure?: unknown }).failure).toBeUndefined()
})
it('is the default when no classifier is given', async () => {
// A caller with no opinion must not accidentally strand a report.
const read = withStoredQueue([
{
id: 'waiting',
authoredAt: '2026-06-01T00:00:00.000Z',
payload: { type: 'blowdown' },
},
])
const result = await flushOutbox(async () => {
throw new Error('anything at all')
})
expect(result.stuck).toBe(0)
expect((read()[0] as { failure?: unknown }).failure).toBeUndefined()
})
})
// --- Carrying the photo, not a link to one (#234) -------------------------
describe('a report queued with a photo', () => {
const BYTES = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' })
// Its own empty store per test. The global `beforeEach` clears calls but
// not implementations, so without this the queue is whatever the previous
// describe left behind.
beforeEach(() => {
withStoredQueue()
})
it('stores the bytes beside the report', async () => {
// Not `payload.photo_url`, which is the shape for a photo already
// uploaded. Out here the report is usually written with no signal at all
// and flushes days later, so the image has to survive in IndexedDB.
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'), BYTES)
const [item] = await listQueued()
expect(item.photo).toBe(BYTES)
})
it('leaves the key off entirely when there is no photo', async () => {
// An explicit `photo: undefined` is a difference that reads as one to
// every comparison and rewrite the queue goes through.
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'))
const [item] = await listQueued()
expect('photo' in item).toBe(false)
})
it('hands the photo to the sender along with the report', async () => {
// The send is what turns bytes into an upload; the outbox's only job is
// that they are still there when it runs.
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'), BYTES)
const send = vi.fn().mockResolvedValue(undefined)
await flushOutbox(send)
expect(send.mock.calls[0][0].photo).toBe(BYTES)
})
it('keeps the photo when the send fails and the item stays queued', async () => {
// The retry has to carry the same bytes; losing them on the first failed
// flush would mean the photo only ever survived a first-try success,
// which on this trail is the uncommon case.
await enqueue(DRAFT, new Date('2026-07-27T08:00:00Z'), BYTES)
await flushOutbox(vi.fn().mockRejectedValue(new Error('no signal')))
const [item] = await listQueued()
expect(item.photo).toBe(BYTES)
})
})