Files
MealQuest/web/src/onboarding/OnboardingPage.test.tsx
T
orchestrator-agent cc7875c76c
CI / ci (pull_request) Successful in 2m23s
CI / ci (push) Successful in 2m27s
Integration fix: Feature 12 feature-review findings F1-F8 (#95)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-06 19:54:47 -04:00

420 lines
20 KiB
TypeScript

import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { createApiClient, createVerifiedSession } from '../api/index.ts'
import App from '../App.tsx'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea, hitArea } from '../test/hitArea.ts'
import styles from './Onboarding.module.css'
afterEach(() => {
vi.unstubAllGlobals()
})
type Dinner = DinnerBody
// A request the fake server holds until the test answers it.
type Held = { method: string; path: string; body: unknown; answer: (response: Response) => void }
// stubDinnersServer stands in for the server a household meets on first
// run: signed in, onboarding not finished and the given Regulars. GET answers at once;
// each POST and DELETE is held in `held` until the test answers it, so the
// test sees the optimistic list first. Once failReads is set, every GET of
// the Regulars fails, so the test sees the list the client keeps by itself.
function stubDinnersServer(regulars: Dinner[] = []) {
const held: Held[] = []
const reads = { fail: false }
const fetchMock: Mock<typeof fetch> = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
const method = init?.method ?? 'GET'
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
if (method === 'GET' && path === '/api/v1/settings/household') {
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
}
if (method === 'GET' && path === '/api/v1/dinners?status=regular') {
if (reads.fail) return Promise.resolve(errorResponse(500, 'internal'))
return Promise.resolve(jsonResponse(200, { dinners: regulars }))
}
if ((method === 'POST' && path === '/api/v1/dinners') || (method === 'DELETE' && path.startsWith('/api/v1/dinners/'))) {
return new Promise<Response>((resolve) => {
held.push({ method, path, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), answer: resolve })
})
}
throw new Error(`unexpected fetch: ${method} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, held, regulars, reads }
}
// created answers a held POST /api/v1/dinners as the server does, adding the
// Regular to the list later GETs read.
function created(server: ReturnType<typeof stubDinnersServer>, request: Held, id: string) {
const { name } = request.body as { name: string }
server.regulars.push(dinnerBody(id, name))
request.answer(jsonResponse(201, { ...dinnerBody(id, name, { grounding_status: 'pending' }), grounding_job_id: 'job-1' }))
}
// renderAt renders the whole app, so /me supplies the CSRF token every
// write needs and OnboardingGate reads the household settings.
function renderAt(path: string) {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path={path}>
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
}
async function regularsStep(regulars: Dinner[] = []) {
const server = stubDinnersServer(regulars)
renderAt('/onboarding/regulars')
// Common picks show once the Regulars have loaded.
await screen.findByRole('heading', { level: 2, name: 'Common picks' })
expect(screen.getByRole('heading', { level: 2, name: `Your regulars · ${regulars.length}` })).toBeInTheDocument()
return server
}
function input() {
return screen.getByRole('textbox', { name: 'Dinner name' })
}
function regularsList() {
return screen.getByRole('heading', { level: 2, name: /^Your regulars/ }).nextElementSibling as HTMLElement
}
function regularNames() {
return within(regularsList())
.queryAllByRole('listitem')
.map((item) => item.textContent)
}
function next() {
return screen.getByRole('button', { name: 'Next: notifications' })
}
describe('onboarding step 1', () => {
it('welcomes the household, explains the words and starts with step 2', async () => {
stubDinnersServer()
renderAt('/onboarding')
expect(await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.getByText('Step 1 of 3')).toBeInTheDocument()
const terms = within(screen.getByRole('list')).getAllByRole('listitem')
expect(terms.map((term) => term.textContent)).toEqual([
expect.stringMatching(/^Regulars/),
expect.stringMatching(/^Side quests/),
expect.stringMatching(/^Quest log/),
])
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: "Let's start" }))
expect(await screen.findByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
})
it('fills one progress pill of three', async () => {
stubDinnersServer()
renderAt('/onboarding')
await screen.findByRole('heading', { level: 1, name: 'Welcome to MealQuest' })
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, false, false])
})
})
describe('onboarding step 2 (mock 1h)', () => {
it('shows the step as the mock does', async () => {
await regularsStep([dinnerBody('d1', 'Beef tacos')])
expect(screen.getByText('Step 2 of 3')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: "What's on your usual rotation?" })).toBeInTheDocument()
expect(
screen.getByText('Add the dinners your household makes on repeat. MealQuest builds suggestions from these.'),
).toBeInTheDocument()
expect(input()).toHaveAttribute('placeholder', 'e.g. Chicken enchiladas')
expect(regularNames()).toEqual(['Beef tacos'])
expect(screen.getByRole('button', { name: 'Remove Beef tacos' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: 'Common picks' })).toBeInTheDocument()
// A common pick the household already has is not offered again.
expect(screen.queryByRole('button', { name: 'Add Beef tacos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Chili' })).toHaveTextContent('+Chili')
const pills = Array.from(document.getElementsByClassName(styles.pill!))
expect(pills.map((pill) => pill.classList.contains(styles.filled!))).toEqual([true, true, false])
expect(declaredStyle(pills[0]!).height).toBe('6px')
})
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
})
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
await regularsStep()
expect(hitArea(input()).height).toBe(48)
const add = screen.getByRole('button', { name: 'Add Regular' })
expectMinHitArea(add)
expect(declaredStyle(add.firstElementChild!).width).toBe('36px')
expect(declaredStyle(add.firstElementChild!).height).toBe('36px')
expectMinHitArea(screen.getByRole('button', { name: 'Add Chili' }))
expect(declaredStyle(next()).height).toBe('52px')
expect(declaredStyle(next().parentElement!).position).toBe('fixed')
})
it('adds a Regular on Enter, showing it before the server answers', async () => {
const server = await regularsStep()
fireEvent.change(input(), { target: { value: ' Chicken enchiladas ' } })
// Enter in the input submits its form.
fireEvent.submit(input())
expect(input()).toHaveValue('')
await vi.waitFor(() => expect(regularNames()).toEqual(['Chicken enchiladas']))
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
// Not removable until the server has given it an id.
expect(screen.queryByRole('button', { name: 'Remove Chicken enchiladas' })).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chicken enchiladas' } })
created(server, server.held[0]!, 'd1')
expect(await screen.findByRole('button', { name: 'Remove Chicken enchiladas' })).toBeInTheDocument()
})
it('adds a Regular with the "+" button', async () => {
const server = await regularsStep()
const add = screen.getByRole('button', { name: 'Add Regular' })
expect(add).toBeDisabled()
fireEvent.change(input(), { target: { value: 'Shrimp scampi' } })
fireEvent.click(add)
await vi.waitFor(() => expect(regularNames()).toEqual(['Shrimp scampi']))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', body: { name: 'Shrimp scampi' } })
})
it('adds a common pick on tap and stops offering it', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili']))
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'POST', path: '/api/v1/dinners', body: { name: 'Chili' } })
})
it('takes an add back out and says why when the server refuses it', async () => {
const server = await regularsStep([dinnerBody('d1', 'Chili')])
fireEvent.change(input(), { target: { value: 'chili' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(regularNames()).toEqual(['Chili', 'chili']))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add chili: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why an add was refused while another add is still unanswered', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Burgers', 'Chili'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
// The other add's answer does not hide the refusal.
created(server, server.held[1]!, 'd1')
await screen.findByRole('button', { name: 'Remove Chili' })
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why each of two overlapping adds was refused', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Burgers: duplicate_dinner message',
'Could not add Chili: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('says why each of two overlapping adds of the same name was refused', async () => {
const server = await regularsStep()
fireEvent.change(input(), { target: { value: 'Lasagne' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.change(input(), { target: { value: 'Lasagne' } })
fireEvent.submit(input())
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Lasagne', 'Lasagne'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Lasagne: duplicate_dinner message')
expect(regularNames()).toEqual(['Lasagne'])
// The second add's refusal shows beside the first instead of replacing it.
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Lasagne: duplicate_dinner message',
'Could not add Lasagne: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('removes a Regular at once and archives it on the server', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(regularNames()).toEqual(['Pizza night']))
expect(screen.getByRole('heading', { level: 2, name: 'Your regulars · 1' })).toBeInTheDocument()
// Removed, it is a common pick again.
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/dinners/d1' })
})
it('puts a removed Regular back in its place when the server refuses', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
})
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
// The other removal's answer does not hide the refusal.
server.regulars.splice(1, 1)
server.held[1]!.answer(new Response(null, { status: 204 }))
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
})
it('puts two refused removals back in their places without rereading the list', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
server.reads.fail = true
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
await screen.findByRole('alert')
server.held[1]!.answer(errorResponse(500, 'internal'))
await screen.findByText('Could not remove Pizza night: internal message')
// The reread after the last answer fails, so the order is the client's own.
expect(await screen.findByText(/Your Regulars could not load/)).toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /^Remove / }).map((button) => button.getAttribute('aria-label'))).toEqual([
'Remove Beef tacos',
'Remove Pizza night',
'Remove Chili',
])
})
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
expect(next()).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
// The third chip shows at once, but Next waits for the server.
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
created(server, server.held[0]!, 'd3')
await screen.findByRole('button', { name: 'Remove Burgers' })
expect(next()).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(next()).toBeDisabled())
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() => expect(next()).toBeEnabled())
fireEvent.click(next())
expect(await screen.findByText('Step 3 of 3')).toBeInTheDocument()
})
it('keeps "Next: notifications" disabled when the third add is refused', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
])
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 3' })
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
expect(next()).toBeDisabled()
})
it('keeps "Next: notifications" disabled while a fourth add is unanswered', async () => {
const server = await regularsStep([
dinnerBody('d1', 'Beef tacos'),
dinnerBody('d2', 'Pizza night'),
dinnerBody('d3', 'Chili'),
])
expect(next()).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await screen.findByRole('heading', { level: 2, name: 'Your regulars · 4' })
expect(next()).toBeDisabled()
await vi.waitFor(() => expect(server.held).toHaveLength(1))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
await screen.findByRole('alert')
expect(next()).toBeEnabled()
})
})