Implement ordering MVP domain layer per spec (issue #1)
Scaffolds the TanStack Start + Drizzle/Postgres + Better Auth app and implements the core ordering domain: menu browsing, guest/account order placement with online payment, per-Order-Item independent cooking and cancellation, dynamic Machine-Tag pool assignment, and multi-step recipes (see docs/adr/0001-0003). Adds a guest-access-token / session-based ownership check on every read/mutation so an order can't be viewed, cancelled, or picked up by anyone but its owner. Covered by 14 tests running the domain layer directly against a real, ephemeral Postgres (testcontainers) — createServerFn couldn't be called outside a real request context, so tests exercise the domain functions the server functions thinly wrap instead of the server functions themselves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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<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.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('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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user