Files
TREK/client/src/hooks/useTripWebSocket.test.ts
T
Maurice e0efa0314a feat(tours): tours read and write through the offline core
A tourRepo replaces the direct toursApi calls: reads fall back to a new
Dexie tours table, and a tour saved or edited offline is written there and
queued like a place, under one temporary id for the place and its facet.
The queue moves both to the real id on replay and drops both when the
server refuses the create. Trip sync caches each trip's tours while the
addon is on, so a tour no longer reads as a plain place offline.

The tour metrics move to shared so the offline copy shows the server's
numbers, and useTourPlaceIds keeps one Set per change as intended.
2026-10-07 21:30:45 +02:00

208 lines
8.1 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
const mocks = vi.hoisted(() => {
const listeners = new Set<(event: Record<string, unknown>) => void>()
const tourRepo = { list: vi.fn() }
const handleRemoteEvent = vi.fn()
const loadFiles = vi.fn()
const useTripStore = Object.assign(vi.fn(() => ({ loadFiles })), {
getState: () => ({ handleRemoteEvent }),
})
return {
listeners,
tourRepo,
handleRemoteEvent,
loadFiles,
useTripStore,
joinTrip: vi.fn(),
leaveTrip: vi.fn(),
addListener: vi.fn((listener: (event: Record<string, unknown>) => void) => listeners.add(listener)),
removeListener: vi.fn((listener: (event: Record<string, unknown>) => void) => listeners.delete(listener)),
}
})
vi.mock('../repo/tourRepo', () => ({ tourRepo: mocks.tourRepo }))
vi.mock('../api/websocket', () => ({
joinTrip: mocks.joinTrip,
leaveTrip: mocks.leaveTrip,
addListener: mocks.addListener,
removeListener: mocks.removeListener,
}))
vi.mock('../store/tripStore', () => ({ useTripStore: mocks.useTripStore }))
import { useTourPlaceIds } from './useTourPlaceIds'
import { useTripWebSocket } from './useTripWebSocket'
const baseTour: TourListItem = {
place_id: 42,
name: 'Ridge walk',
tour_type: 'hike',
distance: 4,
elevation_gain: 100,
elevation_loss: 80,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
max_hiking_difficulty: 2,
planned: false,
caution: false,
}
function emit(event: Record<string, unknown>): void {
for (const listener of [...mocks.listeners]) listener(event)
}
function renderTrip(tripId: number, enabled = true) {
return renderHook(() => {
const tours = useTourPlaceIds(tripId, enabled)
useTripWebSocket(tripId, tours.invalidateTourPlaceIds)
return tours
})
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
beforeEach(() => {
mocks.listeners.clear()
mocks.addListener.mockClear()
mocks.removeListener.mockClear()
mocks.joinTrip.mockClear()
mocks.leaveTrip.mockClear()
mocks.handleRemoteEvent.mockClear()
mocks.loadFiles.mockClear()
mocks.tourRepo.list.mockReset().mockResolvedValue({ tours: [baseTour] })
})
describe('Tours websocket invalidation', () => {
it('refreshes the active trip read model and Places exclusion after tours:changed', async () => {
const nextTour = { ...baseTour, place_id: 84, name: 'Trip 7 new tour' }
const refresh = deferred<{ tours: TourListItem[] }>()
mocks.tourRepo.list.mockResolvedValueOnce({ tours: [baseTour] }).mockReturnValueOnce(refresh.promise)
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
expect(result.current.tourPlaceIds).toEqual(new Set([42, 84]))
await act(async () => refresh.resolve({ tours: [nextTour] }))
await waitFor(() => expect(result.current.tourPlaceIds).toEqual(new Set([84])))
expect(result.current.tours).toEqual([nextTour])
expect(mocks.tourRepo.list).toHaveBeenCalledTimes(2)
})
it('ignores events for another trip and events without an explicit trip id', async () => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => {
emit({ type: 'tours:changed', tripId: 8, placeIds: [84] })
emit({ type: 'tours:changed', placeIds: [84] })
})
expect(mocks.tourRepo.list).toHaveBeenCalledOnce()
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
})
it.each(['place:deleted', 'assignment:created', 'assignment:deleted', 'day:deleted'])(
'refreshes the matching trip after %s changes Tours membership or planned status', async type => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type, tripId: 7, placeId: 42 }))
await waitFor(() => expect(mocks.tourRepo.list).toHaveBeenCalledTimes(2))
},
)
it.each(['place:deleted', 'assignment:created', 'assignment:deleted', 'day:deleted'])(
'refreshes the matching trip after %s changes Tours membership or planned status', async type => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type, tripId: 7 }))
await waitFor(() => expect(mocks.tourRepo.list).toHaveBeenCalledTimes(2))
},
)
it('lets the latest rapid invalidation refresh win without leaking prior results', async () => {
const first = deferred<{ tours: TourListItem[] }>()
const second = deferred<{ tours: TourListItem[] }>()
const newest = { ...baseTour, place_id: 99, name: 'Newest' }
mocks.tourRepo.list.mockResolvedValueOnce({ tours: [baseTour] })
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => {
emit({ type: 'tours:changed', tripId: 7, placeIds: [98] })
emit({ type: 'tours:changed', tripId: 7, placeIds: [99] })
})
expect(result.current.tourPlaceIds).toEqual(new Set([42, 98, 99]))
await act(async () => second.resolve({ tours: [newest] }))
await waitFor(() => expect(result.current.tours).toEqual([newest]))
await act(async () => first.resolve({ tours: [baseTour] }))
expect(result.current.tours).toEqual([newest])
expect(result.current.tourPlaceIds).toEqual(new Set([99]))
expect(mocks.tourRepo.list).toHaveBeenCalledTimes(3)
})
it('removes a pending Tour exclusion when its Place is deleted before refresh completes', async () => {
const staleRefresh = deferred<{ tours: TourListItem[] }>()
const deleteRefresh = deferred<{ tours: TourListItem[] }>()
mocks.tourRepo.list.mockResolvedValueOnce({ tours: [] })
.mockReturnValueOnce(staleRefresh.promise)
.mockReturnValueOnce(deleteRefresh.promise)
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
act(() => emit({ type: 'place:deleted', tripId: 7, placeId: 84 }))
expect(result.current.tourPlaceIds).toEqual(new Set())
await act(async () => deleteRefresh.resolve({ tours: [] }))
await act(async () => staleRefresh.resolve({ tours: [{ ...baseTour, place_id: 84 }] }))
expect(result.current.tourPlaceIds).toEqual(new Set())
expect(mocks.tourRepo.list).toHaveBeenCalledTimes(3)
})
it('keeps same-trip last-good Tours and exposes error on refresh failure', async () => {
const { result } = renderTrip(7)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
mocks.tourRepo.list.mockRejectedValueOnce(new Error('refresh unavailable'))
act(() => emit({ type: 'tours:changed', tripId: 7, placeIds: [84] }))
await waitFor(() => expect(result.current.tourLoadError).toBe(true))
expect(result.current.tours).toEqual([baseTour])
expect(result.current.tourPlaceIds).toEqual(new Set([42, 84]))
})
it('leaves ordinary Place event handling intact and Tours-off refresh is a no-op', async () => {
const { result } = renderTrip(7, false)
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
act(() => emit({ type: 'place:created', tripId: 7, place: { id: 93, name: 'Ordinary place' } }))
act(() => emit({ type: 'tours:changed', tripId: 7 }))
expect(mocks.handleRemoteEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'place:created', tripId: 7 }))
expect(mocks.tourRepo.list).not.toHaveBeenCalled()
expect(result.current.tours).toEqual([])
expect(result.current.tourPlaceIds.size).toBe(0)
})
})