Introduces a role-gated /admin section (sidebar layout, requireAdmin checked both in route loaders and independently in every admin server function) with three sections: - Orders: every order across all customers, and an admin-only order detail view nested under /admin so the sidebar persists and admin access bypasses the guest-token check. - Menu: create/edit/delete menu items, their options, and their preparation steps; deleting an item with past orders is refused (protected by the orderItems FK) instead of crashing. - Kitchen: read-only live view of every machine's busy/idle status and per-tag queue depth, with a manual refresh. Also: - Adds a `role` field to the user table (Better Auth additionalFields, input:false so it can't be self-assigned) and a db:seed-admin script to create/promote an admin account. - Fixes a real bug found while building this: src/server/auth.ts's shared session helpers were plain exported functions using a server-only import, which the framework couldn't tree-shake out of the client bundle once shared across multiple server/*.ts modules — this silently broke hydration app-wide. Fixed with createServerOnlyFn. - Removes the now-fully-unused @tanstack/devtools-vite plugin. - Disables view transitions for in-admin navigation so the persistent sidebar doesn't cross-fade/shift on every click. - Gives the "your orders" empty state the same visual treatment as the basket page's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
774 lines
25 KiB
TypeScript
774 lines
25 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)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
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([])
|
|
})
|
|
})
|