Files
TREK/client/tests/unit/sync/mutationQueue.conflict.test.ts
T
Maurice b4f705a049 fix(offline): a parked write holds back the later writes to the same entity
Parking a write the server keeps failing on unblocked its trip, so a later
queued edit of the same budget item, visit, tour or driving settings went
through while the older one waited. Try again then replayed the older
write last and it silently overwrote the newer one: only places and
packing items send a token the server could refuse it with. Before the
parking change a 5xx stopped the whole flush, so this order was never at
risk.

The flush now holds back every pending write whose entity has an older
write parked as failed or waiting as a conflict, and a write that parks
during the pass holds back the rest of that pass too. Other entities and
other trips keep syncing. Try again therefore replays the parked write
first and the held ones after it, Keep mine does the same for a conflict,
and Discard or Keep theirs flush right away so the held writes go out
without waiting for the next trigger. Writes queued against an offline
create that got parked now wait for it instead of failing as an
unresolved temp id.
2026-10-08 13:02:58 +02:00

243 lines
11 KiB
TypeScript

/**
* mutationQueue conflict tests (#1135) — 409 handling + resolution.
*
* Covers: X-Base-Updated-At header, 'ask' parks a conflict, keep-mine re-sends
* unconditionally, keep-theirs adopts the server entity, and the 'mine'/'server'
* auto-strategies.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import 'fake-indexeddb/auto'
import { server } from '../../helpers/msw/server'
import { http, HttpResponse } from 'msw'
import { setAuthed } from '../../../src/sync/authGate'
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue'
import { offlineDb, clearAll } from '../../../src/db/offlineDb'
import { _resetNetworkMode } from '../../../src/sync/networkMode'
import { _resetOfflinePrefs, setConflictStrategy } from '../../../src/sync/offlinePrefs'
import { buildPlace } from '../../helpers/factories'
beforeEach(async () => {
await clearAll()
mutationQueue._resetFlushing()
_resetNetworkMode()
_resetOfflinePrefs()
setAuthed(true)
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
afterEach(() => {
vi.restoreAllMocks()
setAuthed(false)
})
const BASE = '2026-01-01 00:00:00'
function enqueueConflictingPut(id: string, baseUpdatedAt: string | null = BASE) {
return mutationQueue.enqueue({
id, tripId: 1, method: 'PUT', url: '/trips/1/places/42',
body: { name: 'Mine' }, resource: 'places', entityId: 42, baseUpdatedAt,
})
}
/** Server that 409s when the base token is sent, and 200s once it isn't. */
function conflictThenAcceptHandler(serverName = 'Theirs') {
server.use(
http.put('/api/trips/1/places/42', ({ request }) => {
if (request.headers.get('X-Base-Updated-At')) {
return HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: serverName }) }, { status: 409 })
}
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Mine' }) })
}),
)
}
describe('mutationQueue — base-version header', () => {
it('sends X-Base-Updated-At when the mutation carries a base version', async () => {
let captured: string | null = null
server.use(http.put('/api/trips/1/places/42', ({ request }) => {
captured = request.headers.get('X-Base-Updated-At')
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })
}))
await enqueueConflictingPut(generateUUID())
await mutationQueue.flush()
expect(captured).toBe(BASE)
})
it('omits the header when there is no base version', async () => {
let hadHeader = true
server.use(http.put('/api/trips/1/places/42', ({ request }) => {
hadHeader = request.headers.has('X-Base-Updated-At')
return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })
}))
await enqueueConflictingPut(generateUUID(), null)
await mutationQueue.flush()
expect(hadHeader).toBe(false)
})
})
describe('mutationQueue — 409 with strategy "ask"', () => {
it('parks the mutation as a conflict carrying the server version', async () => {
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
const m = await offlineDb.mutationQueue.get(id)
expect(m!.status).toBe('conflict')
expect((m!.conflictServer as { name: string }).name).toBe('Theirs')
expect(await mutationQueue.conflictCount()).toBe(1)
expect(await mutationQueue.failedCount()).toBe(0)
})
it('does not count a conflict as pending and is skipped by later flushes', async () => {
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42 }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect(await mutationQueue.pendingCount()).toBe(0)
// A second flush must not touch the parked conflict.
await mutationQueue.flush()
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict')
})
})
describe('mutationQueue — conflict resolution', () => {
it('keep-mine re-sends without the base token and clears the conflict', async () => {
const id = generateUUID()
conflictThenAcceptHandler()
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict')
await mutationQueue.resolveKeepMine(id)
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Mine')
expect(await mutationQueue.conflictCount()).toBe(0)
})
it('keep-theirs adopts the server entity and drops the queued write', async () => {
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
await mutationQueue.resolveKeepServer(id)
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Theirs')
})
})
describe('mutationQueue — chained edits to the same entity', () => {
it('do NOT self-conflict: the new token is propagated to the next queued edit (#1135)', async () => {
// A server that does real compare-and-swap on X-Base-Updated-At and bumps
// the token on each accepted write.
let token = 'T0'
let serverPlace = { ...buildPlace({ trip_id: 1, id: 42, name: 'A' }), notes: 'orig', updated_at: token } as Record<string, unknown>
server.use(http.put('/api/trips/1/places/42', async ({ request }) => {
const base = request.headers.get('X-Base-Updated-At')
if (base !== token) return HttpResponse.json({ error: 'conflict', server: serverPlace }, { status: 409 })
const body = await request.json() as Record<string, unknown>
token = token === 'T0' ? 'T1' : 'T2'
serverPlace = { ...serverPlace, ...body, updated_at: token }
return HttpResponse.json({ place: serverPlace })
}))
await offlineDb.places.put({ ...(serverPlace as object) } as never)
// Two offline edits to different fields of place 42, both based on T0.
await mutationQueue.enqueue({ id: 'm1', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'B' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.enqueue({ id: 'm2', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { notes: 'edited' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.flush()
expect(await mutationQueue.conflictCount()).toBe(0)
expect(await offlineDb.mutationQueue.count()).toBe(0)
const final = await offlineDb.places.get(42) as unknown as { name: string; notes: string }
expect(final.name).toBe('B')
expect(final.notes).toBe('edited')
})
})
describe('mutationQueue: a later edit waits behind a conflict on the same entity', () => {
/** A compare-and-swap server where someone else already moved place 42 on to 'T1'. */
function casServer() {
let token = 'T1'
const seen: string[] = []
let serverPlace = { ...buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }), updated_at: token } as Record<string, unknown>
server.use(http.put('/api/trips/1/places/42', async ({ request }) => {
const base = request.headers.get('X-Base-Updated-At')
const body = await request.json() as { name: string }
if (base && base !== token) return HttpResponse.json({ error: 'conflict', server: serverPlace }, { status: 409 })
seen.push(body.name)
token = `${token}+`
serverPlace = { ...serverPlace, ...body, updated_at: token }
return HttpResponse.json({ place: serverPlace })
}))
return seen
}
it('keep-mine sends the conflicting edit first, then the later one on the fresh token', async () => {
const seen = casServer()
await mutationQueue.enqueue({ id: 'a', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'First' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.enqueue({ id: 'b', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'Second' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.flush()
expect((await offlineDb.mutationQueue.get('a'))!.status).toBe('conflict')
// Held, not sent: replaying it now and 'a' later would undo it.
expect((await offlineDb.mutationQueue.get('b'))!).toMatchObject({ status: 'pending', attempts: 0 })
await mutationQueue.resolveKeepMine('a')
expect(seen).toEqual(['First', 'Second'])
expect(await offlineDb.mutationQueue.count()).toBe(0)
expect((await offlineDb.places.get(42))!.name).toBe('Second')
})
it('keep-theirs lets the later edit go, which the server judges on its own token', async () => {
casServer()
await mutationQueue.enqueue({ id: 'a', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'First' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.enqueue({ id: 'b', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'Second' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' })
await mutationQueue.flush()
await mutationQueue.resolveKeepServer('a')
expect(await offlineDb.mutationQueue.get('a')).toBeUndefined()
// Made against the same stale version, so it is a conflict of its own.
expect((await offlineDb.mutationQueue.get('b'))!.status).toBe('conflict')
})
})
describe('mutationQueue — auto strategies', () => {
it('"server" adopts the server version automatically', async () => {
setConflictStrategy('server')
const id = generateUUID()
server.use(http.put('/api/trips/1/places/42', () =>
HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 })))
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Theirs')
expect(await mutationQueue.conflictCount()).toBe(0)
})
it('"mine" re-sends unconditionally and wins', async () => {
setConflictStrategy('mine')
const id = generateUUID()
conflictThenAcceptHandler()
await enqueueConflictingPut(id)
await mutationQueue.flush()
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined()
expect((await offlineDb.places.get(42))!.name).toBe('Mine')
expect(await mutationQueue.conflictCount()).toBe(0)
})
})