Files
MealQuest/web/src/home/RotationCheck.test.tsx
T
dev_agent fd087e0ba3
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s
Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 20:07:53 -04:00

321 lines
15 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
import { QueryClientProvider } from '@tanstack/react-query'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOME } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts'
// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET
// /api/v1/rotation-reviews sends them.
const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' }
const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' }
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'serviceWorker')
})
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
// stubApi answers Home's reads like the server: the pending Rotation checks
// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in
// decisions until the test answers it; a 200 answer takes the check off the
// server's list, as the server does.
function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
const server = [...reviews]
const decisions: Deferred[] = []
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const path = String(input)
if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME))
if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME))
if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server }))
const review = server.find((r) => rotationReviewPath(r.dinner.id) === path)
if (init?.method === 'POST' && review !== undefined) {
expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
const body: unknown = JSON.parse(String(init.body))
return new Promise<Response>((resolve) =>
decisions.push({
path,
body,
resolve: (response) => {
if (response.ok) server.splice(server.indexOf(review), 1)
resolve(response)
},
}),
)
}
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, decisions, server }
}
// decided is the server's answer to a decision on review.
function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) {
return jsonResponse(200, {
dinner: review.dinner,
plan_date: review.plan_date,
decision,
dinner_status: dinnerStatus,
decided_at: '2026-10-02T13:00:00Z',
updated_by: 'sub-1',
})
}
// renderHome loads /me (for the CSRF token an answer needs) and renders Home
// at path.
async function renderHome(path = '/') {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter([{ path: '/', element: <HomePage /> }], { initialEntries: [path] })
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
}
const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' })
const posts = (fetchMock: ReturnType<typeof stubApi>['fetchMock']) =>
fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')
describe('RotationCheck', () => {
it('shows no card when nothing waits for an answer', async () => {
const { fetchMock } = stubApi([])
await renderHome()
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything()))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
})
it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => {
const { fetchMock } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument()
expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument()
const keep = within(check).getByRole('button', { name: 'Keep it' })
const drop = within(check).getByRole('button', { name: 'Not for us' })
const notMade = within(check).getByRole('button', { name: "We didn't make it" })
expect(keep.className).toMatch(/primary/)
expect(drop.className).toMatch(/outline/)
expect(notMade.className).toMatch(/notMade/)
for (const button of [keep, drop, notMade]) expectMinHitArea(button)
const tiles = screen.getByRole('link', { name: 'Quick dinner' })
expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
// The card waits for the household: nothing is sent unasked.
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
expect(posts(fetchMock)).toHaveLength(0)
expect(await card()).toBeInTheDocument()
})
it.each([
['Keep it', 'keep', 'regular', 'Added to your Regulars'],
['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"],
["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'],
])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => {
const { fetchMock, decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: label }))
// Applied before the server answers.
expect(await screen.findByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } })
expect(posts(fetchMock)).toHaveLength(1)
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status)))
// Rotation checks are read again once saved; none is left.
await waitFor(() =>
expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2),
)
expect(screen.getByRole('status')).toHaveTextContent(confirmation)
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('reveals the next pending check at once, its answers locked until the first is saved', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
const next = await card()
expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo')
expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars')
for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular')))
await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled())
fireEvent.click(within(next).getByRole('button', { name: 'Not for us' }))
expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again")
await waitFor(() => expect(decisions).toHaveLength(2))
expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } })
act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived')))
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument()
})
it('puts the check back with an ErrorBanner when the answer fails', async () => {
const { decisions } = stubApi([SHAKSHUKA, RAMEN])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument()
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(errorResponse(500, 'internal')))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message")
const check = await card()
expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('reports an answer that is not that check decided that way, and shows what the server has', async () => {
const { decisions } = stubApi([SHAKSHUKA])
await renderHome()
fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' }))
await waitFor(() => expect(decisions).toHaveLength(1))
act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived')))
expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
// The server closed the check, so the list read again has none.
await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument())
})
it('reports Rotation checks it cannot load, with Retry', async () => {
const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives; a notification tap there reads the checks again.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')]
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) =>
Promise.resolve(
String(input) === ME_PATH
? jsonResponse(200, ME)
: String(input) === HOME_PATH
? jsonResponse(200, HOME)
: answers.shift()!,
),
),
)
await renderHome()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message")
const shown = screen.getByRole('region', { name: 'Keep it in rotation?' })
expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled()
})
it('opens Home with the card from a rotation_review push notification', async () => {
stubApi([SHAKSHUKA])
const origin = 'https://mealquest.test'
const { notice } = parsePushPayload(
JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }),
origin,
)
const path = clickPath({ url: notice.url }, origin)
expect(path).toBe('/')
await renderHome(path)
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const { fetchMock, server } = stubApi([])
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
await renderHome('/')
const home = await screen.findByRole('heading', { name: 'Pizza night' })
await waitFor(() => expect(reads()).toBe(1))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
// Within the cached list's staleTime the Rotation check comes due and
// its notification is tapped while Home is open at /.
server.push(SHAKSHUKA)
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(win.navigate).not.toHaveBeenCalled()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(reads()).toBe(2)
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
})
})