import { QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createMemoryRouter, RouterProvider, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } 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 { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts' import { ROTATION_REVIEWS_PATH } from './rotation.ts' import { HomePage } from './HomePage.tsx' afterEach(() => { vi.unstubAllGlobals() }) // minutesAgo is the stored form of the instant n minutes before now. function minutesAgo(n: number): string { return new Date(Date.now() - n * 60_000).toISOString() } // UPDATES are a GET /api/v1/updates body, newest first: the week drafted // five minutes ago, new Side quests three hours ago and the Supply run two // days ago. const UPDATES = { updates: [ { type: 'plan_ready', title: 'Your week is drafted', body: 'Take a look at the Draft board.', url: '/plan', created_at: minutesAgo(5), }, { type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) }, { type: 'list_ready', title: 'Supply run ready', body: 'Week 41 · Oct 5 – 11', url: '/plan/shopping?week=2026-10-05', created_at: minutesAgo(2 * 24 * 60), }, ], } type Responder = (init: RequestInit | undefined) => Response | Promise // stubServer answers "METHOD path" requests from responders, with /me; any // other request fails the test. Home is unread until the updates are marked // seen, as the server answers. function stubServer(responders: Record = {}) { let seen = false const all: Record = { 'GET /api/v1/me': () => jsonResponse(200, ME), [`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }), [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES), [`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }), [`POST ${UPDATES_SEEN_PATH}`]: () => { seen = true return new Response(null, { status: 204 }) }, ...responders, } const fetchMock = vi.fn((input, init) => { const key = `${init?.method ?? 'GET'} ${String(input)}` const respond = Object.hasOwn(all, key) ? all[key] : undefined if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) return Promise.resolve(respond(init)) }) vi.stubGlobal('fetch', fetchMock) return fetchMock } // posts lists the bodies of the POST /updates/seen requests sent, each of // which must carry the CSRF token. function posts(fetchMock: Mock) { return fetchMock.mock.calls .filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH) .map(([, init]) => { expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) return String(init?.body) }) } function homeReads(fetchMock: Mock) { return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET') .length } function updatesReads(fetchMock: Mock) { return fetchMock.mock.calls.filter( ([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET', ).length } // Where shows the route a row went to. function Where() { const location = useLocation() return

{location.pathname + location.search}

} // renderHome loads /me (for the CSRF token the POST needs) and renders Home, // answering its query client. async function renderHome() { const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() const router = createMemoryRouter( [ { path: '/', element: }, { path: '*', element: }, ], { initialEntries: ['/'] }, ) const queryClient = createQueryClient() render( , ) return queryClient } // openSheet taps the unread bell and answers the open sheet. async function openSheet() { fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' })) return screen.getByRole('dialog', { name: 'Recent updates' }) } async function rows(sheet: HTMLElement) { return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem') } describe('Recent updates sheet', () => { it('lists the updates newest first with how long ago each came', async () => { stubServer() await renderHome() const listed = await rows(await openSheet()) expect(listed).toHaveLength(3) expect(listed[0]).toHaveTextContent('Your week is drafted') expect(listed[0]).toHaveTextContent('Take a look at the Draft board.') expect(listed[0]).toHaveTextContent('5 min ago') expect(listed[1]).toHaveTextContent('5 new Side quests') expect(listed[1]).toHaveTextContent('3 hr ago') expect(listed[2]).toHaveTextContent('Supply run ready') expect(listed[2]).toHaveTextContent('2 days ago') expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at) }) it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => { const fetchMock = stubServer() await renderHome() expect(await screen.findByTestId('unread-dot')).toBeInTheDocument() await openSheet() await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })])) expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() // Home is read again once the mark is saved, and the server says // nothing is unread: the dot does not come back. await waitFor(() => expect(homeReads(fetchMock)).toBe(2)) fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument() expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() }) it('reads the updates afresh on each opening, so one stored since the last opening is listed and marked seen', async () => { // The server holds the stored events, newest first, and says Home is // unread while the newest is after the time marked seen. const stored = [...UPDATES.updates] let seenThrough = '' const fetchMock = stubServer({ [`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: stored[0]!.created_at > seenThrough } }), [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: stored }), [`POST ${UPDATES_SEEN_PATH}`]: (init) => { seenThrough = (JSON.parse(String(init?.body)) as { through: string }).through return new Response(null, { status: 204 }) }, }) const queryClient = await renderHome() await rows(await openSheet()) await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) await waitFor(() => expect(homeReads(fetchMock)).toBe(2)) fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) // A new event is stored a moment later, well within the query client's // staleTime, and Home is read again, as on coming back to it. const reminder = { type: 'day_of_reminder', title: 'Tacos tonight', body: 'Dinner at 6:00.', url: '/plan', created_at: new Date().toISOString(), } stored.unshift(reminder) await queryClient.invalidateQueries({ queryKey: HOME_KEY }) expect(await screen.findByTestId('unread-dot')).toBeInTheDocument() const listed = await rows(await openSheet()) expect(listed).toHaveLength(4) expect(listed[0]).toHaveTextContent('Tacos tonight') await waitFor(() => expect(posts(fetchMock)).toEqual([ JSON.stringify({ through: UPDATES.updates[0]!.created_at }), JSON.stringify({ through: reminder.created_at }), ]), ) await waitFor(() => expect(homeReads(fetchMock)).toBe(4)) expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() }) it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => { const stored = [...UPDATES.updates] let reads = 0 let answer: (() => void) | undefined const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => { const body = { updates: [...stored] } reads++ // Every reading after the first is held until the test answers it. if (reads === 1) return jsonResponse(200, body) return new Promise((resolve) => (answer = () => resolve(jsonResponse(200, body)))) }, }) await renderHome() await rows(await openSheet()) await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) // Closed and opened again at once, before any cache-collection timer // could run, with a new event stored in between. fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) const reminder = { type: 'day_of_reminder', title: 'Tacos tonight', body: 'Dinner at 6:00.', url: '/plan', created_at: new Date().toISOString(), } stored.unshift(reminder) fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) // The list held from the first opening is neither shown nor marked // seen while this opening's read is in flight. await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument() expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() expect(posts(fetchMock)).toHaveLength(1) answer!() const listed = await rows(sheet) expect(listed).toHaveLength(4) expect(listed[0]).toHaveTextContent('Tacos tonight') await waitFor(() => expect(posts(fetchMock)).toEqual([ JSON.stringify({ through: UPDATES.updates[0]!.created_at }), JSON.stringify({ through: reminder.created_at }), ]), ) }) it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => { const stored = [...UPDATES.updates] let firstAborted = false let answerFirst: (() => void) | undefined let reads = 0 const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: (init) => { const body = { updates: [...stored] } reads++ if (reads > 1) return jsonResponse(200, body) // The first reading is held open, and fails as fetch does if it is // aborted. return new Promise((resolve, reject) => { answerFirst = () => resolve(jsonResponse(200, body)) init?.signal?.addEventListener('abort', () => { firstAborted = true reject(init.signal!.reason as Error) }) }) }, }) await renderHome() await openSheet() await waitFor(() => expect(updatesReads(fetchMock)).toBe(1)) // Closed before the first read answers, a new event stored, and opened // again at once. fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) const reminder = { type: 'day_of_reminder', title: 'Tacos tonight', body: 'Dinner at 6:00.', url: '/plan', created_at: new Date().toISOString(), } stored.unshift(reminder) fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) // The first read was cancelled, and the reopen sent its own. await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) expect(firstAborted).toBe(true) // Answering the cancelled read changes nothing. answerFirst!() const listed = await rows(sheet) expect(listed).toHaveLength(4) expect(listed[0]).toHaveTextContent('Tacos tonight') await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })])) }) it.each([ [0, '/plan'], [1, '/discover'], [2, '/plan/shopping?week=2026-10-05'], ])('row %i goes to %s and closes the sheet', async (i, to) => { stubServer() await renderHome() const listed = await rows(await openSheet()) fireEvent.click(within(listed[i]!).getByRole('button')) expect(await screen.findByTestId('where')).toHaveTextContent(to) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('says there are no updates yet, and marks nothing seen', async () => { const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) }) await renderHome() const sheet = await openSheet() expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument() expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() expect(posts(fetchMock)).toEqual([]) }) it('puts the dot back and says so when marking the updates seen fails', async () => { let refuse!: (response: Response) => void const fetchMock = stubServer({ [`POST ${UPDATES_SEEN_PATH}`]: () => new Promise((resolve) => (refuse = resolve)), }) await renderHome() const sheet = await openSheet() await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) // Cleared at once, while the mark is in flight. expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() refuse(errorResponse(500, 'internal')) expect(await within(sheet).findByRole('alert')).toHaveTextContent( "Couldn't mark your updates as seen. internal message", ) expect(screen.getByTestId('unread-dot')).toBeInTheDocument() // The updates are still listed. expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument() }) it('reports updates it cannot load, with Retry', async () => { let fail = true stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) }) await renderHome() const sheet = await openSheet() expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message") fail = false fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' })) expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument() }) it('reports an update that would open another site rather than listing it', async () => { const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }), }) await renderHome() const sheet = await openSheet() expect(await within(sheet).findByRole('alert')).toHaveTextContent( 'update url "//evil.example/plan" is not a path on this app', ) expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() expect(posts(fetchMock)).toEqual([]) }) })