Files
Ghost_Kitchen/tests/ordering.test.ts
T
lukasandClaude Opus 5.5 ac2aa770fb Make "Buy again" add the item to the basket
The order status view now carries each item's menu item id and the ids of
its current Options matching the ordered ones by name, since editing a
Menu Item recreates its Options under new ids. Options no longer on the
menu are left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:07:13 +02:00

975 lines
31 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.orderItemOptions)
await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders)
})
const placeGuestOrder = (items: ordering.PlaceOrderInput['items']) =>
ordering.placeOrder(testDb.db, payment, {
items,
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
})
const placeAccountOrder = (
items: ordering.PlaceOrderInput['items'],
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,
},
])
})
it("lists each menu item's customization Options", async () => {
const menu = await ordering.getMenu(testDb.db, seed.brand.id)
const pizza = menu.find((item) => item.name === 'Pizza')
expect(pizza?.options).toEqual(
seed.menuItemOptions.pizza.map((option) => ({
id: option.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
const burger = menu.find((item) => item.name === 'Burger')
expect(burger?.options).toEqual(
seed.menuItemOptions.burger.map((option) => ({
id: option.id,
name: option.name,
priceDeltaCents: option.priceDeltaCents,
})),
)
})
})
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('customizing an item', () => {
it('charges the adjusted price for selected Options and snapshots them on the Order Item', async () => {
const extraCheese = seed.menuItemOptions.pizza.find(
(option) => option.name === 'Extra cheese',
)!
const noOnions = seed.menuItemOptions.pizza.find(
(option) => option.name === 'No onions',
)!
let chargedAmountCents: number | undefined
const spyingPayment: typeof payment = {
...payment,
charge: async (amountCents) => {
chargedAmountCents = amountCents
return payment.charge(amountCents)
},
}
const order = await ordering.placeOrder(testDb.db, spyingPayment, {
items: [
{
menuItemId: seed.menuItems.pizza.id,
selectedOptionIds: [extraCheese.id, noOnions.id],
},
],
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
})
expect(chargedAmountCents).toBe(
seed.menuItems.pizza.priceCents + extraCheese.priceDeltaCents,
)
const status = await getStatus(order)
expect(status.items[0].options).toEqual(
expect.arrayContaining([
{ name: 'Extra cheese', priceDeltaCents: 150 },
{ name: 'No onions', priceDeltaCents: 0 },
]),
)
expect(status.items[0].options).toHaveLength(2)
})
it('places an unmodified Order Item with no Options selected', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const status = await getStatus(order)
expect(status.items[0].options).toEqual([])
})
it("rejects an Option that doesn't belong to the selected menu item", async () => {
const burgerOption = seed.menuItemOptions.burger[0]
await expect(
placeGuestOrder([
{
menuItemId: seed.menuItems.pizza.id,
selectedOptionIds: [burgerOption.id],
},
]),
).rejects.toThrow(/not valid for menu item/)
})
it('refunds the full customized price on cancellation', async () => {
const extraPatty = seed.menuItemOptions.burger.find(
(option) => option.name === 'Extra patty',
)!
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
const order = await placeGuestOrder([
{
menuItemId: seed.menuItems.burger.id,
selectedOptionIds: [extraPatty.id],
},
])
const itemId = (await getStatus(order)).items[0].id
await ordering.cancelOrderItem(testDb.db, payment, itemId, accessFor(order))
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
seed.menuItems.burger.priceCents + extraPatty.priceDeltaCents,
)
})
})
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(cancelledPizza.cancelledBy).toBe('customer')
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('kitchen simulation', () => {
const policy = (
outcome: ReturnType<ordering.KitchenSimulationPolicy['pickOutcome']>,
): ordering.KitchenSimulationPolicy => ({
stepDurationMs: (step) => (60 + step.delaySeconds) * 1000,
pickOutcome: () => outcome,
})
const inAMinute = () => new Date(Date.now() + 60_000)
it("finishes elapsed steps across every Order, unblocking an item queued behind someone else's", async () => {
const blockingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const waitingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'queued',
)
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'finish' }),
)
expect((await getStatus(blockingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'finish' }),
inAMinute(),
)
expect((await getStatus(blockingOrder)).items[0].steps[0].status).toBe(
'done',
)
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
})
it('keeps a delayed step in progress and adds the delay to the ETA', async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const before = (await getStatus(order)).items[0]
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'delay', delaySeconds: 180 }),
inAMinute(),
)
const after = (await getStatus(order)).items[0]
expect(after.steps[0].status).toBe('in_progress')
expect(after.etaSeconds).toBeGreaterThanOrEqual(before.etaSeconds! + 170)
})
it('lets the kitchen cancel an item mid-step, refunding it and freeing its machine', async () => {
const cancelledOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const waitingOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
await ordering.runKitchenSimulationTick(
testDb.db,
payment,
policy({ type: 'cancel' }),
inAMinute(),
)
const cancelledItem = (await getStatus(cancelledOrder)).items[0]
expect(cancelledItem.status).toBe('cancelled')
expect(cancelledItem.cancelledBy).toBe('kitchen')
expect(payment.refundsFor(cancelledOrder.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
expect((await getStatus(cancelledOrder)).status).toBe('complete')
expect((await getStatus(waitingOrder)).items[0].steps[0].status).toBe(
'in_progress',
)
})
})
describe('buying an item again', () => {
const pizzaOption = (name: string) =>
seed.menuItemOptions.pizza.find((option) => option.name === name)!
it("offers the Menu Item's current Options matching the ones ordered", async () => {
const order = await placeGuestOrder([
{
menuItemId: seed.menuItems.pizza.id,
selectedOptionIds: [
pizzaOption('Extra cheese').id,
pizzaOption('No onions').id,
],
},
])
const [item] = (await getStatus(order)).items
expect(item.menuItemId).toBe(seed.menuItems.pizza.id)
expect(item.buyAgainOptionIds).toEqual(
expect.arrayContaining([
pizzaOption('Extra cheese').id,
pizzaOption('No onions').id,
]),
)
expect(item.buyAgainOptionIds).toHaveLength(2)
})
it('leaves out an Option that is no longer on the menu', async () => {
const order = await placeGuestOrder([
{
menuItemId: seed.menuItems.pizza.id,
selectedOptionIds: [
pizzaOption('Extra cheese').id,
pizzaOption('No onions').id,
],
},
])
const noOnionsEdit = eq(
schema.menuItemOptions.id,
pizzaOption('No onions').id,
)
await testDb.db
.update(schema.menuItemOptions)
.set({ name: 'Extra onions' })
.where(noOnionsEdit)
try {
const [item] = (await getStatus(order)).items
expect(item.buyAgainOptionIds).toEqual([pizzaOption('Extra cheese').id])
} finally {
await testDb.db
.update(schema.menuItemOptions)
.set({ name: 'No onions' })
.where(noOnionsEdit)
}
})
})
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)
})
it('lets an admin view any Order, guest or account, without a token', async () => {
const guestOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const accountOrder = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
await expect(
ordering.getOrderStatus(testDb.db, guestOrder.id, { isAdmin: true }),
).resolves.toMatchObject({ id: guestOrder.id })
await expect(
ordering.getOrderStatus(testDb.db, accountOrder.id, { isAdmin: true }),
).resolves.toMatchObject({ id: accountOrder.id })
})
})
describe('admin order listing', () => {
it("lists every Order across every customer, with each one's placer", async () => {
const guestOrder = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const accountOrder = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
const orders = await ordering.getAllOrders(testDb.db)
expect(orders).toHaveLength(2)
const guestSummary = orders.find((order) => order.id === guestOrder.id)
const accountSummary = orders.find((order) => order.id === accountOrder.id)
expect(guestSummary?.customer).toEqual({
type: 'guest',
name: 'Ada',
contact: 'ada@example.com',
})
expect(accountSummary?.customer).toEqual({
type: 'account',
customerId: 'test-customer-1',
})
})
})
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([])
})
it("reports an Order's progress from its items as they cook and get picked up", async () => {
const order = await placeAccountOrder(
[{ menuItemId: seed.menuItems.burger.id }],
'test-customer-1',
)
const progress = async () =>
(await ordering.getOrdersForCustomer(testDb.db, 'test-customer-1'))[0]
.progress
expect(await progress()).toBe('preparing')
const access = { customerId: 'test-customer-1' }
const itemId = (await ordering.getOrderStatus(testDb.db, order.id, access))
.items[0].id
await ordering.advanceOrderItemStep(testDb.db, itemId)
await ordering.advanceOrderItemStep(testDb.db, itemId)
expect(await progress()).toBe('ready')
await ordering.markOrderItemPickedUp(testDb.db, itemId, access)
expect(await progress()).toBe('picked_up')
})
it('reports an Order as not started while its only item waits for a busy machine', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }])
await placeAccountOrder(
[{ menuItemId: seed.menuItems.pizza.id }],
'test-customer-1',
)
const [order] = await ordering.getOrdersForCustomer(
testDb.db,
'test-customer-1',
)
expect(order.progress).toBe('not_started')
})
const paid = { status: 'paid', started: true } as const
const waiting = { status: 'paid', started: false } as const
const ready = { status: 'ready', started: true } as const
const pickedUp = { status: 'picked_up', started: true } as const
const cancelled = { status: 'cancelled', started: false } as const
it.each([
[[waiting, waiting], 'not_started'],
[[waiting, cancelled], 'not_started'],
[[waiting, paid], 'preparing'],
[[pickedUp, waiting], 'preparing'],
[[ready, waiting], 'partly_ready'],
[[ready, pickedUp], 'ready'],
[[cancelled, pickedUp], 'picked_up'],
[[cancelled, cancelled], 'cancelled'],
] as const)('derives progress from %j as %s', (items, expected) => {
expect(ordering.getOrderProgress([...items])).toBe(expected)
})
})