mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
9deedb80ea
Saving an expense twice while its receipt is also attached to a place and a booking hit the new unique index on (file, expense): the row kept from the first save was skipped, the next spare link row was adopted into a duplicate pair, and the constraint threw inside the transaction. The whole edit rolled back with a 500, every time, so that expense could never be changed again. The adopt now skips a link the expense already holds, and a receipt named twice in one save is linked once. The offline place cache was keyed on the place id alone while it is owned and evicted per trip, so two trips over the same city took each other's rows: the second write claimed them, switching the second trip off deleted them for both, and the first trip's fingerprint still matched so nothing ever refilled them. Offline search then answered nothing for a trip whose switch read "on". Each trip keeps its own copies now, and the upgrade clears the fingerprints so the next sync refills what the rebuild dropped. The chat composer minted its object URLs inside a state updater, which React runs twice in development, so one picked image drew two thumbnails and removing one left a preview for a picture that would not be sent. File and URL are one record now and every mutator commits a finished list. import_google_maps_route wrote up to thirty places and their day assignments under trips:write, which does not imply the places:write that create_place is gated on, and it was the one non-admin write tool with no demo gate and the only tool in the repo with no annotations. All three corrected.
241 lines
9.7 KiB
TypeScript
241 lines
9.7 KiB
TypeScript
/**
|
|
* FE-PLACEPRE-001..012 — the places around a trip, kept for offline search.
|
|
*
|
|
* The gap this closes: an offline instance had the trip's own places and
|
|
* nothing else, so "find a pharmacy near the hotel" had nothing to answer from.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import 'fake-indexeddb/auto'
|
|
import { offlineDb, clearAll } from '../db/offlineDb'
|
|
import { setAuthed } from './authGate'
|
|
import {
|
|
AREA_PLACE_LIMIT,
|
|
cachedToPlaceRecord,
|
|
foldName,
|
|
getCachedPlace,
|
|
prefetchPlacesForTrip,
|
|
searchCachedPlaces,
|
|
} from './placePrefetcher'
|
|
import { mapsApi } from '../api/client'
|
|
import type { Place } from '../types'
|
|
|
|
vi.mock('../api/client', () => ({ mapsApi: { area: vi.fn() } }))
|
|
const areaMock = vi.mocked(mapsApi.area)
|
|
|
|
const place = (lat: number, lng: number): Place =>
|
|
({ id: 1, trip_id: 7, name: 'Hotel', lat, lng }) as unknown as Place
|
|
|
|
const TRIP_PLACES = [place(54.08, 12.13), place(54.1, 12.16)]
|
|
|
|
function apiRow(name: string, over: Record<string, unknown> = {}) {
|
|
return {
|
|
osm_id: `gers:${name.toLowerCase().replace(/\W/g, '')}`,
|
|
name,
|
|
address: 'Steinstrasse 9, 18055, Rostock',
|
|
lat: 54.09,
|
|
lng: 12.14,
|
|
category: 'restaurant',
|
|
website: null,
|
|
phone: null,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await clearAll()
|
|
// The prefetch re-checks this after its request comes back, so a suite that
|
|
// never signs in would exercise the abandon path and nothing else.
|
|
setAuthed(true)
|
|
areaMock.mockReset()
|
|
areaMock.mockResolvedValue({ results: [apiRow("L'Osteria"), apiRow('Café Central')], truncated: false })
|
|
await offlineDb.syncMeta.put({
|
|
tripId: 7,
|
|
lastSyncedAt: null,
|
|
status: 'idle',
|
|
tilesBbox: null,
|
|
filesCachedCount: 0,
|
|
})
|
|
})
|
|
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
describe('foldName', () => {
|
|
it('FE-PLACEPRE-001: folds diacritics and case, so "cafe" finds "Café"', () => {
|
|
expect(foldName('Café Central')).toBe('cafe central')
|
|
expect(foldName(' Zürich ')).toBe('zurich')
|
|
})
|
|
})
|
|
|
|
describe('prefetchPlacesForTrip', () => {
|
|
it('FE-PLACEPRE-002: stores the area under the trip', async () => {
|
|
const n = await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
expect(n).toBe(2)
|
|
const rows = await offlineDb.areaPlaces.toArray()
|
|
expect(rows.map((r) => r.name).sort()).toEqual(['Café Central', "L'Osteria"])
|
|
expect(rows.every((r) => r.tripId === 7)).toBe(true)
|
|
expect(rows[0].gers.startsWith('gers:')).toBe(false)
|
|
})
|
|
|
|
it('FE-PLACEPRE-003: asks for a box around the trip, within the index cap', async () => {
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
const [bbox, limit] = areaMock.mock.calls[0]
|
|
expect(bbox.minLat).toBeLessThan(54.08)
|
|
expect(bbox.maxLat).toBeGreaterThan(54.1)
|
|
expect(bbox.maxLat - bbox.minLat).toBeLessThanOrEqual(1.5)
|
|
expect(limit).toBe(AREA_PLACE_LIMIT)
|
|
})
|
|
|
|
it('FE-PLACEPRE-004: does not re-download an area that has not moved', async () => {
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
const again = await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
expect(again).toBe(0)
|
|
expect(areaMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('FE-PLACEPRE-005b: two trips over the same city keep their own copies', async () => {
|
|
// Keyed on the GERS id alone, the second trip's write rewrote the first
|
|
// trip's rows to point at itself, and switching the second trip off then
|
|
// deleted them for both.
|
|
await offlineDb.syncMeta.put({
|
|
tripId: 8, lastSyncedAt: null, status: 'idle', tilesBbox: null, filesCachedCount: 0,
|
|
})
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
await prefetchPlacesForTrip(8, TRIP_PLACES)
|
|
|
|
const rows = await offlineDb.areaPlaces.toArray()
|
|
expect(rows).toHaveLength(4)
|
|
expect(rows.filter((r) => r.tripId === 7)).toHaveLength(2)
|
|
expect(rows.filter((r) => r.tripId === 8)).toHaveLength(2)
|
|
});
|
|
|
|
it('FE-PLACEPRE-005c: dropping one trip leaves the other trip searchable', async () => {
|
|
await offlineDb.syncMeta.put({
|
|
tripId: 8, lastSyncedAt: null, status: 'idle', tilesBbox: null, filesCachedCount: 0,
|
|
})
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
await prefetchPlacesForTrip(8, TRIP_PLACES)
|
|
|
|
await offlineDb.areaPlaces.where('tripId').equals(8).delete()
|
|
|
|
// Offline search spans every cached trip, and trip 7 never asked to lose
|
|
// anything. Its fingerprint also still matches, so nothing would refill it.
|
|
const hits = await searchCachedPlaces('osteria')
|
|
expect(hits.map((h) => h.name)).toEqual(["L'Osteria"])
|
|
});
|
|
|
|
it('FE-PLACEPRE-005: re-downloads when the trip moves', async () => {
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
await prefetchPlacesForTrip(7, [place(41.9, 12.5), place(41.92, 12.52)])
|
|
expect(areaMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('FE-PLACEPRE-006: replaces the old area rather than merging into it', async () => {
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
areaMock.mockResolvedValue({ results: [apiRow('Trattoria Roma')], truncated: false })
|
|
await prefetchPlacesForTrip(7, [place(41.9, 12.5), place(41.92, 12.52)])
|
|
// Keeping the old rows would make an offline search answer with the last city.
|
|
expect((await offlineDb.areaPlaces.toArray()).map((r) => r.name)).toEqual(['Trattoria Roma'])
|
|
})
|
|
|
|
it('FE-PLACEPRE-007: a trip with no located places costs nothing', async () => {
|
|
expect(await prefetchPlacesForTrip(7, [])).toBe(0)
|
|
expect(areaMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('FE-PLACEPRE-008: clamps a trip that spans more than the index allows', async () => {
|
|
await prefetchPlacesForTrip(7, [place(35, 5), place(60, 25)])
|
|
const [bbox] = areaMock.mock.calls[0]
|
|
expect(bbox.maxLat - bbox.minLat).toBeLessThanOrEqual(1.5)
|
|
expect(bbox.maxLng - bbox.minLng).toBeLessThanOrEqual(1.5)
|
|
})
|
|
|
|
it('FE-PLACEPRE-009: a failure never propagates into the sync', async () => {
|
|
areaMock.mockRejectedValue(new Error('service down'))
|
|
await expect(prefetchPlacesForTrip(7, TRIP_PLACES)).resolves.toBe(0)
|
|
})
|
|
|
|
it('FE-PLACEPRE-010: stores nothing when the index is switched off', async () => {
|
|
areaMock.mockResolvedValue({ results: [], truncated: false, unavailable: true })
|
|
expect(await prefetchPlacesForTrip(7, TRIP_PLACES)).toBe(0)
|
|
expect(await offlineDb.areaPlaces.count()).toBe(0)
|
|
})
|
|
|
|
it('FE-PLACEPRE-011: skips a row with no id or no name rather than storing a blank', async () => {
|
|
areaMock.mockResolvedValue({
|
|
results: [apiRow('Real Place'), { ...apiRow('x'), name: ' ' }, { ...apiRow('y'), osm_id: null }],
|
|
truncated: false,
|
|
})
|
|
expect(await prefetchPlacesForTrip(7, TRIP_PLACES)).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('searchCachedPlaces', () => {
|
|
beforeEach(async () => {
|
|
await prefetchPlacesForTrip(7, TRIP_PLACES)
|
|
})
|
|
|
|
it('FE-PLACEPRE-012: matches on a prefix and on a word inside the name', async () => {
|
|
expect((await searchCachedPlaces('cafe')).map((p) => p.name)).toEqual(['Café Central'])
|
|
// No prefix index answers this one — "L'Osteria" folds to "l'osteria".
|
|
expect((await searchCachedPlaces('osteria')).map((p) => p.name)).toEqual(["L'Osteria"])
|
|
})
|
|
|
|
it('FE-PLACEPRE-013: ignores a query too short to mean anything', async () => {
|
|
expect(await searchCachedPlaces('c')).toEqual([])
|
|
})
|
|
|
|
it('FE-PLACEPRE-014: reads across trips, because the area is where you are', async () => {
|
|
// tripId is how a set is evicted, not who may read it. Somebody offline in
|
|
// Rostock searching from the journal wants the same places.
|
|
expect((await searchCachedPlaces('osteria')).length).toBe(1)
|
|
await offlineDb.areaPlaces.toCollection().modify({ tripId: 99 })
|
|
expect((await searchCachedPlaces('osteria')).length).toBe(1)
|
|
})
|
|
|
|
it('FE-PLACEPRE-015: hands back the shape every search caller already reads', async () => {
|
|
const [hit] = await searchCachedPlaces('osteria')
|
|
expect(cachedToPlaceRecord(hit)).toMatchObject({
|
|
osm_id: "gers:l'osteria".replace("'", ''),
|
|
name: "L'Osteria",
|
|
// Named rather than passed off as live: several screens show this.
|
|
source: 'offline-cache',
|
|
rating: null,
|
|
})
|
|
})
|
|
|
|
it('FE-PLACEPRE-016: finds a place by the id its own suggestion handed out', async () => {
|
|
// This closes the loop offline. The suggestion list gives out `gers:` ids;
|
|
// picking one calls the details lookup with exactly that string, and before
|
|
// this the lookup had no cache path, failed, and the callers fell back to
|
|
// searching for "name, address" — which matches on the folded NAME alone,
|
|
// so it found nothing and the user got an error for a place that was
|
|
// sitting in the cache.
|
|
const [hit] = await searchCachedPlaces('osteria')
|
|
const found = await getCachedPlace(`gers:${hit.gers}`)
|
|
expect(found?.name).toBe("L'Osteria")
|
|
})
|
|
|
|
it('FE-PLACEPRE-018: a logout while the request is in flight leaves the database alone', async () => {
|
|
// The handle repoints at the anonymous database on logout, so writing the
|
|
// answer after that seeds one person's trip area into the next person's
|
|
// offline search. A shared tablet is all it takes.
|
|
await clearAll()
|
|
areaMock.mockImplementation(async () => {
|
|
setAuthed(false)
|
|
return { results: [apiRow('Nach dem Logout')], truncated: false }
|
|
})
|
|
|
|
await expect(prefetchPlacesForTrip(7, TRIP_PLACES, true)).resolves.toBe(0)
|
|
expect(await offlineDb.areaPlaces.count()).toBe(0)
|
|
})
|
|
|
|
it('FE-PLACEPRE-017: an id the cache does not hold is a miss, not somebody else record', async () => {
|
|
// A miss has to stay a miss: answering the wrong place would be worse than
|
|
// the failure it replaces.
|
|
expect(await getCachedPlace('gers:not-cached')).toBeNull()
|
|
// Ids from sources the cache never holds are not looked up at all.
|
|
expect(await getCachedPlace('node:5255005321')).toBeNull()
|
|
expect(await getCachedPlace('ChIJLU7jZClu5kcR4PcOOO6p3I0')).toBeNull()
|
|
})
|
|
})
|