Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 172 additions & 33 deletions README.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ React + Vite + TypeScript frontend for ByteBite.

## Prerequisites

- Node 18+
- Node 22 (matches the CI runner and [Dockerfile](Dockerfile))

## Setup & Run

Expand Down Expand Up @@ -38,16 +38,16 @@ npx vitest run <path> # run a single file, e.g. src/lib/mappers.test.ts

Tests sit next to the code they exercise (`*.test.ts` / `*.test.tsx`), in three tiers:

- **Unit** [src/lib/mappers.test.ts](src/lib/mappers.test.ts): the pure API↔view-model
- **Unit** [src/lib/mappers.test.ts](src/lib/mappers.test.ts): the pure API↔view-model
converters (quantity `null ↔ "N/A"`, item-payload mapping, derived counts).
- **Component** [src/components/](src/components/): `AuthCard`, `ItemListForm` and
- **Component** [src/components/](src/components/): `AuthCard`, `ItemListForm` and
`GroceryListView` rendered in isolation, driving real user interactions (form validation,
add/remove rows, optimistic toggle with rollback, loading/error/empty states).
- **Integration** [src/App.integration.test.tsx](src/App.integration.test.tsx): the real `App`
with `fetch` mocked at the network boundary, covering core workflows login bootstrap, session
- **Integration** [src/App.integration.test.tsx](src/App.integration.test.tsx): the real `App`
with `fetch` mocked at the network boundary, covering core workflows, login bootstrap, session
expiry, merging recipes into a grocery list across views, manual create, optimistic-delete
rollback, and failed-load retry.

