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> let payment: ReturnType 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 => ({ 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) }) })