2026-10-07 00:14:32 -04:00
|
|
|
|
import { act, cleanup, render, screen } from '@testing-library/react'
|
2026-10-05 15:22:37 -04:00
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
|
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
|
|
|
|
|
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
2026-10-03 18:40:34 -04:00
|
|
|
|
import { mockViewport } from './test/matchMedia.ts'
|
2026-10-06 12:45:05 -04:00
|
|
|
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
2026-10-03 18:40:34 -04:00
|
|
|
|
import App, { routes } from './App.tsx'
|
2026-10-06 12:45:05 -04:00
|
|
|
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
|
|
|
|
|
import { InDataRouter } from './test/dataRouter.tsx'
|
2026-10-06 21:28:23 -04:00
|
|
|
|
import { dinnerBody } from './test/dinners.ts'
|
2026-10-08 15:16:51 -04:00
|
|
|
|
import { defaultWeek } from './plan/week.ts'
|
2026-10-02 22:26:34 -04:00
|
|
|
|
|
2026-10-03 18:40:34 -04:00
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
Reflect.deleteProperty(window, 'matchMedia')
|
2026-10-05 15:22:37 -04:00
|
|
|
|
vi.restoreAllMocks()
|
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-05 15:22:37 -04:00
|
|
|
|
return { location, ...renderAppAt(path, location) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderAppAt(path: string, location = fakeLocation(path)) {
|
|
|
|
|
|
return render(
|
2026-10-06 12:45:05 -04:00
|
|
|
|
<InDataRouter path={path}>
|
2026-10-05 15:22:37 -04:00
|
|
|
|
<App api={createApiClient(location, createNetworkStatus(window), createVerifiedSession(localStorage))} serviceWorker={undefined} />
|
2026-10-06 12:45:05 -04:00
|
|
|
|
</InDataRouter>,
|
2026-10-03 18:40:34 -04:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 12:45:05 -04:00
|
|
|
|
// The routes that render a built screen rather than a placeholder page.
|
2026-10-06 21:28:23 -04:00
|
|
|
|
const SCREENS = {
|
|
|
|
|
|
'/onboarding': true,
|
2026-10-07 16:53:23 -04:00
|
|
|
|
'/plan': true,
|
2026-10-08 15:16:51 -04:00
|
|
|
|
'/plan/shopping': true,
|
|
|
|
|
|
'/plan/shopping/archive': true,
|
2026-10-07 05:53:22 -04:00
|
|
|
|
'/discover': true,
|
2026-10-09 10:38:36 -04:00
|
|
|
|
'/quick': true,
|
|
|
|
|
|
'/cook-from-pantry': true,
|
2026-10-09 14:01:14 -04:00
|
|
|
|
'/dinner/:id': true,
|
2026-10-06 21:28:23 -04:00
|
|
|
|
'/settings': true,
|
|
|
|
|
|
'/settings/ai': true,
|
|
|
|
|
|
'/settings/household': true,
|
|
|
|
|
|
'/settings/regulars': true,
|
2026-10-08 03:13:21 -04:00
|
|
|
|
'/settings/calendar': true,
|
2026-10-06 21:28:23 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SIGNED_IN answers what every route reads first: the session, then the
|
|
|
|
|
|
// household settings, onboarding finished.
|
|
|
|
|
|
const SIGNED_IN = {
|
|
|
|
|
|
'/api/v1/me': () => jsonResponse(200, ME),
|
|
|
|
|
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
|
|
|
|
|
}
|
2026-10-06 12:45:05 -04:00
|
|
|
|
|
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
|
|
|
|
|
2026-10-06 12:45:05 -04:00
|
|
|
|
it.each(routes.filter(({ path }) => !Object.hasOwn(SCREENS, path)).map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
2026-10-03 18:40:34 -04:00
|
|
|
|
'%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-06 21:28:23 -04:00
|
|
|
|
stubFetchByPath(SIGNED_IN)
|
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-06 12:45:05 -04:00
|
|
|
|
|
|
|
|
|
|
it('/settings renders the settings home inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({
|
|
|
|
|
|
'/api/v1/me': () => jsonResponse(200, ME),
|
|
|
|
|
|
...SETTINGS_API,
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/settings')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('/settings/ai renders the AI assistant screen inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({
|
2026-10-06 21:28:23 -04:00
|
|
|
|
...SIGNED_IN,
|
2026-10-06 12:45:05 -04:00
|
|
|
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
|
|
|
|
|
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
|
|
|
|
|
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/settings/ai')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-08 15:16:51 -04:00
|
|
|
|
it('/plan/shopping renders the Supply run inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
|
|
|
|
|
|
stubFetchByPath({
|
|
|
|
|
|
...SIGNED_IN,
|
|
|
|
|
|
[`/api/v1/shopping/${week}`]: () =>
|
|
|
|
|
|
jsonResponse(200, {
|
|
|
|
|
|
weekStart: week,
|
|
|
|
|
|
label: 'Week 41 · Oct 5 – 11',
|
|
|
|
|
|
title: null,
|
|
|
|
|
|
status: 'current',
|
|
|
|
|
|
sent_to_chat_at: null,
|
2026-10-09 18:04:05 -04:00
|
|
|
|
chat_send_unconfirmed_at: null,
|
2026-10-08 15:16:51 -04:00
|
|
|
|
needed_count: 1,
|
|
|
|
|
|
total_count: 1,
|
|
|
|
|
|
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
|
|
|
|
|
|
}),
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/plan/shopping')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({
|
|
|
|
|
|
...SIGNED_IN,
|
|
|
|
|
|
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/plan/shopping/archive')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-07 05:53:22 -04:00
|
|
|
|
it('/discover renders the Side quests deck inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({
|
|
|
|
|
|
...SIGNED_IN,
|
|
|
|
|
|
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null, failed_batch: null }),
|
|
|
|
|
|
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/discover')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByText('Quest log · 0')).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-06 12:45:05 -04:00
|
|
|
|
it('/settings/household renders the household settings screen inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
2026-10-06 21:28:23 -04:00
|
|
|
|
stubFetchByPath(SIGNED_IN)
|
2026-10-06 12:45:05 -04:00
|
|
|
|
renderAt('/settings/household')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
|
|
|
|
|
})
|
2026-10-06 21:28:23 -04:00
|
|
|
|
|
|
|
|
|
|
it('/settings/regulars renders the Regular dinners screen inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({
|
|
|
|
|
|
...SIGNED_IN,
|
|
|
|
|
|
'/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [dinnerBody('d1', 'Beef tacos')] }),
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/settings/regulars')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Regular dinners' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
describe('App first-run redirect', () => {
|
|
|
|
|
|
const FIRST_RUN = {
|
|
|
|
|
|
'/api/v1/me': () => jsonResponse(200, ME),
|
|
|
|
|
|
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it.each(routes.filter(({ path }) => path !== '/onboarding').map(({ path }) => path.replace(':id', 'abc123')))(
|
|
|
|
|
|
'%s redirects to /onboarding while onboarding is not finished',
|
|
|
|
|
|
async (path) => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath(FIRST_RUN)
|
|
|
|
|
|
renderAt(path)
|
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
|
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
it('stays on an onboarding step', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({ ...FIRST_RUN, '/api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: [] }) })
|
|
|
|
|
|
renderAt('/onboarding/regulars')
|
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('opens the routes once onboarding is finished', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath(SIGNED_IN)
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-06 21:28:23 -04:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
2026-10-06 21:28:23 -04:00
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
|
screen.getByRole('button', { name: 'Retry' }).click()
|
2026-10-09 10:38:36 -04:00
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-06 21:28:23 -04:00
|
|
|
|
})
|
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)
|
2026-10-06 21:28:23 -04:00
|
|
|
|
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
2026-10-04 08:46:40 -04:00
|
|
|
|
|
|
|
|
|
|
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
|
|
2026-10-09 10:38:36 -04:00
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-04 08:46:40 -04:00
|
|
|
|
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'))
|
2026-10-05 15:22:37 -04:00
|
|
|
|
const { location } = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
2026-10-04 08:46:40 -04:00
|
|
|
|
|
|
|
|
|
|
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'))
|
2026-10-05 15:22:37 -04:00
|
|
|
|
const { location } = renderAt('/')
|
2026-10-04 08:46:40 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-06 21:28:23 -04:00
|
|
|
|
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
2026-10-04 08:46:40 -04:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-10-05 15:22:37 -04:00
|
|
|
|
|
|
|
|
|
|
describe('App offline bootstrap', () => {
|
|
|
|
|
|
let onLine: boolean
|
|
|
|
|
|
|
|
|
|
|
|
function goOffline() {
|
|
|
|
|
|
onLine = false
|
|
|
|
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// visitOnline is an earlier online visit on this device: /me answers with a
|
|
|
|
|
|
// member, then the app is closed.
|
|
|
|
|
|
async function visitOnline() {
|
2026-10-06 21:28:23 -04:00
|
|
|
|
stubFetchByPath(SIGNED_IN)
|
2026-10-05 15:22:37 -04:00
|
|
|
|
const { unmount } = renderAt('/')
|
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
|
|
|
|
|
unmount()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
onLine = true
|
|
|
|
|
|
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-07 00:14:32 -04:00
|
|
|
|
// A test that emits an offline event leaves TanStack Query's online
|
|
|
|
|
|
// manager, shared by every test, offline until an online event.
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
|
cleanup()
|
|
|
|
|
|
window.dispatchEvent(new Event('online'))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-05 15:22:37 -04:00
|
|
|
|
it.each([
|
2026-10-09 14:01:14 -04:00
|
|
|
|
['/', 'Home'],
|
2026-10-09 10:38:36 -04:00
|
|
|
|
['/settings/notifications', 'Notifications'],
|
2026-10-05 15:22:37 -04:00
|
|
|
|
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
|
|
|
|
|
await visitOnline()
|
|
|
|
|
|
goOffline()
|
|
|
|
|
|
renderAt(path)
|
|
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: title })).toBeInTheDocument()
|
|
|
|
|
|
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
|
|
|
|
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
|
|
|
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-07 00:14:32 -04:00
|
|
|
|
it('opens the saved page when the browser has emitted an offline event', async () => {
|
|
|
|
|
|
await visitOnline()
|
|
|
|
|
|
// /me is still loading, with the app mounted, when the browser emits
|
|
|
|
|
|
// its offline event; only then does /me fail.
|
|
|
|
|
|
let failMe!: () => void
|
|
|
|
|
|
vi.stubGlobal(
|
|
|
|
|
|
'fetch',
|
|
|
|
|
|
vi.fn<typeof fetch>().mockImplementationOnce(
|
|
|
|
|
|
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
|
|
|
|
|
).mockRejectedValue(new TypeError('Failed to fetch')),
|
|
|
|
|
|
)
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
2026-10-07 00:14:32 -04:00
|
|
|
|
await vi.waitFor(() => expect(failMe).toBeDefined())
|
|
|
|
|
|
onLine = false
|
|
|
|
|
|
act(() => {
|
|
|
|
|
|
window.dispatchEvent(new Event('offline'))
|
|
|
|
|
|
})
|
|
|
|
|
|
act(() => failMe())
|
|
|
|
|
|
|
2026-10-09 10:38:36 -04:00
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-07 00:14:32 -04:00
|
|
|
|
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
|
|
|
|
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
|
|
|
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-05 15:22:37 -04:00
|
|
|
|
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
|
|
|
|
|
await visitOnline()
|
|
|
|
|
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
2026-10-05 15:22:37 -04:00
|
|
|
|
|
2026-10-09 10:38:36 -04:00
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-05 15:22:37 -04:00
|
|
|
|
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('reports the failure offline when this device never verified a session', async () => {
|
|
|
|
|
|
goOffline()
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
2026-10-05 15:22:37 -04:00
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('reports the failure offline after the session was refused', async () => {
|
|
|
|
|
|
await visitOnline()
|
|
|
|
|
|
stubFetch(errorResponse(401, 'unauthenticated'))
|
2026-10-09 10:38:36 -04:00
|
|
|
|
const { location, unmount } = renderAt('/settings/notifications')
|
2026-10-05 15:22:37 -04:00
|
|
|
|
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
|
|
|
|
|
unmount()
|
|
|
|
|
|
|
|
|
|
|
|
goOffline()
|
2026-10-09 10:38:36 -04:00
|
|
|
|
renderAt('/settings/notifications')
|
2026-10-05 15:22:37 -04:00
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
|
|
|
|
|
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
|
|
|
|
|
await visitOnline()
|
|
|
|
|
|
goOffline()
|
2026-10-09 10:38:36 -04:00
|
|
|
|
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
2026-10-05 15:22:37 -04:00
|
|
|
|
render(
|
2026-10-09 10:38:36 -04:00
|
|
|
|
<InDataRouter path="/settings/notifications">
|
2026-10-05 15:22:37 -04:00
|
|
|
|
<App api={api} serviceWorker={undefined} />
|
2026-10-06 12:45:05 -04:00
|
|
|
|
</InDataRouter>,
|
2026-10-05 15:22:37 -04:00
|
|
|
|
)
|
2026-10-09 10:38:36 -04:00
|
|
|
|
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-05 15:22:37 -04:00
|
|
|
|
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
|
const fetchMock = stubFetchByPath(SIGNED_IN)
|
2026-10-05 15:22:37 -04:00
|
|
|
|
onLine = true
|
|
|
|
|
|
act(() => {
|
|
|
|
|
|
window.dispatchEvent(new Event('online'))
|
|
|
|
|
|
})
|
2026-10-06 21:28:23 -04:00
|
|
|
|
await vi.waitFor(() =>
|
|
|
|
|
|
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
|
|
|
|
|
'/api/v1/me',
|
|
|
|
|
|
'/api/v1/settings/household',
|
|
|
|
|
|
]),
|
|
|
|
|
|
)
|
2026-10-05 15:22:37 -04:00
|
|
|
|
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
2026-10-09 10:38:36 -04:00
|
|
|
|
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
2026-10-05 15:22:37 -04:00
|
|
|
|
})
|
2026-10-08 03:13:21 -04:00
|
|
|
|
|
|
|
|
|
|
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
|
|
|
|
|
mockViewport(390)
|
|
|
|
|
|
stubFetchByPath({
|
|
|
|
|
|
...SIGNED_IN,
|
|
|
|
|
|
'/api/v1/integrations/google': () =>
|
|
|
|
|
|
jsonResponse(200, { status: 'not_connected', calendar_id: null, calendar_name: null, connected_at: null, last_sync_error: null }),
|
|
|
|
|
|
})
|
|
|
|
|
|
renderAt('/settings/calendar')
|
|
|
|
|
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Google Calendar' })
|
|
|
|
|
|
expect(screen.getByRole('main')).toContainElement(heading)
|
|
|
|
|
|
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toHaveAttribute(
|
|
|
|
|
|
'href',
|
|
|
|
|
|
'/api/v1/integrations/google/connect',
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
2026-10-05 15:22:37 -04:00
|
|
|
|
})
|