2026-10-02 22:26:34 -04:00
|
|
|
import { render, screen } from '@testing-library/react'
|
|
|
|
|
import { MemoryRouter } from 'react-router'
|
2026-10-04 08:46:40 -04:00
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { createApiClient } from './api/index.ts'
|
2026-10-05 08:13:50 -04:00
|
|
|
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
2026-10-03 18:40:34 -04:00
|
|
|
import { mockViewport } from './test/matchMedia.ts'
|
2026-10-04 08:46:40 -04:00
|
|
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
2026-10-03 18:40:34 -04:00
|
|
|
import App, { routes } from './App.tsx'
|
2026-10-02 22:26:34 -04:00
|
|
|
|
2026-10-03 18:40:34 -04:00
|
|
|
afterEach(() => {
|
|
|
|
|
Reflect.deleteProperty(window, 'matchMedia')
|
2026-10-04 08:46:40 -04:00
|
|
|
vi.unstubAllGlobals()
|
2026-10-03 18:40:34 -04:00
|
|
|
})
|
|
|
|
|
|
2026-10-04 08:46:40 -04:00
|
|
|
function renderAt(path: string, location = fakeLocation(path)) {
|
2026-10-03 18:40:34 -04:00
|
|
|
render(
|
|
|
|
|
<MemoryRouter initialEntries={[path]}>
|
2026-10-05 08:13:50 -04:00
|
|
|
<App api={createApiClient(location, createNetworkStatus(window))} serviceWorker={undefined} />
|
2026-10-03 18:40:34 -04:00
|
|
|
</MemoryRouter>,
|
|
|
|
|
)
|
2026-10-04 08:46:40 -04:00
|
|
|
return location
|
2026-10-03 18:40:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('App routes', () => {
|
|
|
|
|
it('registers every handoff route plus the extra settings pages', () => {
|
|
|
|
|
expect(routes.map((route) => route.path)).toEqual([
|
|
|
|
|
'/',
|
|
|
|
|
'/onboarding',
|
|
|
|
|
'/plan',
|
|
|
|
|
'/plan/shopping',
|
|
|
|
|
'/plan/shopping/archive',
|
|
|
|
|
'/discover',
|
|
|
|
|
'/quick',
|
|
|
|
|
'/cook-from-pantry',
|
|
|
|
|
'/dinner/:id',
|
|
|
|
|
'/settings',
|
|
|
|
|
'/settings/notifications',
|
|
|
|
|
'/settings/ai',
|
|
|
|
|
'/settings/household',
|
|
|
|
|
'/settings/regulars',
|
|
|
|
|
'/settings/calendar',
|
|
|
|
|
])
|
2026-10-02 22:26:34 -04:00
|
|
|
})
|
2026-10-03 18:40:34 -04:00
|
|
|
|
|
|
|
|
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
|
|
|
|
'%s renders its placeholder page inside the shell',
|
2026-10-04 08:46:40 -04:00
|
|
|
async (path, title) => {
|
2026-10-03 18:40:34 -04:00
|
|
|
mockViewport(390)
|
2026-10-04 08:46:40 -04:00
|
|
|
stubFetch(jsonResponse(200, ME))
|
2026-10-03 18:40:34 -04:00
|
|
|
renderAt(path)
|
2026-10-04 08:46:40 -04:00
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
2026-10-03 18:40:34 -04:00
|
|
|
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-10-02 22:26:34 -04:00
|
|
|
})
|
2026-10-04 08:46:40 -04:00
|
|
|
|
|
|
|
|
describe('App bootstrap', () => {
|
|
|
|
|
it('shows the skeleton until /me loads, then the routes', async () => {
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
const fetchMock = stubFetch(jsonResponse(200, ME))
|
|
|
|
|
renderAt('/plan')
|
|
|
|
|
|
|
|
|
|
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
|
|
|
|
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
|
|
|
|
|
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
|
|
|
|
|
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows the not-in-this-household screen on a 403', async () => {
|
|
|
|
|
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
|
|
|
|
const location = renderAt('/')
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(location.assign).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
|
|
|
|
renderAt('/')
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
|
|
|
|
screen.getByRole('button', { name: 'Retry' }).click()
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|