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>
436 lines
14 KiB
TypeScript
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)
|
|
})
|
|
})
|