import { and, eq } from 'drizzle-orm' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, } from 'vitest' import * as schema from '#/db/schema.ts' import * as ordering from '#/domain/ordering.ts' import { createFakePaymentProvider } from '#/lib/payment.ts' import { seedGhostKitchen } from '#/db/seed.ts' import { startTestDatabase } from './helpers/test-db.ts' import type { TestDatabase } from './helpers/test-db.ts' let testDb: TestDatabase let seed: Awaited> 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.orderItems) await testDb.db.delete(schema.orders) }) const placeGuestOrder = (items: Array<{ menuItemId: number }>) => ordering.placeOrder(testDb.db, payment, { items, customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' }, }) // Tests act as the guest who placed the order, so they present its access // token exactly like a real caller would — this exercises the same // ownership check a stranger guessing the order id would fail. const accessFor = (order: { guestAccessToken: string | null }) => ({ guestAccessToken: order.guestAccessToken ?? undefined, }) const getStatus = (order: { id: number; guestAccessToken: string | null }) => ordering.getOrderStatus(testDb.db, order.id, accessFor(order)) describe('menu', () => { it('lists menu items for a brand with their recipe steps', async () => { const menu = await ordering.getMenu(testDb.db, seed.brand.id) const pizza = menu.find((item) => item.name === 'Pizza') expect(pizza?.steps).toEqual([ { sequence: 1, machineTag: 'oven', estimatedDurationSeconds: 600 }, ]) const burger = menu.find((item) => item.name === 'Burger') expect(burger?.steps).toEqual([ { sequence: 1, machineTag: 'grill', estimatedDurationSeconds: 240 }, { sequence: 2, machineTag: 'assembly', estimatedDurationSeconds: 90 }, ]) }) }) describe('placing an order', () => { it('rejects an order with no items', async () => { await expect(placeGuestOrder([])).rejects.toThrow() }) it('charges the total price and queues the first Preparation Step for each Order Item', async () => { const order = await placeGuestOrder([ { menuItemId: seed.menuItems.pizza.id }, { menuItemId: seed.menuItems.burger.id }, ]) expect(order.status).toBe('paid') expect(order.paymentChargeId).toBeTruthy() const status = await getStatus(order) expect(status.items).toHaveLength(2) const pizzaItem = status.items.find( (item) => item.menuItemName === 'Pizza', )! // Only one oven, but the pizza is the only thing waiting for it, so it // starts immediately. expect(pizzaItem.steps[0].status).toBe('in_progress') expect(pizzaItem.cancellable).toBe(false) const burgerItem = status.items.find( (item) => item.menuItemName === 'Burger', )! expect(burgerItem.steps[0].status).toBe('in_progress') expect(burgerItem.steps[1].status).toBe('pending') }) it('does not let a second Order Item jump the queue ahead of one already assigned to the only Machine of that Tag', async () => { const order = await placeGuestOrder([ { menuItemId: seed.menuItems.pizza.id }, ]) const secondOrder = await placeGuestOrder([ { menuItemId: seed.menuItems.pizza.id }, ]) const firstStatus = await getStatus(order) const secondStatus = await getStatus(secondOrder) expect(firstStatus.items[0].steps[0].status).toBe('in_progress') expect(secondStatus.items[0].steps[0].status).toBe('queued') expect(secondStatus.items[0].steps[0].queuePosition).toBe(1) expect(secondStatus.items[0].cancellable).toBe(true) }) it('refunds the charge if the DB transaction fails after payment has already gone through', async () => { let capturedChargeId: string | undefined const spyingPayment: typeof payment = { ...payment, charge: async (amountCents) => { const charge = await payment.charge(amountCents) capturedChargeId = charge.chargeId return charge }, } // Pre-charge validation still runs against the real DB (so it passes // normally); only the transaction itself is forced to fail, simulating // an unexpected DB error after the customer has already been charged. const dbWithFailingTransaction = Object.create( testDb.db, ) as typeof testDb.db dbWithFailingTransaction.transaction = () => { throw new Error('simulated DB failure after payment') } await expect( ordering.placeOrder(dbWithFailingTransaction, spyingPayment, { items: [{ menuItemId: seed.menuItems.pizza.id }], customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' }, }), ).rejects.toThrow('simulated DB failure after payment') expect(capturedChargeId).toBeTruthy() expect(payment.refundsFor(capturedChargeId!)).toBe( seed.menuItems.pizza.priceCents, ) }) }) describe('cancelling an Order Item', () => { it('cancels and refunds an item that has not started cooking yet, without touching its sibling', async () => { await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven const order = await placeGuestOrder([ { menuItemId: seed.menuItems.pizza.id }, { menuItemId: seed.menuItems.burger.id }, ]) const before = await getStatus(order) const pizzaItem = before.items.find( (item) => item.menuItemName === 'Pizza', )! const burgerItem = before.items.find( (item) => item.menuItemName === 'Burger', )! expect(pizzaItem.status).toBe('paid') // still queued, oven is busy expect(burgerItem.steps[0].status).toBe('in_progress') // grill was free await ordering.cancelOrderItem( testDb.db, payment, pizzaItem.id, accessFor(order), ) expect(payment.refundsFor(order.paymentChargeId!)).toBe( seed.menuItems.pizza.priceCents, ) const after = await getStatus(order) const cancelledPizza = after.items.find( (item) => item.menuItemName === 'Pizza', )! const untouchedBurger = after.items.find( (item) => item.menuItemName === 'Burger', )! expect(cancelledPizza.status).toBe('cancelled') expect(untouchedBurger.steps[0].status).toBe('in_progress') }) it('completes the order once its only item is cancelled', async () => { await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven const order = await placeGuestOrder([ { menuItemId: seed.menuItems.pizza.id }, ]) await ordering.cancelOrderItem( testDb.db, payment, (await getStatus(order)).items[0].id, accessFor(order), ) const after = await getStatus(order) expect(after.status).toBe('complete') }) it('refuses to cancel an item that has already started cooking, with a message reflecting that', async () => { const order = await placeGuestOrder([ { menuItemId: seed.menuItems.pizza.id }, ]) const status = await getStatus(order) const pizzaItem = status.items[0] expect(pizzaItem.steps[0].status).toBe('in_progress') await expect( ordering.cancelOrderItem( testDb.db, payment, pizzaItem.id, accessFor(order), ), ).rejects.toThrow(/already started cooking/) }) it('rejects a cancellation with the wrong access token', async () => { const order = await placeGuestOrder([ { menuItemId: seed.menuItems.burger.id }, ]) const status = await getStatus(order) await expect( ordering.cancelOrderItem(testDb.db, payment, status.items[0].id, { guestAccessToken: 'not-the-real-token', }), ).rejects.toThrow(ordering.ForbiddenError) }) it('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) }) })