Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #181.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user