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) }) 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)) 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(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 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) // 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 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) }) }) 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([]) }) })