mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { TourListItem } from '@trek/shared'
|
|
import { toursApi } from '../api/client'
|
|
import { useTourPlaceIds } from './useTourPlaceIds'
|
|
|
|
vi.mock('../api/client', () => ({
|
|
toursApi: { list: vi.fn() },
|
|
}))
|
|
|
|
const tour: 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,
|
|
tour_group_id: null,
|
|
max_hiking_difficulty: 2,
|
|
planned: false,
|
|
caution: false,
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
function tourFor(placeId: number, name: string): TourListItem {
|
|
return { ...tour, place_id: placeId, name }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(toursApi.list).mockResolvedValue({ tours: [tour] })
|
|
})
|
|
|
|
describe('useTourPlaceIds', () => {
|
|
it('loads one facet list for filtering and selected-tour lookup', async () => {
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
|
|
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
|
expect(result.current.tours).toEqual([tour])
|
|
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
|
expect(toursApi.list).toHaveBeenCalledWith(7)
|
|
})
|
|
|
|
it('hides facet identity while disabled without fetching or deleting its cached data', async () => {
|
|
const { result, rerender } = renderHook(({ enabled }) => useTourPlaceIds(7, enabled), { initialProps: { enabled: true } })
|
|
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
|
|
|
rerender({ enabled: false })
|
|
expect(result.current.tourDataReady).toBe(true)
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourPlaceIds.size).toBe(0)
|
|
expect(toursApi.list).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('inserts a fresh save immediately and replaces it after an edit', async () => {
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
|
const saved = { ...tour, place_id: 43, name: 'Fresh tour' }
|
|
|
|
act(() => result.current.upsertTour(saved))
|
|
expect(result.current.tours).toEqual([saved, tour])
|
|
|
|
const edited = { ...saved, name: 'Fresh tour edited' }
|
|
act(() => result.current.upsertTour(edited))
|
|
expect(result.current.tours).toEqual([edited, tour])
|
|
})
|
|
|
|
it('removes a deleted Tour from the visible list before the refresh completes', async () => {
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
|
const refresh = deferred<{ tours: TourListItem[] }>()
|
|
vi.mocked(toursApi.list).mockReturnValueOnce(refresh.promise)
|
|
|
|
act(() => result.current.invalidateTourPlaceIds({ removedPlaceIds: [tour.place_id] }))
|
|
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourPlaceIds.size).toBe(0)
|
|
await act(async () => refresh.resolve({ tours: [] }))
|
|
await waitFor(() => expect(result.current.toursLoading).toBe(false))
|
|
expect(result.current.tourLoadError).toBe(false)
|
|
})
|
|
|
|
it('shows only the new trip Tours after a successful trip switch', async () => {
|
|
const tripTwoTour = tourFor(84, 'Trip two tour')
|
|
vi.mocked(toursApi.list).mockImplementation(async tripId => ({
|
|
tours: [tripId === 7 ? tour : tripTwoTour],
|
|
}))
|
|
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
|
initialProps: { tripId: 7 },
|
|
})
|
|
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
|
|
|
rerender({ tripId: 8 })
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourPlaceIds.size).toBe(0)
|
|
expect(result.current.tourDataReady).toBe(false)
|
|
expect(result.current.toursLoading).toBe(true)
|
|
await waitFor(() => expect(result.current.tours).toEqual([tripTwoTour]))
|
|
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
|
|
})
|
|
|
|
it('keeps previous-trip data hidden when the new trip refresh fails', async () => {
|
|
vi.mocked(toursApi.list).mockImplementation(async tripId => {
|
|
if (tripId === 8) throw new Error('Trip two unavailable')
|
|
return { tours: [tour] }
|
|
})
|
|
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
|
initialProps: { tripId: 7 },
|
|
})
|
|
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
|
|
|
rerender({ tripId: 8 })
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourDataReady).toBe(false)
|
|
await waitFor(() => expect(result.current.tourLoadError).toBe(true))
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourPlaceIds.size).toBe(0)
|
|
expect(result.current.tourDataReady).toBe(false)
|
|
expect(result.current.toursLoading).toBe(false)
|
|
})
|
|
|
|
it('ignores a late response from the previous trip', async () => {
|
|
const tripOne = deferred<{ tours: TourListItem[] }>()
|
|
const tripTwoTour = tourFor(84, 'Trip two tour')
|
|
const tripTwo = deferred<{ tours: TourListItem[] }>()
|
|
vi.mocked(toursApi.list).mockImplementation(tripId => tripId === 7 ? tripOne.promise : tripTwo.promise)
|
|
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
|
initialProps: { tripId: 7 },
|
|
})
|
|
|
|
rerender({ tripId: 8 })
|
|
await act(async () => tripTwo.resolve({ tours: [tripTwoTour] }))
|
|
await waitFor(() => expect(result.current.tours).toEqual([tripTwoTour]))
|
|
await act(async () => tripOne.resolve({ tours: [tour] }))
|
|
|
|
expect(result.current.tours).toEqual([tripTwoTour])
|
|
expect(result.current.tourPlaceIds).toEqual(new Set([84]))
|
|
})
|
|
|
|
it('lets only the latest request commit after rapid trip changes', async () => {
|
|
const requests = new Map<string | number, ReturnType<typeof deferred<{ tours: TourListItem[] }>>>()
|
|
vi.mocked(toursApi.list).mockImplementation(tripId => {
|
|
const request = deferred<{ tours: TourListItem[] }>()
|
|
requests.set(tripId, request)
|
|
return request.promise
|
|
})
|
|
const { result, rerender } = renderHook(({ tripId }) => useTourPlaceIds(tripId, true), {
|
|
initialProps: { tripId: 1 },
|
|
})
|
|
|
|
rerender({ tripId: 2 })
|
|
rerender({ tripId: 3 })
|
|
const tripThreeTour = tourFor(93, 'Trip three tour')
|
|
await act(async () => requests.get(3)!.resolve({ tours: [tripThreeTour] }))
|
|
await waitFor(() => expect(result.current.tours).toEqual([tripThreeTour]))
|
|
await act(async () => requests.get(2)!.resolve({ tours: [tourFor(92, 'Trip two tour')] }))
|
|
await act(async () => requests.get(1)!.resolve({ tours: [tour] }))
|
|
|
|
expect(result.current.tours).toEqual([tripThreeTour])
|
|
expect(result.current.tourPlaceIds).toEqual(new Set([93]))
|
|
})
|
|
|
|
it('lets only the latest same-trip refresh commit after rapid assignment mutations', async () => {
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
await waitFor(() => expect(result.current.tours).toEqual([tour]))
|
|
const beforeMutation = deferred<{ tours: TourListItem[] }>()
|
|
const afterMutation = deferred<{ tours: TourListItem[] }>()
|
|
vi.mocked(toursApi.list).mockReset()
|
|
.mockReturnValueOnce(beforeMutation.promise)
|
|
.mockReturnValueOnce(afterMutation.promise)
|
|
|
|
let firstRefresh!: Promise<void>
|
|
let secondRefresh!: Promise<void>
|
|
act(() => {
|
|
firstRefresh = result.current.reloadTourPlaceIds()
|
|
secondRefresh = result.current.reloadTourPlaceIds()
|
|
})
|
|
const plannedTour = { ...tour, planned: true }
|
|
await act(async () => { afterMutation.resolve({ tours: [plannedTour] }); await secondRefresh })
|
|
await waitFor(() => expect(result.current.tours).toEqual([plannedTour]))
|
|
await act(async () => { beforeMutation.resolve({ tours: [tour] }); await firstRefresh })
|
|
|
|
expect(result.current.tours).toEqual([plannedTour])
|
|
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
|
expect(result.current.tourLoadError).toBe(false)
|
|
})
|
|
|
|
it('retains same-trip last-good data and exposes a failed refresh', async () => {
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
|
vi.mocked(toursApi.list).mockRejectedValueOnce(new Error('Refresh unavailable'))
|
|
|
|
await act(async () => { await result.current.reloadTourPlaceIds() })
|
|
|
|
expect(result.current.tours).toEqual([tour])
|
|
expect(result.current.tourPlaceIds).toEqual(new Set([42]))
|
|
expect(result.current.tourDataReady).toBe(true)
|
|
expect(result.current.tourLoadError).toBe(true)
|
|
expect(result.current.toursLoading).toBe(false)
|
|
})
|
|
|
|
it('does not let a pending request update state after unmount', async () => {
|
|
const request = deferred<{ tours: TourListItem[] }>()
|
|
vi.mocked(toursApi.list).mockReturnValue(request.promise)
|
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const { unmount } = renderHook(() => useTourPlaceIds(7, true))
|
|
unmount()
|
|
|
|
await act(async () => request.resolve({ tours: [tour] }))
|
|
|
|
expect(consoleError).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('distinguishes an empty successful list from a failed refresh', async () => {
|
|
vi.mocked(toursApi.list).mockResolvedValueOnce({ tours: [] })
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourLoadError).toBe(false)
|
|
|
|
vi.mocked(toursApi.list).mockRejectedValueOnce(new Error('Refresh unavailable'))
|
|
await act(async () => { await result.current.reloadTourPlaceIds() })
|
|
expect(result.current.tours).toEqual([])
|
|
expect(result.current.tourDataReady).toBe(true)
|
|
expect(result.current.tourLoadError).toBe(true)
|
|
})
|
|
|
|
it('does not let an older refresh overwrite a successful Tour upsert', async () => {
|
|
const { result } = renderHook(() => useTourPlaceIds(7, true))
|
|
await waitFor(() => expect(result.current.tourDataReady).toBe(true))
|
|
const refresh = deferred<{ tours: TourListItem[] }>()
|
|
vi.mocked(toursApi.list).mockReturnValueOnce(refresh.promise)
|
|
act(() => { void result.current.reloadTourPlaceIds() })
|
|
await waitFor(() => expect(result.current.toursLoading).toBe(true))
|
|
|
|
const savedTour = tourFor(43, 'Saved while refreshing')
|
|
act(() => result.current.upsertTour(savedTour))
|
|
await act(async () => refresh.resolve({ tours: [tour] }))
|
|
|
|
expect(result.current.tours).toEqual([savedTour, tour])
|
|
expect(result.current.tourLoadError).toBe(false)
|
|
})
|
|
})
|