Files
Ghost_Kitchen/tests/ordering.test.ts
T
lukasandClaude Sonnet 5 79bd64ce8c Add guest/account auth flows, order tracking, and order history UI
- Wire up Better Auth (email/password + Google/Apple SSO) with a
  sign-in/sign-up UI, header account menu, and a basket that follows
  the signed-in-or-guest state.
- Give the order status page a vertical timeline per preparation step
  (with a distinct customer-facing name separate from the machine tag,
  a done/in-progress/queued/pending look, and a sonar-ping highlight on
  the active step), a formatted ETA (relative under 3h, absolute clock
  time beyond that), and Buy again / Get help / Cancel actions.
- Expand Pizza's recipe into 6 steps across a shared prep station and
  the existing oven/assembly stations, with matching schema, seed, and
  domain changes plus updated/added test coverage.
- Add an Orders list (account orders via session, guest orders via a
  locally remembered access-token list) with a shared getOrdersForCustomer
  domain summary, and remember a guest's name/contact after their first
  order so checkout doesn't ask again.
- Improve page transitions with the View Transitions API: a shared
  element morph between an order card and its detail page, a stable
  (non-jumping) header across navigations, and reduced-motion handling.
- Add a Ghost Kitchen wordmark (Space Grotesk, faded "Ghost", ghost
  icon standing in for the "o") and shrink the basket icon to match
  the rest of the header controls.
- Add a run-ghost-kitchen agent skill documenting local setup, the
  dev-server driver, and the gotchas hit along the way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:58:24 +02:00

621 lines
20 KiB
TypeScript

