Files
Ghost_Kitchen/tests/ordering.test.ts
T
lukasandClaude Sonnet 5 506319ce4f Refund before cancelling, not after, with compensation on failure
cancelOrderItem committed the cancellation (freeing the Machine slot,
marking the item cancelled) before calling the payment provider's
refund — so a refund failure (gateway timeout, provider outage) left
the item cancelled with no refund ever issued, and no record that one
was owed. placeOrder already avoids the equivalent problem by charging
before its DB transaction and refunding if the transaction then fails;
apply the same pattern here for consistency.

Refund now happens first; the DB transaction's atomic queued-status
check (added for the earlier cancellation race) doubles as the signal
that the refund needs to be undone if this transaction loses the race
or otherwise fails, so the catch block re-charges to compensate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:01:55 +02:00

436 lines
14 KiB
TypeScript

import { and, eq } from 'drizzle-orm'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} 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' },
})
// 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, machineTag: 'oven', estimatedDurationSeconds: 600 },
])
const burger = menu.find((item) => item.name === 'Burger')
expect(burger?.steps).toEqual([
{ sequence: 1, machineTag: 'grill', estimatedDurationSeconds: 240 },
{ sequence: 2, 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 oven, but the pizza is the only thing waiting for it, so it
// 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,
)
})
})
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 oven
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, oven 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 oven
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 oven
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('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)
firstStatus = await getStatus(firstOrder)
expect(firstStatus.items[0].status).toBe('ready')
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)
})
})