Files
TREK/client/tests/unit/db/offlineDb.test.ts
jubnl b194e8317d feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips:

- Dexie IndexedDB schema (trips, places, packing, todo, budget,
  reservations, files, mutationQueue, syncMeta, blobCache)
- Repo layer for all domains: offline reads from Dexie, writes
  optimistically to Dexie and enqueue mutations for later replay
- Mutation queue with UUID idempotency keys (X-Idempotency-Key),
  FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx
- Trip sync manager: caches all trips with end_date >= today or null,
  auto-evicts 7d after end_date, fetches bundle endpoint in one request
- Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap,
  warms SW cache via fetch
- Sync triggers: network online → flush + syncAll; WS reconnect →
  flush only (rate-limiter safe); visibilitychange/30s → flush only
- WS remoteEventHandler writes through to Dexie on every event
- Server idempotency middleware + idempotency_keys table (migration 100,
  24h TTL nightly cleanup)
- GET /api/trips/:id/bundle endpoint for efficient single-request sync
- OfflineBanner component: amber (offline) / blue (syncing) / hidden
- OfflineTab in Settings: cached trip list, re-sync and clear actions
- usePendingMutations hook for per-item pending indicators

Closes #505 #541
2026-04-14 23:04:25 +02:00

274 lines
9.1 KiB
TypeScript