No network or backend is needed `fetch` is mocked, so the suite is fast and deterministic. It runs
No network or backend is needed, `fetch` is mocked, so the suite is fast and deterministic. It runs
automatically in CI (see [`.github/workflows/test-build-push.yml`](../.github/workflows/test-build-push.yml))
and gates image builds, so a failing test blocks the merge.
58 changes: 58 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lucide-react": "^1.16.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.18.1",
"tailwindcss": "^4.3.0"
},
"devDependencies": {
Expand Down
159 changes: 147 additions & 12 deletions client/src/App.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import App from './App'
import type { AuthPayload } from './components/AuthCard'
import type {
ApiRecipe, ApiRecipeSummary, ApiGroceryList, ApiGroceryListSummary,
AuthPayload, ApiRecipe, ApiRecipeSummary, ApiGroceryList, ApiGroceryListSummary,
} from './types'

// These tests render the real App and mock `fetch` at the network boundary, so they exercise the
// wiring in App.tsx — token threading, the API↔view-model mappers, and state created in one view
// that surfaces in another — which the isolated component tests can't reach.
// wiring App owns — the session and api layer, the API↔view-model mappers, routing, and state
// created on one page that surfaces on another — which the isolated component tests can't reach.
// The router lives outside App (main.tsx supplies BrowserRouter), so the tests supply their own.

// ── A tiny declarative fake backend ─────────────────────────────────────────────────────────
type ResponseSpec = { status?: number; body?: unknown }
Expand Down Expand Up @@ -73,8 +74,17 @@ const okRecipes = { method: 'GET', match: '/api/recipes', respond: { body: recip
const okGroceryEmpty = { method: 'GET', match: '/api/grocery-list', respond: { body: [] as ApiGroceryListSummary[] } } as Route
const okSession = { method: 'GET', match: '/api/users/me', respond: { body: auth } } as Route

function renderApp(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
)
}

// The sidebar navigates with real links now, so these are anchors rather than buttons.
function gotoView(user: ReturnType<typeof userEvent.setup>, name: 'Recipes' | 'Grocery Lists') {
return user.click(screen.getByRole('button', { name }))
return user.click(screen.getByRole('link', { name }))
}

beforeEach(() => {
Expand All @@ -93,7 +103,7 @@ describe('auth bootstrap', () => {
okRecipes,
okGroceryEmpty,
])
render(<App />)
renderApp()

// Starts on the auth screen.
expect(screen.getByText('Sign in to continue')).toBeInTheDocument()
Expand All @@ -116,7 +126,7 @@ describe('auth bootstrap', () => {
it('drops back to the auth screen when the stored session is rejected', async () => {
seedSession()
installApi([{ method: 'GET', match: '/api/users/me', respond: { status: 401 } }])
render(<App />)
renderApp()

expect(await screen.findByText('Sign in to continue')).toBeInTheDocument()
expect(localStorage.getItem('bytebite-token')).toBeNull()
Expand All @@ -133,7 +143,7 @@ describe('recipe workflows', () => {
okGroceryEmpty,
{ method: 'POST', match: '/api/grocery-list/merge', respond: { body: mergedList } },
])
render(<App />)
renderApp()

await gotoView(user, 'Recipes')
await screen.findByText('Pasta')
Expand All @@ -147,11 +157,94 @@ describe('recipe workflows', () => {

expect(await screen.findByText(/merged!/i)).toBeInTheDocument()

// The merged list lives in App state, so it shows up when we switch views.
// The merged list lives in the shared grocery-list state, so it is there when we navigate.
await gotoView(user, 'Grocery Lists')
expect(await screen.findByText('Pasta + Salad')).toBeInTheDocument()
})

it('merges a single selected recipe into a grocery list', async () => {
const user = userEvent.setup()
seedSession()
const singleList: ApiGroceryList = {
groceryListId: 'g8',
name: 'Pasta',
createdAt: '2026-01-04',
items: [{ itemId: 'i1', name: 'Tomato', quantity: 3, unit: '', category: 'PRODUCE', purchased: false }],
}
const fetchMock = installApi([
okSession,
okRecipes,
okGroceryEmpty,
{ method: 'POST', match: '/api/grocery-list/merge', respond: { body: singleList } },
])
renderApp()

await gotoView(user, 'Recipes')
await screen.findByText('Pasta')

// One recipe is enough: the Merge button must be live with a single selection.
await user.click(screen.getAllByRole('checkbox')[0])
const merge = screen.getByRole('button', { name: 'Merge' })
expect(merge).toBeEnabled()
await user.click(merge)

expect(await screen.findByText(/merged!/i)).toBeInTheDocument()
const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === 'POST')!
expect(JSON.parse((post[1] as RequestInit).body as string).recipeIds).toEqual(['r1'])
})

// Generating on the Home page is the only place the dietary flags exist. They are never stored:
// the ingredient that clashes with the diet is saved as its alternative, so the recipe — and
// every grocery list merged from it — already names the thing the user should buy.
it('saves a diet-clashing ingredient under its alternative', async () => {
const user = userEvent.setup()
seedSession()
const generated = {
dish: 'Pancakes',
ingredients: [
{ name: 'Flour', quantity: '300', unit: 'g', category: 'PANTRY', restricted: false, alternative: null },
{ name: 'Milk', quantity: '200', unit: 'ml', category: 'DAIRY', restricted: true, alternative: 'oat milk' },
],
}
const saved: ApiRecipe = {
recipeId: 'r9', name: 'Pancakes', createdAt: '2026-01-06',
items: [
{ itemId: 'i1', name: 'Flour', quantity: 300, unit: 'g', category: 'PANTRY' },
{ itemId: 'i2', name: 'oat milk', quantity: 200, unit: 'ml', category: 'DAIRY' },
],
}
const fetchMock = installApi([
okSession,
{ method: 'GET', match: '/api/recipes/providers', respond: { body: { openaiAvailable: false } } },
{ method: 'GET', match: '/api/recipes', respond: { body: [] as ApiRecipeSummary[] } },
okGroceryEmpty,
{ method: 'POST', match: '/api/recipes/generate', respond: { body: generated } },
{ method: 'POST', match: '/api/recipes', respond: { body: saved } },
])
renderApp()

await user.type(await screen.findByPlaceholderText(/paste a recipe/i), 'Pancakes')
await user.click(screen.getByRole('button', { name: /generate recipe/i }))

await waitFor(() => {
const post = fetchMock.mock.calls.find(([url, init]) =>
String(url) === '/api/recipes' && (init as RequestInit)?.method === 'POST')
expect(post).toBeDefined()
})

const post = fetchMock.mock.calls.find(([url, init]) =>
String(url) === '/api/recipes' && (init as RequestInit)?.method === 'POST')!
const body = JSON.parse((post[1] as RequestInit).body as string)

// The milk was swapped for the oat milk; the unrestricted flour was left alone…
expect(body.items.map((item: { name: string }) => item.name)).toEqual(['Flour', 'oat milk'])
// …the swapped item kept the quantity and unit of the ingredient it replaced…
expect(body.items[1]).toMatchObject({ quantity: 200, unit: 'ml', category: 'DAIRY' })
// …and no dietary fields were persisted, because there is nowhere to put them.
expect(body.items[1]).not.toHaveProperty('restricted')
expect(body.items[1]).not.toHaveProperty('alternative')
})

it('creates a recipe from the manual editor and prepends it to the list', async () => {
const user = userEvent.setup()
seedSession()
Expand All @@ -165,7 +258,7 @@ describe('recipe workflows', () => {
okGroceryEmpty,
{ method: 'POST', match: '/api/recipes', respond: { body: created } },
])
render(<App />)
renderApp()

await gotoView(user, 'Recipes')
await screen.findByText('No recipes yet')
Expand All @@ -188,7 +281,7 @@ describe('recipe workflows', () => {
okGroceryEmpty,
{ method: 'DELETE', match: /\/api\/recipes\/r1$/, respond: { status: 500 } },
])
render(<App />)
renderApp()

await gotoView(user, 'Recipes')
await screen.findByText('Pasta')
Expand All @@ -208,12 +301,54 @@ describe('recipe workflows', () => {
{ method: 'GET', match: '/api/recipes', respond: ({ count }) => (count === 1 ? { status: 500 } : { body: [recipeSummaries[0]] }) },
okGroceryEmpty,
])
render(<App />)
renderApp()

await gotoView(user, 'Recipes')
expect(await screen.findByText(/couldn't load recipes/i)).toBeInTheDocument()

await user.click(screen.getByRole('button', { name: /try again/i }))
expect(await screen.findByText('Pasta')).toBeInTheDocument()
})
})

// Every screen has its own URL now, which is what makes these possible at all — before the router,
// the app rendered whatever `view` state said and the address bar never moved.
describe('routing', () => {
it('opens a deep link straight to the page, without passing through Home', async () => {
seedSession()
installApi([okSession, okRecipes, okGroceryEmpty])
renderApp('/recipes')

expect(await screen.findByText('Pasta')).toBeInTheDocument()
expect(screen.queryByText(/AI-powered grocery assistant/i)).not.toBeInTheDocument()
})

it('sends an unauthenticated deep link through login and back to where it was headed', async () => {
const user = userEvent.setup()
installApi([
{ method: 'POST', match: '/api/auth/login', respond: { body: auth } },
okRecipes,
okGroceryEmpty,
])
renderApp('/recipes')

// The guard bounced us to the login screen…
expect(await screen.findByText('Sign in to continue')).toBeInTheDocument()

await user.type(screen.getByLabelText('Email'), 'ada@example.com')
await user.type(screen.getByLabelText('Password'), 'supersecret')
const submit = screen.getAllByRole('button').find(b => b.getAttribute('type') === 'submit')!
await user.click(submit)

// …and signing in returns us to /recipes rather than dumping us on Home.
expect(await screen.findByText('Pasta')).toBeInTheDocument()
})

it('redirects an unknown path to Home', async () => {
seedSession()
installApi([okSession, okRecipes, okGroceryEmpty])
renderApp('/does-not-exist')

expect(await screen.findByText(/AI-powered grocery assistant/i)).toBeInTheDocument()
})
})
Loading
Loading