import { and, eq } from 'drizzle-orm'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest'
import * as schema from '#/db/schema.ts'
import * as ordering from '#/domain/ordering.ts'
import { createFakePaymentProvider } from '#/lib/payment.ts'
import { seedGhostKitchen } from '#/db/seed.ts'
import { startTestDatabase } from './helpers/test-db.ts'
import type { TestDatabase } from './helpers/test-db.ts'
let testDb: TestDatabase
let seed: Awaited<ReturnType<typeof seedGhostKitchen>>
let payment: ReturnType<typeof createFakePaymentProvider>
beforeAll(async () => {
testDb = await startTestDatabase()
seed = await seedGhostKitchen(testDb.db)
}, 120_000)
afterAll(async () => {
await testDb.stop()
})
beforeEach(() => {
payment = createFakePaymentProvider()
})
// Every Order/OrderItem/Step is scoped to a single test — each test places
// its own Order(s) — so the Machines seeded once in beforeAll stay idle
// between tests instead of leaking a busy state into the next one.
afterEach(async () => {
await testDb.db.delete(schema.preparationStepInstances)
await testDb.db.delete(schema.orderItemSteps)
await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders)
})
const placeGuestOrder = (items: Array<{ menuItemId: number }>) =>
ordering.placeOrder(testDb.db, payment, {
items,
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
})
const placeAccountOrder = (
items: Array<{ menuItemId: number }>,
customerId: string,
) =>
ordering.placeOrder(testDb.db, payment, {
items,
customer: { type: 'account', customerId },
})
// Tests act as the guest who placed the order, so they present its access
// token exactly like a real caller would — this exercises the same
// ownership check a stranger guessing the order id would fail.
const accessFor = (order: { guestAccessToken: string | null }) => ({
guestAccessToken: order.guestAccessToken ?? undefined,
})
const getStatus = (order: { id: number; guestAccessToken: string | null }) =>
ordering.getOrderStatus(testDb.db, order.id, accessFor(order))
describe('menu', () => {
it('lists menu items for a brand with their recipe steps', async () => {
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
const pizza = menu.find((item) => item.name === 'Pizza')
expect(pizza?.steps).toEqual([
{
sequence: 1,
name: 'Flattening dough',
machineTag: 'prep',
estimatedDurationSeconds: 30,
},
{
sequence: 2,
name: 'Spreading sauce',
machineTag: 'prep',
estimatedDurationSeconds: 20,
},
{
sequence: 3,
name: 'Adding toppings',
machineTag: 'prep',
estimatedDurationSeconds: 30,
},
{
sequence: 4,
name: 'Baking in the oven',
machineTag: 'oven',
estimatedDurationSeconds: 600,
},
{
sequence: 5,
name: 'Slicing',
machineTag: 'assembly',
estimatedDurationSeconds: 20,
},
{
sequence: 6,
name: 'Packaging',
machineTag: 'assembly',
estimatedDurationSeconds: 20,
},
])
const burger = menu.find((item) => item.name === 'Burger')
expect(burger?.steps).toEqual([
{
sequence: 1,
name: 'Grilling patty',
machineTag: 'grill',
estimatedDurationSeconds: 240,
},
{
sequence: 2,
name: 'Assembling burger',
machineTag: 'assembly',
estimatedDurationSeconds: 90,
},
])
})
})
describe('placing an order', () => {
it('rejects an order with no items', async () => {
await expect(placeGuestOrder([])).rejects.toThrow()
})
it('charges the total price and queues the first Preparation Step for each Order Item', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
{ menuItemId: seed.menuItems.burger.id },
])
expect(order.status).toBe('paid')
expect(order.paymentChargeId).toBeTruthy()
const status = await getStatus(order)
expect(status.items).toHaveLength(2)
const pizzaItem = status.items.find(
(item) => item.menuItemName === 'Pizza',
)!
// Only one prep station, but the pizza is the only thing waiting for
// it, so its first Step starts immediately.
expect(pizzaItem.steps[0].status).toBe('in_progress')
expect(pizzaItem.cancellable).toBe(false)
const burgerItem = status.items.find(
(item) => item.menuItemName === 'Burger',
)!
expect(burgerItem.steps[0].status).toBe('in_progress')
expect(burgerItem.steps[1].status).toBe('pending')
})
it('does not let a second Order Item jump the queue ahead of one already assigned to the only Machine of that Tag', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const secondOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const firstStatus = await getStatus(order)
const secondStatus = await getStatus(secondOrder)
expect(firstStatus.items[0].steps[0].status).toBe('in_progress')
expect(secondStatus.items[0].steps[0].status).toBe('queued')
expect(secondStatus.items[0].steps[0].queuePosition).toBe(1)
expect(secondStatus.items[0].cancellable).toBe(true)
})
it('refunds the charge if the DB transaction fails after payment has already gone through', async () => {
let capturedChargeId: string | undefined
const spyingPayment: typeof payment = {
...payment,
charge: async (amountCents) => {
const charge = await payment.charge(amountCents)
capturedChargeId = charge.chargeId
return charge
},
}
// Pre-charge validation still runs against the real DB (so it passes
// normally); only the transaction itself is forced to fail, simulating
// an unexpected DB error after the customer has already been charged.
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after payment')
}
await expect(
ordering.placeOrder(dbWithFailingTransaction, spyingPayment, {
items: [{ menuItemId: seed.menuItems.pizza.id }],
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
}),
).rejects.toThrow('simulated DB failure after payment')
expect(capturedChargeId).toBeTruthy()
expect(payment.refundsFor(capturedChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
})
it('surfaces the original transaction failure even if the compensating refund also fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const failingRefundPayment: typeof payment = {
...payment,
refund: async () => {
throw new Error('simulated payment provider outage')
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after payment')
}
await expect(
ordering.placeOrder(dbWithFailingTransaction, failingRefundPayment, {
items: [{ menuItemId: seed.menuItems.pizza.id }],
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
}),
).rejects.toThrow('simulated DB failure after payment')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('cancelling an Order Item', () => {
it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
{ menuItemId: seed.menuItems.burger.id },
])
const before = await getStatus(order)
const pizzaItem = before.items.find(
(item) => item.menuItemName === 'Pizza',
)!
const burgerItem = before.items.find(
(item) => item.menuItemName === 'Burger',
)!
expect(pizzaItem.status).toBe('paid') // still queued, prep station is busy
expect(burgerItem.steps[0].status).toBe('in_progress') // grill was free
await ordering.cancelOrderItem(
testDb.db,
payment,
pizzaItem.id,
accessFor(order),
)
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
const after = await getStatus(order)
const cancelledPizza = after.items.find(
(item) => item.menuItemName === 'Pizza',
)!
const untouchedBurger = after.items.find(
(item) => item.menuItemName === 'Burger',
)!
expect(cancelledPizza.status).toBe('cancelled')
expect(untouchedBurger.steps[0].status).toBe('in_progress')
})
it('completes the order once its only item is cancelled', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
await ordering.cancelOrderItem(
testDb.db,
payment,
(await getStatus(order)).items[0].id,
accessFor(order),
)
const after = await getStatus(order)
expect(after.status).toBe('complete')
})
it('refuses to cancel an item that has already started cooking, with a message reflecting that', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const status = await getStatus(order)
const pizzaItem = status.items[0]
expect(pizzaItem.steps[0].status).toBe('in_progress')
await expect(
ordering.cancelOrderItem(
testDb.db,
payment,
pizzaItem.id,
accessFor(order),
),
).rejects.toThrow(/already started cooking/)
})
it('rejects a cancellation with the wrong access token', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const status = await getStatus(order)
await expect(
ordering.cancelOrderItem(testDb.db, payment, status.items[0].id, {
guestAccessToken: 'not-the-real-token',
}),
).rejects.toThrow(ordering.ForbiddenError)
})
it('recharges the customer if the DB transaction fails after the refund has already gone through', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const itemId = (await getStatus(order)).items[0].id
let rechargedAmountCents: number | undefined
const spyingPayment: typeof payment = {
...payment,
charge: async (amountCents) => {
rechargedAmountCents = amountCents
return payment.charge(amountCents)
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after refund')
}
await expect(
ordering.cancelOrderItem(
dbWithFailingTransaction,
spyingPayment,
itemId,
accessFor(order),
),
).rejects.toThrow('simulated DB failure after refund')
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
expect(rechargedAmountCents).toBe(seed.menuItems.pizza.priceCents)
})
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only prep station
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const itemId = (await getStatus(order)).items[0].id
const failingRechargePayment: typeof payment = {
...payment,
charge: async () => {
throw new Error('simulated payment provider outage')
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after refund')
}
await expect(
ordering.cancelOrderItem(
dbWithFailingTransaction,
failingRechargePayment,
itemId,
accessFor(order),
),
).rejects.toThrow('simulated DB failure after refund')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('gives an accurate message for a double-cancel', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const itemId = (await getStatus(order)).items[0].id
await ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order))
await expect(
ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order)),
).rejects.toThrow(/already been cancelled/)
})
})
describe('advancing steps and pickup', () => {
it('moves a multi-step item through each step, freeing the machine, and completes the order once every item is picked up independently', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
let status = await getStatus(order)
const burgerItemId = status.items[0].id
expect(status.items[0].steps[0].status).toBe('in_progress')
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
status = await getStatus(order)
expect(status.items[0].steps[0].status).toBe('done')
expect(status.items[0].steps[1].status).toBe('in_progress')
expect(status.items[0].status).toBe('paid')
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
status = await getStatus(order)
expect(status.items[0].steps[1].status).toBe('done')
expect(status.items[0].status).toBe('ready')
await ordering.markOrderItemPickedUp(
testDb.db,
burgerItemId,
accessFor(order),
)
status = await getStatus(order)
expect(status.items[0].status).toBe('picked_up')
expect(status.status).toBe('complete')
})
it('assigns a freed machine to the next queued step for the same Tag', async () => {
const firstOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const secondOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
let firstStatus = await getStatus(firstOrder)
const firstItemId = firstStatus.items[0].id
let secondStatus = await getStatus(secondOrder)
expect(secondStatus.items[0].steps[0].status).toBe('queued')
await ordering.advanceOrderItemStep(testDb.db, firstItemId)
// The freed prep station goes to whichever queued Step has been
// waiting longest for that Tag — the second order's first Step, which
// was already queued, rather than the first order's own next Step,
// which only just joined the queue.
firstStatus = await getStatus(firstOrder)
expect(firstStatus.items[0].steps[0].status).toBe('done')
expect(firstStatus.items[0].steps[1].status).toBe('queued')
secondStatus = await getStatus(secondOrder)
expect(secondStatus.items[0].steps[0].status).toBe('in_progress')
})
it('refuses to mark an item picked up before it is ready', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const status = await getStatus(order)
await expect(
ordering.markOrderItemPickedUp(
testDb.db,
status.items[0].id,
accessFor(order),
),
).rejects.toThrow(ordering.OrderItemNotReadyError)
})
})
describe('recipe snapshot', () => {
it("keeps an Order Item on the Recipe as it existed at order time, even if the Menu Item's live Recipe is edited afterwards", async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const burgerSecondStepEdit = and(
eq(schema.menuItemSteps.menuItemId, seed.menuItems.burger.id),
eq(schema.menuItemSteps.sequence, 2),
)
// Edit the live Recipe after the Order was placed: swap the burger's
// second Step to a different Machine Tag and duration.
await testDb.db
.update(schema.menuItemSteps)
.set({
machineTagId: seed.machineTags.oven.id,
estimatedDurationSeconds: 999,
})
.where(burgerSecondStepEdit)
try {
const beforeAdvance = await getStatus(order)
const burgerItemId = beforeAdvance.items[0].id
const pendingStep = beforeAdvance.items[0].steps[1]
expect(pendingStep.status).toBe('pending')
expect(pendingStep.machineTag).toBe('assembly')
expect(pendingStep.estimatedDurationSeconds).toBe(90)
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
const afterAdvance = await getStatus(order)
const secondStep = afterAdvance.items[0].steps[1]
expect(secondStep.status).toBe('in_progress')
expect(secondStep.machineTag).toBe('assembly')
expect(secondStep.estimatedDurationSeconds).toBe(90)
} finally {
await testDb.db
.update(schema.menuItemSteps)
.set({
machineTagId: seed.machineTags.assembly.id,
estimatedDurationSeconds: 90,
})
.where(burgerSecondStepEdit)
}
})
})
describe('access control', () => {
it('refuses to show order status to someone without the access token', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
await expect(
ordering.getOrderStatus(testDb.db, order.id, {}),
).rejects.toThrow(ordering.ForbiddenError)
})
})
describe('order history', () => {
it("lists a signed-in customer's own Orders, most recent first, with per-item quantities, total, and no guest access token", async () => {
await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
await placeAccountOrder(
[
{ menuItemId: seed.menuItems.pizza.id },
{ menuItemId: seed.menuItems.pizza.id },
],
'test-customer-1',
)
const orders = await ordering.getOrdersForCustomer(
testDb.db,
'test-customer-1',
)
expect(orders).toHaveLength(2)
expect(orders[0].items).toEqual([{ menuItemName: 'Pizza', quantity: 2 }])
expect(orders[0].totalCents).toBe(seed.menuItems.pizza.priceCents * 2)
expect(orders[0].guestAccessToken).toBeNull()
expect(orders[1].items).toEqual([{ menuItemName: 'Burger', quantity: 1 }])
})
it("does not include another customer's Orders", async () => {
await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'someone-else',
)
const orders = await ordering.getOrdersForCustomer(
testDb.db,
'test-customer-1',
)
expect(orders).toEqual([])
})
it("looks up a guest's Orders by their remembered (orderId, token) pairs, skipping any with the wrong token", async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const withCorrectToken = await ordering.getGuestOrderSummaries(
testDb.db,
[{ orderId: order.id, guestAccessToken: order.guestAccessToken! }],
)
expect(withCorrectToken).toHaveLength(1)
expect(withCorrectToken[0].guestAccessToken).toBe(order.guestAccessToken)
expect(withCorrectToken[0].items).toEqual([
{ menuItemName: 'Burger', quantity: 1 },
])
const withWrongToken = await ordering.getGuestOrderSummaries(testDb.db, [
{ orderId: order.id, guestAccessToken: 'not-the-real-token' },
])
expect(withWrongToken).toEqual([])
})
})