Both placeOrder and cancelOrderItem call a compensating payment action (refund / re-charge) after catching a failure, then rethrow. If that compensating call itself threw, its error replaced the original one, hiding the actual root cause behind whatever the payment provider happened to fail with on the second attempt. Wrap each compensation in its own try/catch, log the compensation failure, and always rethrow the original error. Also fixes a stale comment left over from renaming createFirstStepForOrderItem to initializeOrderItemRecipe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
501 lines
16 KiB
TypeScript
501 lines
16 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' },
|
|
})
|
|
|
|
// 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,
|
|
)
|
|
})
|
|
|
|
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 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('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 oven
|
|
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)
|
|
|
|
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)
|
|
})
|
|
})
|