A ready Order Item now gets the lowest-numbered free Pickup Box (or waits for one to free up), shown as "Pickup box N" on its status. The customer opens the box from their order and closes it again; closing marks the item picked up and hands the box to the next waiting item. On the order card the box button comes first, full width on mobile, with a padlock that lifts on open and snaps shut before the button collapses away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1042 lines
33 KiB
TypeScript
1042 lines
33 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)
|
|
await testDb.db.update(schema.pickupBoxes).set({ isOpen: false })
|
|
})
|
|
|
|
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))
|
|
|
|
const pickUp = async (orderItemId: number, access: ordering.OrderAccess) => {
|
|
await ordering.openPickupBox(testDb.db, orderItemId, access)
|
|
await ordering.closePickupBox(testDb.db, orderItemId, access)
|
|
}
|
|
|
|
// The burger's two Steps each start straight away while no other Order is
|
|
// cooking, so advancing twice leaves it ready.
|
|
const placeReadyBurger = async () => {
|
|
const order = await placeGuestOrder([
|
|
{ menuItemId: seed.menuItems.burger.id },
|
|
])
|
|
const itemId = (await getStatus(order)).items[0].id
|
|
await ordering.advanceOrderItemStep(testDb.db, itemId)
|
|
await ordering.advanceOrderItemStep(testDb.db, itemId)
|
|
return { order, itemId }
|
|
}
|
|
|
|
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 pickUp(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 open a pickup box before the item is ready', async () => {
|
|
const order = await placeGuestOrder([
|
|
{ menuItemId: seed.menuItems.pizza.id },
|
|
])
|
|
const status = await getStatus(order)
|
|
|
|
await expect(
|
|
ordering.openPickupBox(testDb.db, status.items[0].id, accessFor(order)),
|
|
).rejects.toThrow(ordering.OrderItemNotReadyError)
|
|
})
|
|
})
|
|
|
|
describe('pickup boxes', () => {
|
|
it('puts a ready item in the lowest-numbered free box', async () => {
|
|
const { order } = await placeReadyBurger()
|
|
|
|
const [item] = (await getStatus(order)).items
|
|
expect(item.pickupBox).toEqual({ number: 1, isOpen: false })
|
|
})
|
|
|
|
it('picks the item up once the customer opens and closes its box', async () => {
|
|
const { order, itemId } = await placeReadyBurger()
|
|
|
|
await ordering.openPickupBox(testDb.db, itemId, accessFor(order))
|
|
expect((await getStatus(order)).items[0].pickupBox).toEqual({
|
|
number: 1,
|
|
isOpen: true,
|
|
})
|
|
|
|
await ordering.closePickupBox(testDb.db, itemId, accessFor(order))
|
|
const status = await getStatus(order)
|
|
expect(status.items[0].status).toBe('picked_up')
|
|
expect(status.items[0].pickupBox).toBeNull()
|
|
expect(status.status).toBe('complete')
|
|
})
|
|
|
|
it("refuses to close a box that hasn't been opened", async () => {
|
|
const { order, itemId } = await placeReadyBurger()
|
|
|
|
await expect(
|
|
ordering.closePickupBox(testDb.db, itemId, accessFor(order)),
|
|
).rejects.toThrow(ordering.PickupBoxNotOpenError)
|
|
expect((await getStatus(order)).items[0].status).toBe('ready')
|
|
})
|
|
|
|
it('makes a ready item wait for a box while all are taken, then gives it the first one freed', async () => {
|
|
const filling = []
|
|
for (const _ of seed.pickupBoxes) filling.push(await placeReadyBurger())
|
|
const waiting = await placeReadyBurger()
|
|
|
|
expect((await getStatus(waiting.order)).items[0].pickupBox).toBeNull()
|
|
await expect(
|
|
ordering.openPickupBox(
|
|
testDb.db,
|
|
waiting.itemId,
|
|
accessFor(waiting.order),
|
|
),
|
|
).rejects.toThrow(ordering.NoPickupBoxYetError)
|
|
|
|
const boxTwo = filling[1]
|
|
await pickUp(boxTwo.itemId, accessFor(boxTwo.order))
|
|
|
|
expect((await getStatus(waiting.order)).items[0].pickupBox).toEqual({
|
|
number: 2,
|
|
isOpen: false,
|
|
})
|
|
})
|
|
})
|
|
|
|
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 pickUp(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)
|
|
})
|
|
})
|