Files
TREK/client/src/db/offlineDb.test.ts
T
Maurice 237f82c0b6 fix(offline): an archived trip is not a deleted one, and eviction keeps parked changes
The trip sync clears every cached trip the server no longer lists, but it
read only GET /trips, which leaves archived trips out. Every sync (login,
reconnect, the periodic run) therefore wiped every archived trip from the
device, pinned ones included, and with it their parked failed changes. A
trip now only counts as gone when it is missing from both the active and
the archived list, and when the archived list cannot be read nothing is
treated as gone that round.

clearTripData no longer deletes 'failed' rows. Stale eviction, a trip
switched off in the storage settings and a trip that is really gone all
go through it, and none of them is the user deciding to give up a change.
A parked change of a deleted trip, or one the user was removed from, now
stays in the queue: Settings > Offline counts it under failed changes and
offers Try again (the server answers 403/404, so it parks again) or
Discard. Dropping it silently would lose the only copy of an edit the
user may still want to look at or redo elsewhere, and keeping it costs
nothing more than a counter the user can clear in one tap.
2026-10-08 13:02:58 +02:00

403 lines
15 KiB
TypeScript

// FE-DB-OFFLINE-001 to FE-DB-OFFLINE-029
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import 'fake-indexeddb/auto'
import Dexie from 'dexie'
import {
offlineDb,
clearAll,
clearTripData,
reopenForUser,
reopenAnonymous,
deleteCurrentUserDb,
upsertTrip,
upsertDays,
upsertPlaces,
upsertPackingItems,
upsertTodoItems,
upsertBudgetItems,
upsertReservations,
upsertTripFiles,
upsertAccommodations,
upsertTripMembers,
upsertTags,
upsertCategories,
upsertSyncMeta,
getCachedBlob,
saveImportFiles,
getImportFiles,
deleteImportFiles,
enforceBlobBudget,
BLOB_CACHE_MAX_ENTRIES,
BLOB_CACHE_MAX_BYTES,
} from './offlineDb'
import type { BlobCacheEntry, QueuedMutation } from './offlineDb'
import type { Accommodation, TripMember } from '../types'
import {
buildTrip,
buildDay,
buildPlace,
buildPackingItem,
buildTodoItem,
buildBudgetItem,
buildReservation,
buildTripFile,
buildTag,
buildCategory,
} from '../../tests/helpers/factories'
function blobEntry(url: string, cachedAt: number, bytes: number, tripId = 1): BlobCacheEntry {
return { url, tripId, blob: new Blob(['x'.repeat(bytes)]), bytes, mime: 'application/pdf', cachedAt }
}
function queued(id: string, tripId: number, status: QueuedMutation['status']): QueuedMutation {
return { id, tripId, method: 'PUT', url: `/trips/${tripId}/places/1`, body: {}, createdAt: 1, status, attempts: 0, lastError: null }
}
beforeEach(async () => {
await clearAll()
})
afterEach(async () => {
vi.restoreAllMocks()
await reopenAnonymous()
})
describe('offlineDb — bulk upsert helpers', () => {
it('FE-DB-OFFLINE-001: every entity helper writes into its own table', async () => {
await upsertTrip(buildTrip({ id: 1 }))
await upsertDays([buildDay({ id: 1, trip_id: 1 })])
await upsertPlaces([buildPlace({ id: 1, trip_id: 1 })])
await upsertPackingItems([buildPackingItem({ id: 1, trip_id: 1 })])
await upsertTodoItems([buildTodoItem({ id: 1, trip_id: 1 })])
await upsertBudgetItems([buildBudgetItem({ id: 1, trip_id: 1 })])
await upsertReservations([buildReservation({ id: 1, trip_id: 1 })])
await upsertTripFiles([buildTripFile({ id: 1, trip_id: 1 })])
await upsertAccommodations([{ id: 1, trip_id: 1, start_day_id: 1, end_day_id: 2 } as Accommodation])
await upsertTags([buildTag({ id: 1 })])
await upsertCategories([buildCategory({ id: 1 })])
expect(await offlineDb.trips.count()).toBe(1)
expect(await offlineDb.days.count()).toBe(1)
expect(await offlineDb.places.count()).toBe(1)
expect(await offlineDb.packingItems.count()).toBe(1)
expect(await offlineDb.todoItems.count()).toBe(1)
expect(await offlineDb.budgetItems.count()).toBe(1)
expect(await offlineDb.reservations.count()).toBe(1)
expect(await offlineDb.tripFiles.count()).toBe(1)
expect(await offlineDb.accommodations.count()).toBe(1)
expect(await offlineDb.tags.count()).toBe(1)
expect(await offlineDb.categories.count()).toBe(1)
})
it('FE-DB-OFFLINE-002: upsertTripMembers stamps the tripId onto every member row', async () => {
const members = [
{ id: 5, username: 'ana', role: 'owner' },
{ id: 6, username: 'ben', role: 'member' },
] as unknown as TripMember[]
await upsertTripMembers(42, members)
const rows = await offlineDb.tripMembers.where('tripId').equals(42).toArray()
expect(rows.map(r => r.username).sort()).toEqual(['ana', 'ben'])
expect(rows.every(r => r.tripId === 42)).toBe(true)
})
it('FE-DB-OFFLINE-003: upsertSyncMeta overwrites the previous row for the same trip', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: 100, status: 'idle', tilesBbox: null, filesCachedCount: 0 })
await upsertSyncMeta({ tripId: 1, lastSyncedAt: 200, status: 'error', tilesBbox: [0, 0, 1, 1], filesCachedCount: 3 })
const meta = await offlineDb.syncMeta.get(1)
expect(meta).toMatchObject({ lastSyncedAt: 200, status: 'error', filesCachedCount: 3 })
expect(await offlineDb.syncMeta.count()).toBe(1)
})
})
describe('offlineDb — getCachedBlob', () => {
it('FE-DB-OFFLINE-004: returns null when the url was never cached', async () => {
expect(await getCachedBlob('/api/files/1/download')).toBeNull()
})
it('FE-DB-OFFLINE-005: reapplies the stored MIME when the persisted Blob lost its type', async () => {
await offlineDb.blobCache.put({
url: '/a.csv', tripId: 1, blob: new Blob(['a,b']), bytes: 3, mime: 'text/csv', cachedAt: 1,
})
const blob = await getCachedBlob('/a.csv')
expect(blob).toBeInstanceOf(Blob)
expect(blob!.type).toBe('text/csv')
})
it('FE-DB-OFFLINE-006: falls back to octet-stream when neither the Blob nor the row has a type', async () => {
await offlineDb.blobCache.put({
url: '/a.bin', tripId: 1, blob: new Blob(['a']), bytes: 1, mime: '', cachedAt: 1,
})
expect((await getCachedBlob('/a.bin'))!.type).toBe('application/octet-stream')
})
it('FE-DB-OFFLINE-007: a read error degrades to null instead of throwing', async () => {
vi.spyOn(offlineDb.blobCache, 'get').mockRejectedValue(new Error('db closed'))
expect(await getCachedBlob('/a.pdf')).toBeNull()
})
})
describe('offlineDb — booking-import source files', () => {
it('FE-DB-OFFLINE-008: stores a job\'s files under [jobId+fileName] and rebuilds them as Files', async () => {
await saveImportFiles('job-1', [
new File(['a'], 'ticket.pdf', { type: 'application/pdf' }),
new File(['b'], 'boarding.pdf', { type: 'application/pdf' }),
])
expect(await offlineDb.importFiles.where('jobId').equals('job-1').count()).toBe(2)
const files = await getImportFiles('job-1')
expect(files.map(f => f.name).sort()).toEqual(['boarding.pdf', 'ticket.pdf'])
expect(files.every(f => f instanceof File)).toBe(true)
})
it('FE-DB-OFFLINE-009: a stored Blob without a type is rebuilt as octet-stream', async () => {
await saveImportFiles('job-2', [new File(['x'], 'unknown')])
expect((await getImportFiles('job-2'))[0].type).toBe('application/octet-stream')
})
it('FE-DB-OFFLINE-010: saving prunes source files from abandoned imports older than an hour', async () => {
await offlineDb.importFiles.put({
jobId: 'stale', fileName: 'old.pdf', blob: new Blob(['old']), createdAt: Date.now() - 2 * 3600_000,
})
await saveImportFiles('fresh', [new File(['new'], 'new.pdf')])
expect(await getImportFiles('stale')).toEqual([])
expect(await getImportFiles('fresh')).toHaveLength(1)
})
it('FE-DB-OFFLINE-011: deleteImportFiles drops only the given job', async () => {
await saveImportFiles('job-a', [new File(['a'], 'a.pdf')])
await saveImportFiles('job-b', [new File(['b'], 'b.pdf')])
await deleteImportFiles('job-a')
expect(await getImportFiles('job-a')).toEqual([])
expect(await getImportFiles('job-b')).toHaveLength(1)
})
it('FE-DB-OFFLINE-012: all three helpers stay best-effort when Dexie throws', async () => {
vi.spyOn(offlineDb.importFiles, 'bulkPut').mockRejectedValue(new Error('quota'))
vi.spyOn(offlineDb.importFiles, 'where').mockImplementation(() => { throw new Error('db closed') })
await expect(saveImportFiles('job-c', [new File(['c'], 'c.pdf')])).resolves.toBeUndefined()
await expect(getImportFiles('job-c')).resolves.toEqual([])
await expect(deleteImportFiles('job-c')).resolves.toBeUndefined()
})
})
describe('offlineDb — blob cache budget', () => {
it('FE-DB-OFFLINE-013: exposes conservative defaults', () => {
expect(BLOB_CACHE_MAX_ENTRIES).toBe(200)
expect(BLOB_CACHE_MAX_BYTES).toBe(100 * 1024 * 1024)
})
it('FE-DB-OFFLINE-014: a cache within both budgets is left untouched', async () => {
await offlineDb.blobCache.bulkPut([blobEntry('/1', 1, 10), blobEntry('/2', 2, 10)])
await enforceBlobBudget(5, 1000)
expect(await offlineDb.blobCache.count()).toBe(2)
})
it('FE-DB-OFFLINE-015: evicts oldest-first until the entry count fits', async () => {
await offlineDb.blobCache.bulkPut([blobEntry('/1', 1, 10), blobEntry('/2', 2, 10), blobEntry('/3', 3, 10)])
await enforceBlobBudget(1, 1000)
expect((await offlineDb.blobCache.toArray()).map(e => e.url)).toEqual(['/3'])
})
it('FE-DB-OFFLINE-016: evicts oldest-first until the byte budget fits', async () => {
await offlineDb.blobCache.bulkPut([blobEntry('/1', 1, 100), blobEntry('/2', 2, 100), blobEntry('/3', 3, 100)])
await enforceBlobBudget(100, 150)
expect((await offlineDb.blobCache.toArray()).map(e => e.url)).toEqual(['/3'])
})
it('FE-DB-OFFLINE-017: rows written before the bytes column are counted as zero', async () => {
await offlineDb.blobCache.bulkPut([
{ url: '/legacy', tripId: -1, blob: new Blob(['x']), mime: '', cachedAt: 1 } as unknown as BlobCacheEntry,
blobEntry('/2', 2, 100),
])
await enforceBlobBudget(100, 100)
expect(await offlineDb.blobCache.count()).toBe(2)
})
})
describe('offlineDb — clearTripData', () => {
it('FE-DB-OFFLINE-018: drops the trip\'s read cache and leaves other trips alone', async () => {
await upsertTrip(buildTrip({ id: 1 }))
await upsertTrip(buildTrip({ id: 2 }))
await upsertDays([buildDay({ id: 1, trip_id: 1 }), buildDay({ id: 2, trip_id: 2 })])
await upsertPlaces([buildPlace({ id: 1, trip_id: 1 }), buildPlace({ id: 2, trip_id: 2 })])
await upsertPackingItems([buildPackingItem({ id: 1, trip_id: 1 })])
await upsertTodoItems([buildTodoItem({ id: 1, trip_id: 1 })])
await upsertBudgetItems([buildBudgetItem({ id: 1, trip_id: 1 })])
await upsertReservations([buildReservation({ id: 1, trip_id: 1 })])
await upsertTripFiles([buildTripFile({ id: 1, trip_id: 1 })])
await upsertAccommodations([{ id: 1, trip_id: 1, start_day_id: 1, end_day_id: 2 } as Accommodation])
await upsertTripMembers(1, [{ id: 9, username: 'ana', role: 'owner' } as unknown as TripMember])
await upsertSyncMeta({ tripId: 1, lastSyncedAt: 1, status: 'idle', tilesBbox: null, filesCachedCount: 0 })
await offlineDb.blobCache.put(blobEntry('/f1', 1, 10, 1))
await offlineDb.blobCache.put(blobEntry('/f2', 2, 10, 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.count()).toBe(0)
expect(await offlineDb.todoItems.count()).toBe(0)
expect(await offlineDb.budgetItems.count()).toBe(0)
expect(await offlineDb.reservations.count()).toBe(0)
expect(await offlineDb.tripFiles.count()).toBe(0)
expect(await offlineDb.accommodations.count()).toBe(0)
expect(await offlineDb.tripMembers.count()).toBe(0)
expect(await offlineDb.syncMeta.get(1)).toBeUndefined()
expect(await offlineDb.trips.get(2)).toBeDefined()
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1)
expect((await offlineDb.blobCache.toArray()).map(e => e.url)).toEqual(['/f2'])
})
it('FE-DB-OFFLINE-019: keeps unsynced work, conflicts and parked failed changes', async () => {
await offlineDb.mutationQueue.bulkPut([
queued('m-pending', 1, 'pending'),
queued('m-syncing', 1, 'syncing'),
queued('m-conflict', 1, 'conflict'),
queued('m-failed', 1, 'failed'),
queued('m-other-trip', 2, 'failed'),
])
await clearTripData(1)
const left = (await offlineDb.mutationQueue.toArray()).map(m => m.id).sort()
expect(left).toEqual(['m-conflict', 'm-failed', 'm-other-trip', 'm-pending', 'm-syncing'])
})
})
describe('offlineDb — per-user database scoping', () => {
it('FE-DB-OFFLINE-020: reopenForUser switches to the user-scoped database', async () => {
await reopenForUser(7)
expect(offlineDb.name).toBe('trek-offline-u7')
expect(offlineDb.isOpen()).toBe(true)
})
it('FE-DB-OFFLINE-021: one account cannot read another account\'s cached trips', async () => {
await reopenForUser(7)
await upsertTrip(buildTrip({ id: 500, title: 'Seven' }))
await reopenForUser(8)
expect(await offlineDb.trips.get(500)).toBeUndefined()
await reopenForUser(7)
expect((await offlineDb.trips.get(500))!.title).toBe('Seven')
})
it('FE-DB-OFFLINE-022: switching to the database already in use just reopens it', async () => {
await reopenForUser(7)
offlineDb.close()
await reopenForUser(7)
expect(offlineDb.name).toBe('trek-offline-u7')
expect(offlineDb.isOpen()).toBe(true)
})
it('FE-DB-OFFLINE-023: deleteCurrentUserDb wipes the account data and returns to anonymous', async () => {
await reopenForUser(9)
await upsertTrip(buildTrip({ id: 501 }))
await deleteCurrentUserDb()
expect(offlineDb.name).toBe('trek-offline')
await reopenForUser(9)
expect(await offlineDb.trips.count()).toBe(0)
})
it('FE-DB-OFFLINE-024: deleteCurrentUserDb on the anonymous database is a no-op switch', async () => {
await reopenAnonymous()
await upsertTrip(buildTrip({ id: 502 }))
await deleteCurrentUserDb()
expect(offlineDb.name).toBe('trek-offline')
expect(await offlineDb.trips.get(502)).toBeDefined()
})
})
describe('offlineDb — connection proxy', () => {
it('FE-DB-OFFLINE-028: reads and writes go to the connection that is live right now', async () => {
const proxied = offlineDb as unknown as Record<string, unknown>
proxied.__marker = 'anon'
expect(proxied.__marker).toBe('anon')
await reopenForUser(56)
expect((offlineDb as unknown as Record<string, unknown>).__marker).toBeUndefined()
})
it('FE-DB-OFFLINE-029: upgrading a pre-v3 cache backfills tripId and bytes on blob rows', async () => {
const legacy = new Dexie('trek-offline-u55')
legacy.version(1).stores({
trips: 'id',
days: 'id, trip_id',
places: 'id, trip_id',
packingItems: 'id, trip_id',
todoItems: 'id, trip_id',
budgetItems: 'id, trip_id',
reservations: 'id, trip_id',
tripFiles: 'id, trip_id',
mutationQueue: 'id, tripId, status, createdAt',
syncMeta: 'tripId',
blobCache: 'url, cachedAt',
})
legacy.version(2).stores({
accommodations: 'id, trip_id',
tripMembers: '[tripId+id], tripId',
tags: 'id',
categories: 'id',
})
await legacy.open()
await legacy.table('blobCache').put({ url: '/legacy.pdf', blob: new Blob(['abc']), mime: 'application/pdf', cachedAt: 1 })
legacy.close()
await reopenForUser(55)
const row = await offlineDb.blobCache.get('/legacy.pdf')
expect(row!.tripId).toBe(-1)
expect(typeof row!.bytes).toBe('number')
})
})
describe('offlineDb — initial database name', () => {
it('FE-DB-OFFLINE-025: boots straight into the persisted user\'s database', async () => {
localStorage.setItem('trek_auth_snapshot', JSON.stringify({ state: { user: { id: 33 } } }))
vi.resetModules()
const mod = await import('./offlineDb')
expect(mod.offlineDb.name).toBe('trek-offline-u33')
})
it('FE-DB-OFFLINE-026: falls back to the anonymous database without a snapshot', async () => {
localStorage.removeItem('trek_auth_snapshot')
vi.resetModules()
const mod = await import('./offlineDb')
expect(mod.offlineDb.name).toBe('trek-offline')
})
it('FE-DB-OFFLINE-027: a corrupt snapshot falls back to the anonymous database', async () => {
localStorage.setItem('trek_auth_snapshot', 'not json')
vi.resetModules()
const mod = await import('./offlineDb')
expect(mod.offlineDb.name).toBe('trek-offline')
})
})