mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
f092e7a95f
A queued change the server answered with a 5xx was put back at the head of the line and the whole flush stopped, every 30 seconds, forever: one bad payload held every later change of every trip. Now only its own trip waits, with gaps doubling from 30 s, and after eight failures it is parked as failed. A DELETE answered 404 counts as done instead of showing as a failed sync. Settings > Offline offers to try parked changes again or discard them.
592 lines
22 KiB
TypeScript
592 lines
22 KiB
TypeScript
/**
|
|
* mutationQueue unit tests.
|
|
*
|
|
* Covers: enqueue, flush (2xx success, 4xx fail, network error), idempotency header,
|
|
* pending count, create temp-id reconciliation, delete Dexie cleanup.
|
|
*/
|
|
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, nextTempId } from '../../../src/sync/mutationQueue';
|
|
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
|
import { placeRepo } from '../../../src/repo/placeRepo';
|
|
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
|
|
|
beforeEach(async () => {
|
|
await clearAll();
|
|
mutationQueue._resetFlushing();
|
|
setAuthed(true);
|
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
setAuthed(false);
|
|
});
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeMutation(overrides: Partial<Parameters<typeof mutationQueue.enqueue>[0]> = {}) {
|
|
return {
|
|
id: generateUUID(),
|
|
tripId: 1,
|
|
method: 'POST' as const,
|
|
url: '/trips/1/places',
|
|
body: { name: 'Eiffel Tower' },
|
|
resource: 'places',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── enqueue ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('mutationQueue.enqueue', () => {
|
|
it('stores mutation with pending status', async () => {
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
const stored = await offlineDb.mutationQueue.get(id);
|
|
expect(stored).toBeDefined();
|
|
expect(stored!.status).toBe('pending');
|
|
expect(stored!.attempts).toBe(0);
|
|
});
|
|
|
|
it('returns the mutation id', async () => {
|
|
const id = generateUUID();
|
|
const returned = await mutationQueue.enqueue(makeMutation({ id }));
|
|
expect(returned).toBe(id);
|
|
});
|
|
});
|
|
|
|
// ── flush — success path ──────────────────────────────────────────────────────
|
|
|
|
describe('mutationQueue.flush — 2xx success', () => {
|
|
it('removes mutation from queue and writes canonical entity to Dexie', async () => {
|
|
const place = buildPlace({ trip_id: 1, id: 42 });
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const queued = await offlineDb.mutationQueue.get(id);
|
|
expect(queued).toBeUndefined();
|
|
|
|
const cached = await offlineDb.places.get(42);
|
|
expect(cached).toBeDefined();
|
|
expect(cached!.name).toBe(place.name);
|
|
});
|
|
|
|
it('attaches X-Idempotency-Key header matching the mutation id', async () => {
|
|
const place = buildPlace({ trip_id: 1 });
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
let capturedKey: string | null = null;
|
|
server.use(
|
|
http.post('/api/trips/1/places', ({ request }) => {
|
|
capturedKey = request.headers.get('X-Idempotency-Key');
|
|
return HttpResponse.json({ place });
|
|
}),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
expect(capturedKey).toBe(id);
|
|
});
|
|
|
|
it('removes temp entry and adds canonical entry on CREATE flush', async () => {
|
|
const tempId = -12345;
|
|
const place = buildPlace({ trip_id: 1, id: 99 });
|
|
const id = generateUUID();
|
|
|
|
// Optimistic temp entry in Dexie
|
|
await offlineDb.places.put({ ...place, id: tempId });
|
|
|
|
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
|
expect(await offlineDb.places.get(99)).toBeDefined();
|
|
});
|
|
|
|
it('handles DELETE: removes entity from Dexie after flush', async () => {
|
|
const place = buildPlace({ trip_id: 1, id: 55 });
|
|
await offlineDb.places.put(place);
|
|
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue({
|
|
id,
|
|
tripId: 1,
|
|
method: 'DELETE',
|
|
url: '/trips/1/places/55',
|
|
body: undefined,
|
|
resource: 'places',
|
|
entityId: 55,
|
|
});
|
|
|
|
server.use(
|
|
http.delete('/api/trips/1/places/55', () => HttpResponse.json({ success: true })),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
|
|
expect(await offlineDb.places.get(55)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── flush — error paths ───────────────────────────────────────────────────────
|
|
|
|
describe('mutationQueue.flush — 4xx client error', () => {
|
|
it('marks mutation as failed and continues to next mutation', async () => {
|
|
const id1 = generateUUID();
|
|
const id2 = generateUUID();
|
|
const place = buildPlace({ trip_id: 1 });
|
|
|
|
// Enqueue in order
|
|
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
|
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
|
|
|
let callCount = 0;
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
return HttpResponse.json({ error: 'Bad request' }, { status: 400 });
|
|
}
|
|
return HttpResponse.json({ place });
|
|
}),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const m1 = await offlineDb.mutationQueue.get(id1);
|
|
expect(m1).toBeDefined();
|
|
expect(m1!.status).toBe('failed');
|
|
|
|
// Second mutation succeeded and was removed
|
|
expect(await offlineDb.mutationQueue.get(id2)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('mutationQueue.flush — network error', () => {
|
|
it('resets to pending and stops flush without marking failed', async () => {
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.error()),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const m = await offlineDb.mutationQueue.get(id);
|
|
expect(m).toBeDefined();
|
|
expect(m!.status).toBe('pending');
|
|
expect(m!.attempts).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ── flush — offline guard ─────────────────────────────────────────────────────
|
|
|
|
describe('mutationQueue.flush — offline guard', () => {
|
|
it('does nothing when offline', async () => {
|
|
Object.defineProperty(navigator, 'onLine', { value: false });
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
let called = false;
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => {
|
|
called = true;
|
|
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
|
}),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
expect(called).toBe(false);
|
|
const m = await offlineDb.mutationQueue.get(id);
|
|
expect(m!.status).toBe('pending');
|
|
});
|
|
|
|
it('does nothing when logged out (auth gate closed)', async () => {
|
|
setAuthed(false);
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
let called = false;
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => {
|
|
called = true;
|
|
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
|
}),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
expect(called).toBe(false);
|
|
const m = await offlineDb.mutationQueue.get(id);
|
|
expect(m!.status).toBe('pending');
|
|
});
|
|
});
|
|
|
|
// ── pending / pendingCount ────────────────────────────────────────────────────
|
|
|
|
describe('mutationQueue.flush — interrupted flush recovery', () => {
|
|
it('replays a row an earlier flush abandoned on syncing', async () => {
|
|
let seen = 0;
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => {
|
|
seen += 1;
|
|
return HttpResponse.json({ place: buildPlace({ id: 42 }) });
|
|
}),
|
|
);
|
|
|
|
// What a killed tab leaves behind: marked syncing, stamped long enough ago
|
|
// that no live request could still be in flight.
|
|
const id = generateUUID();
|
|
await offlineDb.mutationQueue.add({
|
|
id, tripId: 1, method: 'POST', url: '/trips/1/places',
|
|
body: { name: 'Eiffel Tower' }, resource: 'places',
|
|
createdAt: Date.now() - 600_000, status: 'syncing', attempts: 0, lastError: null,
|
|
syncingSince: Date.now() - 600_000,
|
|
});
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(seen).toBe(1);
|
|
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
|
|
});
|
|
|
|
it('leaves a freshly marked syncing row alone (a concurrent flush owns it)', async () => {
|
|
const id = generateUUID();
|
|
await offlineDb.mutationQueue.add({
|
|
id, tripId: 1, method: 'POST', url: '/trips/1/places',
|
|
body: { name: 'Eiffel Tower' }, resource: 'places',
|
|
createdAt: Date.now(), status: 'syncing', attempts: 0, lastError: null,
|
|
syncingSince: Date.now(),
|
|
});
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect((await offlineDb.mutationQueue.get(id))!.status).toBe('syncing');
|
|
});
|
|
});
|
|
|
|
describe('mutationQueue.pending', () => {
|
|
it('returns pending mutations for a trip', async () => {
|
|
const id1 = generateUUID();
|
|
const id2 = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id: id1, tripId: 1 }));
|
|
await mutationQueue.enqueue(makeMutation({ id: id2, tripId: 2 }));
|
|
|
|
const trip1 = await mutationQueue.pending(1);
|
|
expect(trip1).toHaveLength(1);
|
|
expect(trip1[0].id).toBe(id1);
|
|
});
|
|
|
|
it('returns all pending when no tripId given', async () => {
|
|
await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 1 }));
|
|
await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 2 }));
|
|
|
|
const all = await mutationQueue.pending();
|
|
expect(all).toHaveLength(2);
|
|
});
|
|
|
|
it('excludes failed mutations', async () => {
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
await offlineDb.mutationQueue.update(id, { status: 'failed' });
|
|
|
|
const pending = await mutationQueue.pending(1);
|
|
expect(pending).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('mutationQueue.pendingCount', () => {
|
|
it('returns zero for empty queue', async () => {
|
|
expect(await mutationQueue.pendingCount()).toBe(0);
|
|
});
|
|
|
|
it('counts pending and syncing, excludes failed', async () => {
|
|
const id1 = generateUUID();
|
|
const id2 = generateUUID();
|
|
const id3 = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
|
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
|
await mutationQueue.enqueue(makeMutation({ id: id3 }));
|
|
await offlineDb.mutationQueue.update(id3, { status: 'failed' });
|
|
|
|
expect(await mutationQueue.pendingCount()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('mutationQueue.failedCount', () => {
|
|
it('counts only failed mutations (not pending/syncing)', async () => {
|
|
const id1 = generateUUID();
|
|
const id2 = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
|
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
|
await offlineDb.mutationQueue.update(id2, { status: 'failed' });
|
|
|
|
expect(await mutationQueue.failedCount()).toBe(1);
|
|
expect(await mutationQueue.pendingCount()).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ── B2: collision-free temp ids ────────────────────────────────────────────────
|
|
|
|
describe('nextTempId (B2)', () => {
|
|
it('returns distinct negative ids even within the same millisecond', () => {
|
|
mutationQueue._resetFlushing();
|
|
const a = nextTempId();
|
|
const b = nextTempId();
|
|
const c = nextTempId();
|
|
expect(a).toBeLessThan(0);
|
|
expect(new Set([a, b, c]).size).toBe(3);
|
|
});
|
|
|
|
it('two tight offline creates produce two distinct Dexie rows', async () => {
|
|
Object.defineProperty(navigator, 'onLine', { value: false });
|
|
await placeRepo.create(1, { name: 'First' });
|
|
await placeRepo.create(1, { name: 'Second' });
|
|
|
|
const rows = await offlineDb.places.where('trip_id').equals(1).toArray();
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.map(r => r.name).sort()).toEqual(['First', 'Second']);
|
|
});
|
|
});
|
|
|
|
// ── B1: temp-id → real-id remapping ─────────────────────────────────────────────
|
|
|
|
describe('mutationQueue.flush — temp-id remapping (B1)', () => {
|
|
it('rewrites a dependent PUT/DELETE to the real id within one flush', async () => {
|
|
const tempId = -1;
|
|
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
|
|
|
const createId = generateUUID();
|
|
const putId = generateUUID();
|
|
const deleteId = generateUUID();
|
|
|
|
await mutationQueue.enqueue({
|
|
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
|
body: { name: 'Temp' }, resource: 'places', tempId,
|
|
});
|
|
await mutationQueue.enqueue({
|
|
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
|
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
|
});
|
|
await mutationQueue.enqueue({
|
|
id: deleteId, tripId: 1, method: 'DELETE', url: '/trips/1/places/{id}',
|
|
body: undefined, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
|
});
|
|
|
|
const putUrls: string[] = [];
|
|
const deleteUrls: string[] = [];
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })),
|
|
http.put('/api/trips/1/places/:id', ({ params }) => { putUrls.push(String(params.id)); return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Edited' }) }); }),
|
|
http.delete('/api/trips/1/places/:id', ({ params }) => { deleteUrls.push(String(params.id)); return HttpResponse.json({ success: true }); }),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(putUrls).toEqual(['42']);
|
|
expect(deleteUrls).toEqual(['42']);
|
|
expect(await mutationQueue.pendingCount()).toBe(0);
|
|
expect(await mutationQueue.failedCount()).toBe(0);
|
|
});
|
|
|
|
it('durably rewrites a still-queued dependent after the CREATE flushes alone', async () => {
|
|
const tempId = -7;
|
|
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
|
|
|
const createId = generateUUID();
|
|
const putId = generateUUID();
|
|
await mutationQueue.enqueue({
|
|
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
|
body: { name: 'Temp' }, resource: 'places', tempId,
|
|
});
|
|
await mutationQueue.enqueue({
|
|
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
|
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
|
});
|
|
|
|
// Only the CREATE succeeds this round; the PUT errors out (network) and stays queued.
|
|
let putAttempts = 0;
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 88 }) })),
|
|
http.put('/api/trips/1/places/:id', () => { putAttempts++; return HttpResponse.error(); }),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const queuedPut = await offlineDb.mutationQueue.get(putId);
|
|
expect(queuedPut).toBeDefined();
|
|
expect(queuedPut!.url).toBe('/trips/1/places/88');
|
|
expect(queuedPut!.entityId).toBe(88);
|
|
expect(queuedPut!.tempEntityId).toBeUndefined();
|
|
expect(putAttempts).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('marks an orphaned dependent (placeholder never resolved) as failed', async () => {
|
|
const putId = generateUUID();
|
|
await mutationQueue.enqueue({
|
|
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
|
body: { name: 'Edited' }, resource: 'places', entityId: -999, tempEntityId: -999,
|
|
});
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const m = await offlineDb.mutationQueue.get(putId);
|
|
expect(m!.status).toBe('failed');
|
|
});
|
|
});
|
|
|
|
// ── B3: terminal rollback + retryable classification ────────────────────────────
|
|
|
|
describe('mutationQueue.flush — failure handling (B3)', () => {
|
|
it('rolls back the phantom optimistic row on a terminal 400 CREATE', async () => {
|
|
const tempId = -3;
|
|
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
|
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'Bad' }, { status: 400 })),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
|
const m = await offlineDb.mutationQueue.get(id);
|
|
expect(m!.status).toBe('failed');
|
|
});
|
|
|
|
it('treats 429 as retryable: resets to pending and stops the flush', async () => {
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'slow down' }, { status: 429 })),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const m = await offlineDb.mutationQueue.get(id);
|
|
expect(m!.status).toBe('pending');
|
|
expect(m!.attempts).toBe(1);
|
|
expect(await mutationQueue.failedCount()).toBe(0);
|
|
});
|
|
|
|
it('treats 401 as retryable rather than dropping the change', async () => {
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'AUTH_REQUIRED' }, { status: 401 })),
|
|
);
|
|
|
|
await mutationQueue.flush();
|
|
|
|
const m = await offlineDb.mutationQueue.get(id);
|
|
expect(m!.status).toBe('pending');
|
|
});
|
|
});
|
|
|
|
describe('mutationQueue.flush — a write the server keeps failing on', () => {
|
|
it('holds back only its own trip, with a growing gap, while other trips sync', async () => {
|
|
const stuck = generateUUID();
|
|
const sameTrip = generateUUID();
|
|
const otherTrip = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id: stuck }));
|
|
await mutationQueue.enqueue(makeMutation({ id: sameTrip, body: { name: 'Louvre' } }));
|
|
await mutationQueue.enqueue(makeMutation({ id: otherTrip, tripId: 2, url: '/trips/2/places' }));
|
|
|
|
server.use(
|
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
|
http.post('/api/trips/2/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 2, id: 77 }) })),
|
|
);
|
|
|
|
const before = Date.now();
|
|
await mutationQueue.flush();
|
|
|
|
const first = await offlineDb.mutationQueue.get(stuck);
|
|
expect(first).toMatchObject({ status: 'pending', attempts: 1 });
|
|
expect(first!.retryAfter).toBeGreaterThanOrEqual(before + 30_000);
|
|
// The trip's later write keeps its place in line…
|
|
expect(await offlineDb.mutationQueue.get(sameTrip)).toMatchObject({ status: 'pending', attempts: 0 });
|
|
// …and the other trip is not held up at all.
|
|
expect(await offlineDb.mutationQueue.get(otherTrip)).toBeUndefined();
|
|
|
|
// Inside the gap the next trigger does not knock again.
|
|
await mutationQueue.flush();
|
|
expect((await offlineDb.mutationQueue.get(stuck))!.attempts).toBe(1);
|
|
});
|
|
|
|
it('parks it as failed once it has failed often enough, so the trip moves on', async () => {
|
|
const stuck = generateUUID();
|
|
const tempId = nextTempId();
|
|
await offlineDb.places.put(buildPlace({ trip_id: 1, id: tempId }));
|
|
await mutationQueue.enqueue(makeMutation({ id: stuck, tempId }));
|
|
await offlineDb.mutationQueue.update(stuck, { attempts: 7 });
|
|
|
|
server.use(http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
|
await mutationQueue.flush();
|
|
|
|
expect(await offlineDb.mutationQueue.get(stuck)).toMatchObject({ status: 'failed', attempts: 8 });
|
|
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
|
expect(await mutationQueue.failedCount()).toBe(1);
|
|
});
|
|
|
|
it('counts a DELETE the server answers 404 as done', async () => {
|
|
const id = generateUUID();
|
|
await offlineDb.places.put(buildPlace({ trip_id: 1, id: 5 }));
|
|
await mutationQueue.enqueue(makeMutation({ id, method: 'DELETE', url: '/trips/1/places/5', body: undefined, entityId: 5 }));
|
|
|
|
server.use(http.delete('/api/trips/1/places/5', () => HttpResponse.json({ error: 'Place not found' }, { status: 404 })));
|
|
await mutationQueue.flush();
|
|
|
|
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
|
|
expect(await offlineDb.places.get(5)).toBeUndefined();
|
|
expect(await mutationQueue.failedCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('mutationQueue — parked changes', () => {
|
|
it('retryFailed puts them back in line from a fresh start and sends them', async () => {
|
|
const id = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id }));
|
|
await offlineDb.mutationQueue.update(id, { status: 'failed', attempts: 8, lastError: 'boom' });
|
|
server.use(http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 91 }) })));
|
|
|
|
await mutationQueue.retryFailed();
|
|
|
|
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
|
|
expect(await offlineDb.places.get(91)).toBeDefined();
|
|
});
|
|
|
|
it('discardFailed drops only the parked ones', async () => {
|
|
const parked = generateUUID();
|
|
const waiting = generateUUID();
|
|
await mutationQueue.enqueue(makeMutation({ id: parked }));
|
|
await mutationQueue.enqueue(makeMutation({ id: waiting }));
|
|
await offlineDb.mutationQueue.update(parked, { status: 'failed' });
|
|
|
|
await mutationQueue.discardFailed();
|
|
|
|
expect(await offlineDb.mutationQueue.get(parked)).toBeUndefined();
|
|
expect(await offlineDb.mutationQueue.get(waiting)).toBeDefined();
|
|
});
|
|
});
|