-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.integration.test.tsx
More file actions
354 lines (303 loc) · 14.1 KB
/
Copy pathApp.integration.test.tsx
File metadata and controls
354 lines (303 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
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, ApiRecipe, ApiRecipeSummary, ApiGroceryList, ApiGroceryListSummary,
} from './types'
// These tests render the real App and mock `fetch` at the network boundary, so they exercise the
// 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 }
type Route = {
method: string
match: string | RegExp
respond: ResponseSpec | ((call: { count: number }) => ResponseSpec)
}
function installApi(routes: Route[]) {
const counts = new Map<Route, number>()
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const method = (init?.method ?? 'GET').toUpperCase()
const route = routes.find(
r => r.method === method && (typeof r.match === 'string' ? r.match === url : r.match.test(url))
)
if (!route) throw new Error(`No mock registered for ${method} ${url}`)
const count = (counts.get(route) ?? 0) + 1
counts.set(route, count)
const spec = typeof route.respond === 'function' ? route.respond({ count }) : route.respond
const status = spec.status ?? 200
return {
ok: status >= 200 && status < 300,
status,
json: async () => spec.body,
} as unknown as Response
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// ── Fixtures ────────────────────────────────────────────────────────────────────────────────
const auth: AuthPayload = {
token: 'jwt-1',
user: { userId: 'u1', name: 'Ada', email: 'ada@example.com', createdAt: '2026-01-01' },
}
const recipeSummaries: ApiRecipeSummary[] = [
{ recipeId: 'r1', name: 'Pasta', createdAt: '2026-01-02' },
{ recipeId: 'r2', name: 'Salad', createdAt: '2026-01-03' },
]
const mergedList: ApiGroceryList = {
groceryListId: 'g9',
name: 'Pasta + Salad',
createdAt: '2026-01-04',
items: [
{ itemId: 'i1', name: 'Tomato', quantity: 3, unit: '', category: 'PRODUCE', purchased: false },
{ itemId: 'i2', name: 'Pasta', quantity: 500, unit: 'g', category: 'PANTRY', purchased: false },
],
}
/** Seeds a logged-in session so App boots straight into the main UI. */
function seedSession() {
localStorage.setItem('bytebite-token', auth.token)
localStorage.setItem('bytebite-user', JSON.stringify(auth.user))
}
const okRecipes = { method: 'GET', match: '/api/recipes', respond: { body: recipeSummaries } } as Route
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('link', { name }))
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('auth bootstrap', () => {
it('logs in, then loads and shows the session', async () => {
const user = userEvent.setup()
const fetchMock = installApi([
{ method: 'POST', match: '/api/auth/login', respond: { body: auth } },
okRecipes,
okGroceryEmpty,
])
renderApp()
// Starts on the auth screen.
expect(screen.getByText('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)
// Lands in the app: the sidebar shows the authenticated user…
expect(await screen.findByText('Ada')).toBeInTheDocument()
// …and the bootstrap fired the two list loads with the returned token.
await waitFor(() => {
const calls = fetchMock.mock.calls.map(([u]) => String(u))
expect(calls).toContain('/api/recipes')
expect(calls).toContain('/api/grocery-list')
})
})
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 } }])
renderApp()
expect(await screen.findByText('Sign in to continue')).toBeInTheDocument()
expect(localStorage.getItem('bytebite-token')).toBeNull()
})
})
describe('recipe workflows', () => {
it('merges recipes on the Recipes page and the new list appears under Grocery Lists', async () => {
const user = userEvent.setup()
seedSession()
installApi([
okSession,
okRecipes,
okGroceryEmpty,
{ method: 'POST', match: '/api/grocery-list/merge', respond: { body: mergedList } },
])
renderApp()
await gotoView(user, 'Recipes')
await screen.findByText('Pasta')
await screen.findByText('Salad')
// Select both recipes, then merge.
const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes[0])
await user.click(checkboxes[1])
await user.click(screen.getByRole('button', { name: 'Merge' }))
expect(await screen.findByText(/merged!/i)).toBeInTheDocument()
// 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()
const created: ApiRecipe = {
recipeId: 'r9', name: 'Tacos', createdAt: '2026-01-05',
items: [{ itemId: 'i1', name: 'Tortilla', quantity: 8, unit: '', category: 'BAKERY' }],
}
installApi([
okSession,
{ method: 'GET', match: '/api/recipes', respond: { body: [] as ApiRecipeSummary[] } },
okGroceryEmpty,
{ method: 'POST', match: '/api/recipes', respond: { body: created } },
])
renderApp()
await gotoView(user, 'Recipes')
await screen.findByText('No recipes yet')
await user.click(screen.getByRole('button', { name: /new recipe/i }))
const dialog = screen.getByRole('dialog')
await user.type(within(dialog).getByPlaceholderText('e.g. Spaghetti Bolognese'), 'Tacos')
await user.type(within(dialog).getByPlaceholderText('Item name'), 'Tortilla')
await user.click(within(dialog).getByRole('button', { name: 'Create recipe' }))
expect(await screen.findByText('Tacos')).toBeInTheDocument()
})
it('rolls back an optimistic delete when the server rejects it', async () => {
const user = userEvent.setup()
seedSession()
installApi([
okSession,
{ method: 'GET', match: '/api/recipes', respond: { body: [recipeSummaries[0]] } },
okGroceryEmpty,
{ method: 'DELETE', match: /\/api\/recipes\/r1$/, respond: { status: 500 } },
])
renderApp()
await gotoView(user, 'Recipes')
await screen.findByText('Pasta')
await user.click(screen.getByRole('button', { name: 'Delete recipe' }))
// Optimistically removed, then restored once the DELETE fails.
await waitFor(() => expect(screen.getByText('Pasta')).toBeInTheDocument())
})
it('recovers from a failed recipe load via Try again', async () => {
const user = userEvent.setup()
seedSession()
installApi([
okSession,
// First load fails; the retry succeeds.
{ method: 'GET', match: '/api/recipes', respond: ({ count }) => (count === 1 ? { status: 500 } : { body: [recipeSummaries[0]] }) },
okGroceryEmpty,
])
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()
})
})