forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtaskSyncEngine.test.ts
More file actions
646 lines (559 loc) · 27.2 KB
/
Copy pathtaskSyncEngine.test.ts
File metadata and controls
646 lines (559 loc) · 27.2 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
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
// Sync-engine behavior tests. The engine is hermetic here: `electron` (net.fetch +
// BrowserWindow) and the storage wrappers (`../ipc/db`, a native better-sqlite3
// module that can't load under plain-node vitest) are mocked; the REAL
// core/session drives the epoch guard. Covers the ported Mac behaviors: local-first
// hydration → sync, reconcile hard-delete (+ empty-guard + 5-min throttle),
// optimistic create/toggle/update/delete (markSynced / revert / keep-local), the
// FIX-ii deletion listener, and retryUnsynced.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionItemRecord } from '../../shared/types'
// --- Hoisted mocks -----------------------------------------------------------
const h = vi.hoisted(() => {
const jsonResponse = (data: unknown, ok = true, status = 200): unknown => ({
ok,
status,
json: async () => data
})
return {
jsonResponse,
// Routed by HTTP method; individual tests override for failures/echoes.
serverItems: [] as unknown[],
netFetch: vi.fn(),
// storage wrappers
getLocalActionItems: vi.fn((): ActionItemRecord[] => []),
getFilteredActionItems: vi.fn((): ActionItemRecord[] => []),
getUnsyncedActionItems: vi.fn((): ActionItemRecord[] => []),
insertLocalActionItem: vi.fn(),
updateCompletionStatus: vi.fn(),
updateActionItemFields: vi.fn(),
deleteActionItemByBackendId: vi.fn((): number[] => []),
markSyncedActionItem: vi.fn(() => ({ merged: false, keptId: 0 })),
syncTaskActionItems: vi.fn(() => ({ skipped: 0, adopted: 0, inserted: 0, updated: 0 })),
hardDeleteAbsentTasks: vi.fn((): number[] => []),
hardDeleteAbsentCompletedTasks: vi.fn((): number[] => []),
getAppMeta: vi.fn((): string | null => '1'), // full-sync flag set → skip full sync by default
setAppMeta: vi.fn(),
// Event-driven promotion trigger (create.ts) — mocked so the engine's toggle/
// delete promote calls are observable without pulling create's real deps.
promoteIfNeeded: vi.fn(async () => {}),
// `tasks:changed` broadcast spy — a fake window's webContents.send.
send: vi.fn(),
// 429-degraded signal — the completed-reconcile guard. Default: healthy.
isBackendDegraded: vi.fn(() => false)
}
})
vi.mock('electron', () => ({
net: { fetch: h.netFetch },
BrowserWindow: {
getAllWindows: () => [{ isDestroyed: () => false, webContents: { send: h.send } }]
}
}))
vi.mock('../ipc/db', () => ({
getLocalActionItems: h.getLocalActionItems,
getFilteredActionItems: h.getFilteredActionItems,
getUnsyncedActionItems: h.getUnsyncedActionItems,
insertLocalActionItem: h.insertLocalActionItem,
updateCompletionStatus: h.updateCompletionStatus,
updateActionItemFields: h.updateActionItemFields,
deleteActionItemByBackendId: h.deleteActionItemByBackendId,
markSyncedActionItem: h.markSyncedActionItem,
syncTaskActionItems: h.syncTaskActionItems,
hardDeleteAbsentTasks: h.hardDeleteAbsentTasks,
hardDeleteAbsentCompletedTasks: h.hardDeleteAbsentCompletedTasks,
getAppMeta: h.getAppMeta,
setAppMeta: h.setAppMeta
}))
vi.mock('../assistants/tasks/create', () => ({ promoteIfNeeded: h.promoteIfNeeded }))
// The REAL core/session (used here) also imports noteBackendStatus from this
// module and calls it after every fetch — mock it as a no-op so the shared module
// mock doesn't strip it out and break session.ts's apiFetch.
vi.mock('../observability/backendDegraded', () => ({
isBackendDegraded: h.isBackendDegraded,
noteBackendStatus: vi.fn()
}))
// Firebase-ish token: payload decodes to a uid (used only to key the full-sync flag).
const TOKEN = `x.${Buffer.from(JSON.stringify({ user_id: 'u1' })).toString('base64')}.y`
const SESSION = {
apiBase: 'https://api.example',
desktopApiBase: 'https://desktop.example',
token: TOKEN
}
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
function backendItem(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'b1',
description: 'from server',
completed: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...over
}
}
// Default net.fetch: GET returns h.serverItems, writes echo a plausible item.
function defaultRoute(): void {
h.netFetch.mockImplementation(async (_url: string, init?: { method?: string }) => {
const method = init?.method ?? 'GET'
if (method === 'GET') return h.jsonResponse({ action_items: h.serverItems, has_more: false })
if (method === 'POST') return h.jsonResponse(backendItem({ id: 'srv-new' }))
if (method === 'PATCH') return h.jsonResponse(backendItem({ completed: true }))
if (method === 'DELETE') return h.jsonResponse({}, true, 204)
return h.jsonResponse({})
})
}
// Each test gets a fresh engine + session module pair (module-scoped state:
// lastReconcileAt, retrying, in-flight promises, deletionListener).
async function freshEngine(): Promise<{
engine: typeof import('./taskSyncEngine')
session: typeof import('../assistants/core/session')
}> {
vi.resetModules()
const session = await import('../assistants/core/session')
const engine = await import('./taskSyncEngine')
session.setBackendSession(SESSION)
return { engine, session }
}
beforeEach(() => {
vi.clearAllMocks()
h.serverItems = [backendItem()]
h.getAppMeta.mockReturnValue('1')
h.getLocalActionItems.mockReturnValue([])
h.hardDeleteAbsentTasks.mockReturnValue([])
h.hardDeleteAbsentCompletedTasks.mockReturnValue([])
h.isBackendDegraded.mockReturnValue(false)
defaultRoute()
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => vi.restoreAllMocks())
describe('hydration (local-first)', () => {
it('listIncomplete returns local rows immediately, then background-syncs the backend page', async () => {
const local = [{ id: 1, description: 'local' } as unknown as ActionItemRecord]
h.getLocalActionItems.mockReturnValue(local)
const { engine } = await freshEngine()
const rows = engine.listIncomplete()
expect(rows).toBe(local) // instant local read
expect(h.getLocalActionItems).toHaveBeenCalledWith({
completed: false,
limit: undefined,
offset: undefined
})
await engine.hydrateIncomplete() // await the background run
expect(h.syncTaskActionItems).toHaveBeenCalledTimes(1)
const items = (h.syncTaskActionItems.mock.calls[0] as unknown[])[0]
expect(items).toEqual([
expect.objectContaining({ backendId: 'b1', description: 'from server', completed: false })
])
})
it('is a local-only no-op with no session (never touches the network)', async () => {
const { engine, session } = await freshEngine()
session.setBackendSession(null)
engine.listIncomplete()
await engine.hydrateIncomplete()
expect(h.netFetch).not.toHaveBeenCalled()
expect(h.syncTaskActionItems).not.toHaveBeenCalled()
})
// Regression: a hydrate that changes nothing MUST NOT emit `tasks:changed`. The
// renderer re-reads on that event, and every read kicks another hydrate — an
// unconditional broadcast turns steady state into an unbounded backend-poll loop.
it('a no-op hydrate stays silent (no tasks:changed broadcast)', async () => {
const { engine } = await freshEngine()
// Defaults: syncTaskActionItems → all-zero counts, hardDeleteAbsentTasks → [].
await engine.hydrateIncomplete()
expect(h.syncTaskActionItems).toHaveBeenCalledTimes(1)
expect(h.send).not.toHaveBeenCalledWith('tasks:changed')
})
it('broadcasts tasks:changed when the sync actually changes a row', async () => {
h.syncTaskActionItems.mockReturnValue({ skipped: 0, adopted: 0, inserted: 1, updated: 0 })
const { engine } = await freshEngine()
await engine.hydrateIncomplete()
expect(h.send).toHaveBeenCalledWith('tasks:changed')
})
})
describe('reconcile (hardDeleteAbsentTasks)', () => {
it('hard-deletes tasks absent from the backend listing and evicts them (FIX ii)', async () => {
h.hardDeleteAbsentTasks.mockReturnValue([42])
const { engine } = await freshEngine()
const evicted: unknown[] = []
engine.setTaskDeletionListener((d) => evicted.push(...d))
await engine.hydrateIncomplete()
expect(h.hardDeleteAbsentTasks).toHaveBeenCalledWith(['b1'])
expect(evicted).toEqual([{ source: 'action_item', id: 42 }])
})
it('empty-guard: when the store deletes nothing, the deletion listener is not called', async () => {
h.hardDeleteAbsentTasks.mockReturnValue([])
const { engine } = await freshEngine()
const listener = vi.fn()
engine.setTaskDeletionListener(listener)
await engine.hydrateIncomplete()
expect(h.hardDeleteAbsentTasks).toHaveBeenCalledWith(['b1'])
expect(listener).not.toHaveBeenCalled()
})
it('throttles reconcile to once per 5 minutes', async () => {
const t0 = 1_700_000_000_000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(t0)
const { engine } = await freshEngine()
await engine.hydrateIncomplete()
expect(h.hardDeleteAbsentTasks).toHaveBeenCalledTimes(1)
nowSpy.mockReturnValue(t0 + 60_000) // +1 min, within throttle
await engine.hydrateIncomplete()
expect(h.hardDeleteAbsentTasks).toHaveBeenCalledTimes(1)
nowSpy.mockReturnValue(t0 + 6 * 60_000) // +6 min, past throttle
await engine.hydrateIncomplete()
expect(h.hardDeleteAbsentTasks).toHaveBeenCalledTimes(2)
})
})
describe('completed-phantom reconcile (hardDeleteAbsentCompletedTasks)', () => {
it('hydrateCompleted reconciles a completed row absent from the backend completed list + evicts it', async () => {
h.hardDeleteAbsentCompletedTasks.mockReturnValue([77])
const { engine } = await freshEngine()
const evicted: unknown[] = []
engine.setTaskDeletionListener((d) => evicted.push(...d))
await engine.hydrateCompleted()
// Called with the backend's completed ids (the fetched list), and its deletions
// are evicted from the embedding index + broadcast.
expect(h.hardDeleteAbsentCompletedTasks).toHaveBeenCalledWith(['b1'], expect.any(Number))
expect(evicted).toEqual([{ source: 'action_item', id: 77 }])
expect(h.send).toHaveBeenCalledWith('tasks:changed')
})
it('empty-guard: a deletion of nothing does not fire the listener or broadcast', async () => {
h.hardDeleteAbsentCompletedTasks.mockReturnValue([])
const { engine } = await freshEngine()
const listener = vi.fn()
engine.setTaskDeletionListener(listener)
await engine.hydrateCompleted()
expect(h.hardDeleteAbsentCompletedTasks).toHaveBeenCalledWith(['b1'], expect.any(Number))
expect(listener).not.toHaveBeenCalled()
expect(h.send).not.toHaveBeenCalledWith('tasks:changed')
})
// The mass-flip guard: a 429 storm can return a thin/partial completed list, so the
// delete-by-absence sweep MUST be skipped entirely while degraded.
it('SKIPS the completed reconcile in the 429-degraded state', async () => {
h.isBackendDegraded.mockReturnValue(true)
h.hardDeleteAbsentCompletedTasks.mockReturnValue([77]) // would delete if it ran
const { engine } = await freshEngine()
const listener = vi.fn()
engine.setTaskDeletionListener(listener)
await engine.hydrateCompleted()
expect(h.hardDeleteAbsentCompletedTasks).not.toHaveBeenCalled()
expect(listener).not.toHaveBeenCalled()
})
// THE mass-flip regression: the sweep must NEVER run against a partial completed
// list. A multi-page fetch whose 2nd page rejects (500/429) makes fetchAll throw,
// so doHydrateCompleted's catch skips the reconcile entirely — no delete-by-absence
// off a truncated snapshot. This guards the invariant (fetchPage throws on !ok →
// fetchAll propagates) a refactor could silently break with an otherwise-green suite.
it('mass-flip guard: a page-2 failure in the completed fetch skips the reconcile (no delete)', async () => {
h.hardDeleteAbsentCompletedTasks.mockReturnValue([99]) // would delete a real row if ever called
h.netFetch.mockImplementation(async (url: string, init?: { method?: string }) => {
if ((init?.method ?? 'GET') !== 'GET') return h.jsonResponse({})
// Page 1 (offset=0): a page that signals more. Page 2 (offset=500): reject.
if (String(url).includes('offset=0'))
return h.jsonResponse({ action_items: [backendItem({ id: 'b1' })], has_more: true })
return h.jsonResponse({}, false, 500)
})
const { engine } = await freshEngine()
const listener = vi.fn()
engine.setTaskDeletionListener(listener)
await engine.hydrateCompleted() // swallows the fetch error
// fetchAll threw on page 2 → neither the sync nor the reconcile is reached.
expect(h.hardDeleteAbsentCompletedTasks).not.toHaveBeenCalled()
expect(h.syncTaskActionItems).not.toHaveBeenCalled()
expect(listener).not.toHaveBeenCalled()
})
it('throttles the completed reconcile to once per 5 minutes', async () => {
const t0 = 1_700_000_000_000
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(t0)
const { engine } = await freshEngine()
await engine.hydrateCompleted()
expect(h.hardDeleteAbsentCompletedTasks).toHaveBeenCalledTimes(1)
nowSpy.mockReturnValue(t0 + 60_000) // within throttle
await engine.hydrateCompleted()
expect(h.hardDeleteAbsentCompletedTasks).toHaveBeenCalledTimes(1)
nowSpy.mockReturnValue(t0 + 6 * 60_000) // past throttle
await engine.hydrateCompleted()
expect(h.hardDeleteAbsentCompletedTasks).toHaveBeenCalledTimes(2)
})
})
describe('optimistic create', () => {
it('inserts locally, returns the row, and marks it synced on POST success', async () => {
h.insertLocalActionItem.mockReturnValue({ id: 7 } as unknown as ActionItemRecord)
const { engine } = await freshEngine()
const rec = engine.createTask({ description: 'buy milk' })
expect(rec).toEqual({ id: 7 })
expect(h.insertLocalActionItem).toHaveBeenCalledWith(
expect.objectContaining({ description: 'buy milk', source: 'manual', completed: false })
)
await flush()
expect(h.markSyncedActionItem).toHaveBeenCalledWith(7, 'srv-new', expect.any(Number))
})
it('stays unsynced on POST failure (no revert, no markSynced)', async () => {
h.insertLocalActionItem.mockReturnValue({ id: 7 } as unknown as ActionItemRecord)
h.netFetch.mockImplementation(async () => h.jsonResponse({}, false, 500))
const { engine } = await freshEngine()
engine.createTask({ description: 'buy milk' })
await flush()
expect(h.markSyncedActionItem).not.toHaveBeenCalled()
expect(h.deleteActionItemByBackendId).not.toHaveBeenCalled() // never reverted
})
})
describe('optimistic toggle', () => {
it('reverts the local completion when the PATCH fails', async () => {
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
if (init?.method === 'PATCH') return h.jsonResponse({}, false, 500)
return h.jsonResponse({ action_items: [], has_more: false })
})
const { engine } = await freshEngine()
engine.toggleTask('b1', true)
expect(h.updateCompletionStatus).toHaveBeenCalledWith('b1', true, expect.any(Number)) // optimistic
await flush()
// Revert = set completion back to the previous value.
expect(h.updateCompletionStatus).toHaveBeenLastCalledWith('b1', false, expect.any(Number))
expect(h.updateCompletionStatus).toHaveBeenCalledTimes(2)
})
it('absorbs the server echo on PATCH success (no revert)', async () => {
const { engine } = await freshEngine()
engine.toggleTask('b1', true)
await flush()
expect(h.updateCompletionStatus).toHaveBeenCalledTimes(1) // no revert
expect(h.syncTaskActionItems).toHaveBeenCalledTimes(1) // echo absorbed
})
})
describe('optimistic update', () => {
it('keeps the local edit when the PATCH fails (no revert)', async () => {
h.netFetch.mockImplementation(async () => h.jsonResponse({}, false, 500))
const { engine } = await freshEngine()
engine.updateTask('b1', { description: 'edited' })
expect(h.updateActionItemFields).toHaveBeenCalledWith(
'b1',
{ description: 'edited' },
expect.any(Number)
)
await flush()
expect(h.updateActionItemFields).toHaveBeenCalledTimes(1) // not undone
})
})
describe('optimistic delete', () => {
it('hard-deletes locally, fires the deletion listener with the ids, and keeps the deletion on DELETE failure', async () => {
h.deleteActionItemByBackendId.mockReturnValue([9])
h.netFetch.mockImplementation(async () => h.jsonResponse({}, false, 500))
const { engine } = await freshEngine()
const listener = vi.fn()
engine.setTaskDeletionListener(listener)
engine.deleteTask('b1')
expect(h.deleteActionItemByBackendId).toHaveBeenCalledWith('b1', 'user')
expect(listener).toHaveBeenCalledWith([{ source: 'action_item', id: 9 }])
await flush()
// keep-local-deleted: nothing re-inserted, delete not retried/undone.
expect(h.deleteActionItemByBackendId).toHaveBeenCalledTimes(1)
expect(h.insertLocalActionItem).not.toHaveBeenCalled()
})
it('a hydrate during an in-flight delete does not resurrect the row (passes the tombstone guard)', async () => {
h.deleteActionItemByBackendId.mockReturnValue([9])
// DELETE never resolves → the tombstone stays set while the hydrate runs.
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
const method = init?.method ?? 'GET'
if (method === 'DELETE') return new Promise(() => {}) // hang forever
return h.jsonResponse({ action_items: [backendItem({ id: 'b1' })], has_more: false })
})
const { engine } = await freshEngine()
engine.deleteTask('b1') // sets the tombstone
await engine.hydrateIncomplete()
// The hydrate's sync call carries a guard that reports the deleted id as pending,
// so the storage insert branch skips it (proven against a real DB in dbTasks.test).
const lastSync = h.syncTaskActionItems.mock.calls.at(-1) as unknown as
| [unknown, { isTombstoned?: (id: string) => boolean }]
| undefined
expect(lastSync?.[1]?.isTombstoned?.('b1')).toBe(true)
})
it('verify: a failed delete whose task is still on the server restores the row + signals failure', async () => {
h.deleteActionItemByBackendId.mockReturnValue([9])
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
const method = init?.method ?? 'GET'
if (method === 'DELETE') return h.jsonResponse({}, false, 429) // storm: delete rejected
if (method === 'GET') return h.jsonResponse(backendItem({ id: 'b1' })) // still present
return h.jsonResponse({})
})
const { engine } = await freshEngine()
engine.deleteTask('b1')
await flush()
// Restored through the normal sync path…
const restore = h.syncTaskActionItems.mock.calls.at(-1) as unknown as
| [{ backendId: string }[], unknown]
| undefined
expect(restore?.[0]?.[0]?.backendId).toBe('b1')
// …and the failure is surfaced (not silent), and the tombstone retired.
expect(h.send).toHaveBeenCalledWith('tasks:opFailed', expect.objectContaining({ op: 'delete' }))
expect(engine.__isTombstonedForTest('b1')).toBe(false)
})
it('verify: a failed delete whose task is GONE stays deleted and clears the tombstone', async () => {
h.deleteActionItemByBackendId.mockReturnValue([9])
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
const method = init?.method ?? 'GET'
if (method === 'DELETE') return h.jsonResponse({}, false, 429)
if (method === 'GET') return h.jsonResponse({ detail: 'not found' }, false, 404) // gone
return h.jsonResponse({})
})
const { engine } = await freshEngine()
engine.deleteTask('b1')
await flush()
expect(h.syncTaskActionItems).not.toHaveBeenCalled() // no restore
expect(h.send).not.toHaveBeenCalledWith('tasks:opFailed', expect.anything())
expect(engine.__isTombstonedForTest('b1')).toBe(false) // guard cleared (delete stuck)
})
// MAJOR fix: when the DELETE AND the first verify GET are both inconclusive (429
// storm), the delete must be RE-VERIFIED later — not left to silently resurrect at
// TTL. Both later outcomes are exercised: still-present → restore + toast;
// gone → stays deleted. Uses fake timers to cross the re-verify backoff.
describe('inconclusive-delete re-verify (MAJOR)', () => {
it('re-verifies a 429/429 delete and RESTORES + signals when it is later still present', async () => {
vi.useFakeTimers()
const rnd = vi.spyOn(Math, 'random').mockReturnValue(0) // deterministic backoff
h.deleteActionItemByBackendId.mockReturnValue([9])
let getCount = 0
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
const method = init?.method ?? 'GET'
if (method === 'DELETE') return h.jsonResponse({}, false, 429)
if (method === 'GET') {
getCount++
if (getCount === 1) return h.jsonResponse({}, false, 429) // first verify inconclusive
return h.jsonResponse(backendItem({ id: 'b1' })) // re-verify: still present
}
return h.jsonResponse({})
})
const { engine } = await freshEngine()
engine.deleteTask('b1')
await vi.advanceTimersByTimeAsync(0) // DELETE 429 → verify 429 → schedule re-verify
expect(engine.__isTombstonedForTest('b1')).toBe(true) // held, not resolved
expect(h.send).not.toHaveBeenCalledWith('tasks:opFailed', expect.anything())
await vi.advanceTimersByTimeAsync(16_000) // fire the re-verify (15s + jitter)
expect(h.syncTaskActionItems).toHaveBeenCalled() // restored
expect(h.send).toHaveBeenCalledWith(
'tasks:opFailed',
expect.objectContaining({ op: 'delete' })
)
expect(engine.__isTombstonedForTest('b1')).toBe(false)
rnd.mockRestore()
vi.useRealTimers()
})
it('re-verifies a 429/429 delete and STAYS deleted when it is later confirmed gone', async () => {
vi.useFakeTimers()
const rnd = vi.spyOn(Math, 'random').mockReturnValue(0)
h.deleteActionItemByBackendId.mockReturnValue([9])
let getCount = 0
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
const method = init?.method ?? 'GET'
if (method === 'DELETE') return h.jsonResponse({}, false, 429)
if (method === 'GET') {
getCount++
if (getCount === 1) return h.jsonResponse({}, false, 429) // inconclusive
return h.jsonResponse({ detail: 'not found' }, false, 404) // re-verify: gone
}
return h.jsonResponse({})
})
const { engine } = await freshEngine()
engine.deleteTask('b1')
await vi.advanceTimersByTimeAsync(0)
expect(engine.__isTombstonedForTest('b1')).toBe(true)
await vi.advanceTimersByTimeAsync(16_000) // re-verify → 404
expect(h.syncTaskActionItems).not.toHaveBeenCalled() // no restore
expect(h.send).not.toHaveBeenCalledWith('tasks:opFailed', expect.anything())
expect(engine.__isTombstonedForTest('b1')).toBe(false) // cleared, stays deleted
rnd.mockRestore()
vi.useRealTimers()
})
})
it('resetPendingDeletes drops tombstones (cross-account hygiene on sign-out)', async () => {
h.deleteActionItemByBackendId.mockReturnValue([9])
// Keep the DELETE pending so the tombstone stays set.
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
if ((init?.method ?? 'GET') === 'DELETE') return new Promise(() => {})
return h.jsonResponse({})
})
const { engine } = await freshEngine()
engine.deleteTask('b1')
expect(engine.__isTombstonedForTest('b1')).toBe(true)
engine.resetPendingDeletes()
expect(engine.__isTombstonedForTest('b1')).toBe(false)
})
})
describe('retryUnsynced', () => {
it('re-POSTs each unsynced create and marks it synced', async () => {
h.getUnsyncedActionItems.mockReturnValue([
{
id: 3,
description: 'x',
completed: false,
dueAt: null,
conversationId: null
} as unknown as ActionItemRecord
])
const { engine } = await freshEngine()
await engine.retryUnsynced()
const posts = h.netFetch.mock.calls.filter(
(c) => (c[1] as { method?: string })?.method === 'POST'
)
expect(posts).toHaveLength(1)
expect(h.markSyncedActionItem).toHaveBeenCalledWith(3, 'srv-new', expect.any(Number))
})
it('does nothing without a session', async () => {
h.getUnsyncedActionItems.mockReturnValue([
{ id: 3, description: 'x', completed: false } as unknown as ActionItemRecord
])
const { engine, session } = await freshEngine()
session.setBackendSession(null)
await engine.retryUnsynced()
expect(h.netFetch).not.toHaveBeenCalled()
expect(h.markSyncedActionItem).not.toHaveBeenCalled()
})
})
describe('event-driven promotion (Mac TasksStore complete/delete triggers)', () => {
it('completing a task fires a promote (vacated slot → pull the next staged task up)', async () => {
const { engine } = await freshEngine()
engine.toggleTask('b1', true)
await flush()
expect(h.promoteIfNeeded).toHaveBeenCalledTimes(1)
})
it('un-completing a task does NOT fire a promote (Mac triggers on complete only)', async () => {
const { engine } = await freshEngine()
engine.toggleTask('b1', false)
await flush()
expect(h.promoteIfNeeded).not.toHaveBeenCalled()
})
it('deleting a task fires a promote', async () => {
h.deleteActionItemByBackendId.mockReturnValue([9])
const { engine } = await freshEngine()
engine.deleteTask('b1')
await flush()
expect(h.promoteIfNeeded).toHaveBeenCalledTimes(1)
})
it('the promote is fire-and-forget — a toggle FAILURE still reverts regardless', async () => {
// promoteIfNeeded runs alongside the toggle; even if it never resolved, the
// toggle's own revert-on-PATCH-failure path is independent and must still fire.
h.promoteIfNeeded.mockReturnValue(new Promise(() => {})) // never settles
h.netFetch.mockImplementation(async (_u: string, init?: { method?: string }) => {
if (init?.method === 'PATCH') return h.jsonResponse({}, false, 500)
return h.jsonResponse({ action_items: [], has_more: false })
})
const { engine } = await freshEngine()
engine.toggleTask('b1', true)
expect(h.updateCompletionStatus).toHaveBeenCalledWith('b1', true, expect.any(Number))
await flush()
// Revert happened despite the never-settling promote.
expect(h.updateCompletionStatus).toHaveBeenLastCalledWith('b1', false, expect.any(Number))
expect(h.promoteIfNeeded).toHaveBeenCalledTimes(1)
})
})
describe('one-time full sync (versioned flag)', () => {
it('pages everything once when the flag is unset, then persists the flag', async () => {
h.getAppMeta.mockReturnValue(null) // not yet done
const { engine } = await freshEngine()
await engine.hydrateIncomplete()
expect(h.setAppMeta).toHaveBeenCalledWith('tasksFullSyncCompleted_v1_u1', '1')
// Both completed=false and completed=true pages were fetched during the full sync.
const gets = h.netFetch.mock.calls
.filter((c) => ((c[1] as { method?: string })?.method ?? 'GET') === 'GET')
.map((c) => String(c[0]))
expect(gets.some((u) => u.includes('completed=true'))).toBe(true)
expect(gets.some((u) => u.includes('completed=false'))).toBe(true)
})
})