diff --git a/backend/migrations/1787000000000_add-customer-disabled-at.js b/backend/migrations/1787000000000_add-customer-disabled-at.js new file mode 100644 index 0000000..0abb90f --- /dev/null +++ b/backend/migrations/1787000000000_add-customer-disabled-at.js @@ -0,0 +1,19 @@ +exports.up = (pgm) => { + pgm.sql(` + -- Nullable timestamp rather than a boolean: disabling is reversible, and + -- this records when it happened without a second column. + ALTER TABLE customers ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ; + + -- attachCustomer joins customers on every authenticated request to check + -- this, so the lookup wants an index on the active case. + CREATE INDEX IF NOT EXISTS customers_disabled_at_idx + ON customers (disabled_at) WHERE disabled_at IS NOT NULL; + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + DROP INDEX IF EXISTS customers_disabled_at_idx; + ALTER TABLE customers DROP COLUMN IF EXISTS disabled_at; + `); +}; diff --git a/backend/src/middleware/customerAuth.ts b/backend/src/middleware/customerAuth.ts index bf0c5b5..a27f3e2 100755 --- a/backend/src/middleware/customerAuth.ts +++ b/backend/src/middleware/customerAuth.ts @@ -12,8 +12,15 @@ declare global { export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise { const token = req.cookies?.rd_session; if (!token) return next(); + // Joined to customers so a disabled account stops resolving at once, rather + // than when its 30-day cookie eventually expires. Register, login and + // password reset all mint sessions, so checking here covers every path + // instead of three separate ones. const { rows } = await pool.query( - `SELECT customer_id FROM customer_sessions WHERE token = $1 AND expires_at > now()`, + `SELECT s.customer_id + FROM customer_sessions s + JOIN customers c ON c.id = s.customer_id + WHERE s.token = $1 AND s.expires_at > now() AND c.disabled_at IS NULL`, [token] ); if (rows.length) req.customerId = rows[0].customer_id; diff --git a/backend/src/routes/adminCustomers.ts b/backend/src/routes/adminCustomers.ts index b84c1c0..81753da 100755 --- a/backend/src/routes/adminCustomers.ts +++ b/backend/src/routes/adminCustomers.ts @@ -7,7 +7,7 @@ const router = Router(); router.get('/', asyncRoute(async (_req: Request, res: Response) => { const { rows } = await pool.query(` SELECT - c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, + c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at, COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count, COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents, MAX(o.created_at) AS last_order_at, @@ -27,6 +27,71 @@ router.get('/', asyncRoute(async (_req: Request, res: Response) => { res.json(rows); })); +// Disabling is reversible, so this records a timestamp rather than flipping a +// boolean — when it happened comes free. +// +// Everything below happens in one transaction. A disable that evicted the +// sessions but left the cart held, or vice versa, would be worse than either +// outcome alone. +router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { rows } = await client.query( + `UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`, + [req.params.id] + ); + if (!rows.length) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'not found' }); + } + + // Immediate eviction. attachCustomer also refuses a disabled account, so + // this is belt and braces — but it means the rows are gone rather than + // lingering until their 30-day expiry. + await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [req.params.id]); + + // A disabled account cannot check out, so holding one-of-a-kind stock off + // the storefront until the expiry sweep serves nobody. Guarded on + // 'reserved' so a sold item is never resurrected. + const { rows: held } = await client.query( + `DELETE FROM cart_items ci + USING carts ca + WHERE ci.cart_id = ca.id AND ca.customer_id = $1 + RETURNING ci.item_id`, + [req.params.id] + ); + if (held.length) { + await client.query( + `UPDATE items SET status = 'available', reserved_until = NULL + WHERE id = ANY($1::int[]) AND status = 'reserved'`, + [held.map((row: { item_id: number }) => row.item_id)] + ); + } + + await client.query('COMMIT'); + res.status(204).end(); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +})); + +// Restores sign-in only. Items released by the disable stay released — they may +// well have been sold to someone else in the meantime, and silently re-reserving +// them would be worse than making the customer add them again. +router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => { + const { rows } = await pool.query( + `UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`, + [req.params.id] + ); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + res.status(204).end(); +})); + router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts index 5e33b12..90f2e39 100755 --- a/backend/src/routes/customers.ts +++ b/backend/src/routes/customers.ts @@ -111,7 +111,7 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]); const customer = rows[0]; - if (customer) { + if (customer && !customer.disabled_at) { // Supersede any outstanding token, so a link cannot be resurrected later // from an older message in the customer's inbox. await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]); @@ -156,6 +156,13 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) => if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' }); const customerId = rows[0].customer_id; + // A token issued before the account was disabled would otherwise still mint a + // fresh session. + const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]); + if (owner[0]?.disabled_at) { + return res.status(403).json({ error: 'this account has been disabled' }); + } + const passwordHash = await bcrypt.hash(String(password), 12); const client = await pool.connect(); @@ -194,6 +201,11 @@ router.post('/login', async (req: Request, res: Response) => { if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) { return res.status(401).json({ error: 'invalid email or password' }); } + // Only after the password checks out, so a wrong password still looks like a + // wrong password and this does not become a bulk membership oracle. + if (customer.disabled_at) { + return res.status(403).json({ error: 'this account has been disabled' }); + } const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); diff --git a/backend/tests/integration/disableCustomer.integration.test.ts b/backend/tests/integration/disableCustomer.integration.test.ts new file mode 100644 index 0000000..1ebb958 --- /dev/null +++ b/backend/tests/integration/disableCustomer.integration.test.ts @@ -0,0 +1,205 @@ +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +const PASSWORD = 'supersecret123'; + +async function register(email: string) { + const agent = request.agent(app); + const res = await agent.post('/api/customers/register').send({ email, password: PASSWORD }); + expect(res.status).toBe(200); + const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]); + return { agent, id: rows[0].id as number }; +} + +async function createItem(name: string) { + const { rows } = await pool.query( + `INSERT INTO items (name, price_cents) VALUES ($1, 1000) RETURNING id`, + [name] + ); + return rows[0].id as number; +} + +describe('disabling a customer', () => { + it('is reported in the admin customer list', async () => { + const { id } = await register('flag@example.com'); + + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const res = await request(app).get('/api/admin/customers'); + const customer = res.body.find((c: { id: number }) => c.id === id); + expect(customer.disabled_at).toBeTruthy(); + }); + + it('refuses the sign-in with an explicit reason, not a credential failure', async () => { + const { id } = await register('told@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const res = await request(app) + .post('/api/customers/login') + .send({ email: 'told@example.com', password: PASSWORD }); + + // A generic 401 would send them round the password-reset loop forever. + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/disabled/i); + }); + + it('still refuses a sign-in with the wrong password, without revealing the disable', async () => { + const { id } = await register('wrongpw@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const res = await request(app) + .post('/api/customers/login') + .send({ email: 'wrongpw@example.com', password: 'not-the-password' }); + expect(res.status).toBe(401); + }); + + it('kills sessions that already existed', async () => { + const { agent, id } = await register('evicted@example.com'); + expect((await agent.get('/api/customers/me')).status).toBe(200); + + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + // The cookie is still held by the agent; it must stop working immediately + // rather than at its 30-day expiry. + expect((await agent.get('/api/customers/me')).status).toBe(401); + }); + + it('releases items the customer was holding', async () => { + const itemId = await createItem('Held item'); + const { agent, id } = await register('holder@example.com'); + expect((await agent.post(`/api/cart/items/${itemId}`)).status).toBe(201); + + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const item = await request(app).get(`/api/items/${itemId}`); + expect(item.body.status).toBe('available'); + + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]); + expect(rows[0].n).toBe(0); + }); + + it('leaves a released item purchasable by someone else', async () => { + const itemId = await createItem('Freed item'); + const { agent: first, id } = await register('first@example.com'); + await first.post(`/api/cart/items/${itemId}`); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const { agent: second } = await register('second@example.com'); + expect((await second.post(`/api/cart/items/${itemId}`)).status).toBe(201); + }); + + it('does not resurrect an item that was already sold', async () => { + const itemId = await createItem('Sold item'); + const { agent, id } = await register('sold@example.com'); + await agent.post(`/api/cart/items/${itemId}`); + await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]); + + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]); + expect(rows[0].status).toBe('sold'); + }); + + it('blocks the self-service data export and account deletion', async () => { + const { agent, id } = await register('gdpr@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + expect((await agent.get('/api/customers/me/export')).status).toBe(401); + expect((await agent.delete('/api/customers/me')).status).toBe(401); + }); + + it('sends no reset mail and issues no token for a disabled account', async () => { + const { id } = await register('noreset@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + // Still 200, so the endpoint stays non-enumerating. + const res = await request(app) + .post('/api/customers/request-password-reset') + .send({ email: 'noreset@example.com' }); + expect(res.status).toBe(200); + + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, + [id] + ); + expect(rows[0].n).toBe(0); + }); + + it('refuses to complete a reset whose token predates the disable', async () => { + const { id } = await register('midreset@example.com'); + await request(app).post('/api/customers/request-password-reset').send({ email: 'midreset@example.com' }); + const { rows } = await pool.query( + `SELECT token FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, + [id] + ); + const token = rows[0].token; + + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + const res = await request(app) + .post('/api/customers/reset-password') + .send({ token, password: 'a-brand-new-password' }); + expect(res.status).toBe(403); + }); + + it('rejects registering again with the same address', async () => { + const { id } = await register('reregister@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + + // Otherwise disabling is trivially undone by signing up again. + const res = await request(app) + .post('/api/customers/register') + .send({ email: 'reregister@example.com', password: PASSWORD }); + expect(res.status).toBe(409); + }); +}); + +describe('re-enabling a customer', () => { + it('restores sign-in', async () => { + const { id } = await register('back@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + await request(app).post(`/api/admin/customers/${id}/enable`).expect(204); + + const res = await request(app) + .post('/api/customers/login') + .send({ email: 'back@example.com', password: PASSWORD }); + expect(res.status).toBe(200); + }); + + it('clears the disabled timestamp', async () => { + const { id } = await register('cleared@example.com'); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + await request(app).post(`/api/admin/customers/${id}/enable`).expect(204); + + const res = await request(app).get('/api/admin/customers'); + const customer = res.body.find((c: { id: number }) => c.id === id); + expect(customer.disabled_at).toBeNull(); + }); + + it('does not give back the items that were released', async () => { + const itemId = await createItem('Not returned'); + const { agent, id } = await register('norestore@example.com'); + await agent.post(`/api/cart/items/${itemId}`); + await request(app).post(`/api/admin/customers/${id}/disable`).expect(204); + await request(app).post(`/api/admin/customers/${id}/enable`).expect(204); + + const item = await request(app).get(`/api/items/${itemId}`); + expect(item.body.status).toBe('available'); + }); + + it('returns 404 for a customer that does not exist', async () => { + expect((await request(app).post('/api/admin/customers/999999/disable')).status).toBe(404); + expect((await request(app).post('/api/admin/customers/999999/enable')).status).toBe(404); + }); +}); diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 4f3ce63..3ad2902 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Layout, Table, Button, Form, Input, InputNumber, Upload, Modal, Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs, @@ -39,7 +39,16 @@ function Inventory() { const [filters, setFilters] = useState(EMPTY_FILTERS); const { mode } = useThemeMode(); - const load = (active: ItemFilters = filters) => fetchAdminItems(active).then(setItems); + // Typing in the price fields fires a request per keystroke, so responses can + // arrive out of order and an older one can repaint stale rows over a newer + // result. Only the most recently issued request is allowed to set state. + const latestRequest = useRef(0); + const load = (active: ItemFilters = filters) => { + const seq = ++latestRequest.current; + return fetchAdminItems(active).then(rows => { + if (seq === latestRequest.current) setItems(rows); + }); + }; // The item form needs the current category tree and tag list; both change // from the sibling tabs, so they're refetched whenever the modal opens. diff --git a/frontend/src/admin/Customers.tsx b/frontend/src/admin/Customers.tsx index 1a3fece..7c430e1 100755 --- a/frontend/src/admin/Customers.tsx +++ b/frontend/src/admin/Customers.tsx @@ -3,6 +3,7 @@ import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty, Modal, Butto import type { ColumnsType } from 'antd/es/table'; import { fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem, + setCustomerDisabled, CustomerSummary, CustomerDetail, ReservedItem } from './adminCustomersApi'; @@ -18,6 +19,7 @@ export default function Customers() { const [reserved, setReserved] = useState([]); const [reservedLoading, setReservedLoading] = useState(false); const [releasing, setReleasing] = useState(null); + const [togglingId, setTogglingId] = useState(null); function load() { return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); }); @@ -46,6 +48,46 @@ export default function Customers() { } } + function handleToggleDisabled(customer: CustomerSummary) { + const disabling = !customer.disabled_at; + + Modal.confirm({ + title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`, + content: disabling ? ( + + They will be signed out everywhere immediately and told the account is disabled if they + try to sign in. + {customer.reserved_count > 0 && ( + <> Their {customer.reserved_count} reserved item + {customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront. + )} + {' '}Self-service data export and account deletion stop working too, so any such request + has to be handled by hand. + + ) : ( + + They will be able to sign in again. Items released when the account was disabled are not + returned — those may already have sold. + + ), + okText: disabling ? 'Disable' : 'Re-enable', + okButtonProps: { danger: disabling }, + onOk: async () => { + setTogglingId(customer.id); + try { + await setCustomerDisabled(customer.id, disabling); + } catch (err) { + message.error(`Couldn't ${disabling ? 'disable' : 're-enable'} — ${(err as Error).message}`); + return; + } finally { + setTogglingId(null); + } + message.success(disabling ? 'Account disabled' : 'Account re-enabled'); + load(); + } + }); + } + async function handleRelease(item: ReservedItem) { if (!reservedFor) return; setReleasing(item.item_id); @@ -90,6 +132,14 @@ export default function Customers() { onFilter: (value, row) => row.marketing_consent === value, render: (v: boolean) => {v ? 'Yes' : 'No'} }, + { + title: 'Status', + dataIndex: 'disabled_at', + render: (disabledAt: string | null) => + disabledAt + ? DISABLED + : ACTIVE + }, { title: 'Reserved', dataIndex: 'reserved_count', @@ -127,6 +177,21 @@ export default function Customers() { sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(), render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—') }, + { + title: '', + key: 'actions', + render: (_: unknown, customer: CustomerSummary) => ( + + ) + }, { title: 'Joined', dataIndex: 'created_at', diff --git a/frontend/src/admin/adminCustomersApi.ts b/frontend/src/admin/adminCustomersApi.ts index fa3a451..0bf45dd 100755 --- a/frontend/src/admin/adminCustomersApi.ts +++ b/frontend/src/admin/adminCustomersApi.ts @@ -9,6 +9,7 @@ export interface CustomerSummary { total_spent_cents: number; last_order_at: string | null; reserved_count: number; + disabled_at: string | null; } export interface ReservedItem { @@ -69,3 +70,15 @@ export async function releaseReservedItem(customerId: number, itemId: number): P throw new Error(detail.error || 'failed to release item'); } } + +export async function setCustomerDisabled(customerId: number, disabled: boolean): Promise { + const res = await fetch(`/api/admin/customers/${customerId}/${disabled ? 'disable' : 'enable'}`, { + method: 'POST' + }); + // Reporting success for a disable that failed would leave an account the + // admin believes is locked still fully usable. + if (!res.ok) { + const detail = await res.json().catch(() => ({})); + throw new Error(detail.error || `failed to ${disabled ? 'disable' : 'enable'} account`); + } +} diff --git a/frontend/tests/e2e/admin-disable-customer.spec.ts b/frontend/tests/e2e/admin-disable-customer.spec.ts new file mode 100644 index 0000000..d5abde5 --- /dev/null +++ b/frontend/tests/e2e/admin-disable-customer.spec.ts @@ -0,0 +1,107 @@ +import { test, expect, Page } from '@playwright/test'; + +const PASSWORD = 'supersecret123'; +const uniqueEmail = () => `disable-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; + +async function register(page: Page, email: string) { + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page).toHaveURL(/\/account/); +} + +async function customerRow(page: Page, email: string) { + await page.goto('/admin'); + await page.getByRole('tab', { name: 'Customers' }).click(); + const row = page.getByRole('row').filter({ hasText: email }); + await expect(row).toBeVisible(); + return row; +} + +test.describe('Disabling a customer account', () => { + test('an admin can disable an account and the customer is told at sign-in', async ({ page }) => { + const email = uniqueEmail(); + await register(page, email); + + const row = await customerRow(page, email); + await expect(row.getByText('ACTIVE')).toBeVisible(); + + await row.getByRole('button', { name: 'Disable' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click(); + await expect(page.getByText('Account disabled')).toBeVisible(); + + const updated = page.getByRole('row').filter({ hasText: email }); + await expect(updated.getByText('DISABLED')).toBeVisible(); + + // A generic credential error would send a real customer round the + // password-reset loop forever. + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByText(/disabled/i)).toBeVisible(); + }); + + test('an existing session stops working immediately', async ({ page, request }) => { + const email = uniqueEmail(); + await register(page, email); + + // Still signed in from registration, in this same browser context. + await page.goto('/account'); + await expect(page.getByText(email)).toBeVisible(); + + const customers = await (await request.get('/api/admin/customers')).json(); + const id = customers.find((c: { email: string }) => c.email === email).id; + await request.post(`/api/admin/customers/${id}/disable`); + + // The cookie is unchanged, so this proves the server rejects it rather + // than the browser having discarded it. + await page.goto('/account'); + await expect(page).toHaveURL(/\/login/); + }); + + test('re-enabling restores sign-in', async ({ page, request }) => { + const email = uniqueEmail(); + await register(page, email); + + const customers = await (await request.get('/api/admin/customers')).json(); + const id = customers.find((c: { email: string }) => c.email === email).id; + await request.post(`/api/admin/customers/${id}/disable`); + + const row = await customerRow(page, email); + await row.getByRole('button', { name: 'Re-enable' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Re-enable' }).click(); + await expect(page.getByText('Account re-enabled')).toBeVisible(); + + await page.goto('/login'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page).toHaveURL(/\/account/); + }); + + test('the confirmation warns that held items will be released', async ({ page, request }) => { + const email = uniqueEmail(); + const itemName = `Held ${Date.now().toString(36)}`; + + const created = await request.post('/api/admin/items', { + multipart: { name: itemName, description: '', price: '40', category_id: '', tags: '[]' } + }); + const itemId = (await created.json()).id as number; + + await register(page, email); + expect((await page.request.post(`/api/cart/items/${itemId}`)).status()).toBe(201); + + const row = await customerRow(page, email); + await row.getByRole('button', { name: 'Disable' }).click(); + + // The consequence has to be visible at the moment of the decision. + await expect(page.getByText(/1 reserved item/)).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Disable' }).click(); + await expect(page.getByText('Account disabled')).toBeVisible(); + + const item = await (await request.get(`/api/items/${itemId}`)).json(); + expect(item.status).toBe('available'); + }); +});