/**
* offlineDb unit tests.
*
* Uses fake-indexeddb so no real browser IDB is needed.
* Each test gets a fresh database by using `use-fake-indexeddb` with Dexie.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import 'fake-indexeddb/auto';
import Dexie from 'dexie';
// Re-import after fake-indexeddb is set up so Dexie picks up the shim.
// We re-open a clean db in each test to isolate state.
import {
offlineDb,
clearTripData,
clearAll,
upsertTrip,
upsertDays,
upsertPlaces,
upsertPackingItems,
upsertTodoItems,
upsertBudgetItems,
upsertReservations,
upsertTripFiles,
upsertSyncMeta,
type QueuedMutation,
type SyncMeta,
type BlobCacheEntry,
} from '../../../src/db/offlineDb';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../../../src/types';
// ── Fixtures ──────────────────────────────────────────────────────────────────
const makeTrip = (id = 1): Trip => ({
id,
name: `Trip ${id}`,
description: null,
start_date: '2026-07-01',
end_date: '2026-07-05',
cover_url: null,
is_archived: false,
reminder_days: 3,
owner_id: 42,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
});
const makeDay = (id: number, tripId = 1): Day => ({
id,
trip_id: tripId,
date: '2026-07-01',
title: null,
notes: null,
assignments: [],
notes_items: [],
});
const makePlace = (id: number, tripId = 1): Place => ({
id,
trip_id: tripId,
name: `Place ${id}`,
description: null,
notes: null,
lat: 48.8566,
lng: 2.3522,
address: null,
category_id: null,
icon: null,
price: null,
currency: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
duration_minutes: null,
transport_mode: null,
website: null,
phone: null,
created_at: '2026-01-01T00:00:00Z',
});
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(async () => {
// Ensure DB is open (fake-indexeddb resets between test files but not between tests).
if (!offlineDb.isOpen()) await offlineDb.open();
// Clear all tables before each test.
await clearAll();
});
afterEach(async () => {
if (!offlineDb.isOpen()) await offlineDb.open();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('offlineDb — trips', () => {
it('stores and retrieves a trip via upsertTrip', async () => {
const trip = makeTrip(10);
await upsertTrip(trip);
const stored = await offlineDb.trips.get(10);
expect(stored).toBeDefined();
expect(stored!.name).toBe('Trip 10');
});
it('upsertTrip overwrites an existing trip (put semantics)', async () => {
await upsertTrip(makeTrip(1));
await upsertTrip({ ...makeTrip(1), name: 'Updated' });
const stored = await offlineDb.trips.get(1);
expect(stored!.name).toBe('Updated');
});
});
describe('offlineDb — days', () => {
it('stores days and retrieves by trip_id index', async () => {
await upsertDays([makeDay(1, 5), makeDay(2, 5), makeDay(3, 9)]);
const trip5Days = await offlineDb.days.where('trip_id').equals(5).toArray();
expect(trip5Days).toHaveLength(2);
expect(trip5Days.map(d => d.id)).toContain(1);
expect(trip5Days.map(d => d.id)).toContain(2);
});
});
describe('offlineDb — places', () => {
it('stores places and retrieves by trip_id', async () => {
await upsertPlaces([makePlace(10, 1), makePlace(11, 1), makePlace(12, 2)]);
const places = await offlineDb.places.where('trip_id').equals(1).toArray();
expect(places).toHaveLength(2);
});
});
describe('offlineDb — packing / todo / budget / reservations / files', () => {
it('upserts packing items', async () => {
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 };
await upsertPackingItems([item]);
expect(await offlineDb.packingItems.count()).toBe(1);
});
it('upserts todo items', async () => {
const item: TodoItem = {
id: 1, trip_id: 1, name: 'Book hotel', category: null, checked: 0,
sort_order: 0, due_date: null, description: null, assigned_user_id: null, priority: 0,
};
await upsertTodoItems([item]);
expect(await offlineDb.todoItems.count()).toBe(1);
});
it('upserts budget items', async () => {
const item: BudgetItem = {
id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR',
category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null,
};
await upsertBudgetItems([item]);
expect(await offlineDb.budgetItems.count()).toBe(1);
});
it('upserts reservations', async () => {
const item: Reservation = {
id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed',
date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z',
};
await upsertReservations([item]);
expect(await offlineDb.reservations.count()).toBe(1);
});
it('upserts trip files', async () => {
const file: TripFile = {
id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf',
mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z',
};
await upsertTripFiles([file]);
expect(await offlineDb.tripFiles.count()).toBe(1);
});
});
describe('offlineDb — syncMeta', () => {
it('stores and retrieves syncMeta by tripId', async () => {
const meta: SyncMeta = {
tripId: 7,
lastSyncedAt: Date.now(),
status: 'idle',
tilesBbox: null,
filesCachedCount: 0,
};
await upsertSyncMeta(meta);
const stored = await offlineDb.syncMeta.get(7);
expect(stored).toBeDefined();
expect(stored!.status).toBe('idle');
});
});
describe('offlineDb — mutationQueue', () => {
it('stores queued mutations queryable by status', async () => {
const pending: QueuedMutation = {
id: 'uuid-1', tripId: 1, method: 'POST', url: '/api/trips/1/places',
body: { name: 'Eiffel Tower' }, createdAt: Date.now(),
status: 'pending', attempts: 0, lastError: null,
};
const failed: QueuedMutation = {
id: 'uuid-2', tripId: 1, method: 'PUT', url: '/api/trips/1/places/5',
body: { name: 'Updated' }, createdAt: Date.now(),
status: 'failed', attempts: 3, lastError: 'Network error',
};
await offlineDb.mutationQueue.bulkPut([pending, failed]);
const pendingRows = await offlineDb.mutationQueue.where('status').equals('pending').toArray();
expect(pendingRows).toHaveLength(1);
expect(pendingRows[0].id).toBe('uuid-1');
const failedRows = await offlineDb.mutationQueue.where('status').equals('failed').toArray();
expect(failedRows).toHaveLength(1);
expect(failedRows[0].lastError).toBe('Network error');
});
});
describe('offlineDb — blobCache', () => {
it('stores and retrieves a Blob entry', async () => {
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
const entry: BlobCacheEntry = {
url: '/api/files/99/download',
blob,
mime: 'application/pdf',
cachedAt: Date.now(),
};
await offlineDb.blobCache.put(entry);
const stored = await offlineDb.blobCache.get('/api/files/99/download');
expect(stored).toBeDefined();
expect(stored!.mime).toBe('application/pdf');
expect(stored!.blob).toBeDefined();
});
});
describe('offlineDb — clearTripData', () => {
it('removes all data for the given trip across all tables', async () => {
await upsertTrip(makeTrip(1));
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
await upsertPlaces([makePlace(10, 1)]);
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 };
await upsertPackingItems([item]);
// Also add data for a different trip — should NOT be removed
await upsertTrip(makeTrip(2));
await upsertDays([makeDay(99, 2)]);
await clearTripData(1);
expect(await offlineDb.trips.get(1)).toBeUndefined();
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
// Trip 2 intact
expect(await offlineDb.trips.get(2)).toBeDefined();
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
});
});
describe('offlineDb — clearAll', () => {
it('empties all tables', async () => {
await upsertTrip(makeTrip(1));
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
await upsertPlaces([makePlace(10, 1)]);
await clearAll();
expect(await offlineDb.trips.count()).toBe(0);
expect(await offlineDb.days.count()).toBe(0);
expect(await offlineDb.places.count()).toBe(0);
});
});