/** * 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); }); });