From 4e7628dbe2b4c64f4f197d143444578ae27ffeeb Mon Sep 17 00:00:00 2001 From: devinderstockholm3219gmailcom Date: Mon, 22 Jun 2026 18:58:59 +0300 Subject: [PATCH] fix(order): subscribe to kitchen events so order status reflects reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order service never consumed order.accepted/order.ready, so its own row (the one GET /orders/:id actually reads) stayed "placed" forever even after the kitchen had accepted and readied the order. Kitchen only ever updated its own database, never order's. Added a consumer (order-events.ts, same extraction pattern as the outbox poller) that updates order's row on both events, with its own Redis idempotency namespace. Also strengthened the e2e test to assert the real public /orders/:id endpoint reaches "ready", not just kitchen's internal database — that's what should have caught this originally. --- services/order/src/db.ts | 12 ++++++++++++ services/order/src/index.ts | 9 ++++++--- services/order/src/order-events.ts | 23 +++++++++++++++++++++++ tests/e2e/order-flow.test.ts | 15 +++++++++++++-- 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 services/order/src/order-events.ts diff --git a/services/order/src/db.ts b/services/order/src/db.ts index 5409c32..59e64fc 100644 --- a/services/order/src/db.ts +++ b/services/order/src/db.ts @@ -134,6 +134,18 @@ export async function markOutboxPublished(id: string): Promise { await sql`UPDATE outbox SET published_at = now() WHERE id = ${id}`; } +// Step 4a: Kitchen's acceptance/readiness only ever updated kitchen's OWN +// database (kitchen_db) — never order's. Without these, GET /orders/:id +// would show "placed" forever, since that's the only row order ever +// writes to its own table. +export async function markOrderAccepted(orderId: string): Promise { + await sql`UPDATE orders SET status = 'accepted' WHERE order_id = ${orderId}`; +} + +export async function markOrderReady(orderId: string): Promise { + await sql`UPDATE orders SET status = 'ready' WHERE order_id = ${orderId}`; +} + // Step 4: Read an order back with its items for GET /orders/:id. // Returns null on a miss so the route layer can map that to a 404. export async function getOrderById(orderId: string): Promise { diff --git a/services/order/src/index.ts b/services/order/src/index.ts index 2c5e386..bc31569 100644 --- a/services/order/src/index.ts +++ b/services/order/src/index.ts @@ -1,16 +1,19 @@ import { connect } from "@quickbite/shared"; import { buildServer } from "./server.ts"; import { startOutboxPoller } from "./outbox.ts"; +import { startOrderEventConsumer } from "./order-events.ts"; const PORT = Number(process.env.PORT ?? 3002); const RABBITMQ_URL = process.env.RABBITMQ_URL ?? "amqp://guest:guest@localhost:5672"; // Connect to RabbitMQ (the poller needs the channel), start the poller, -// build the server, then start listening. The HTTP server and the poller -// run concurrently for the lifetime of this process. +// subscribe to kitchen's events so order's OWN row reflects the real +// status (kitchen only ever updates its own database otherwise), build the +// server, then start listening. connect(RABBITMQ_URL) - .then(({ channel }) => { + .then(async ({ channel }) => { startOutboxPoller(channel); + await startOrderEventConsumer(channel); return buildServer(); }) .then((server) => server.listen({ port: PORT, host: "0.0.0.0" })) diff --git a/services/order/src/order-events.ts b/services/order/src/order-events.ts new file mode 100644 index 0000000..71e0b4e --- /dev/null +++ b/services/order/src/order-events.ts @@ -0,0 +1,23 @@ +import { consume, EventName, RedisIdempotency, type OrderAccepted, type OrderReady, type ResilientChannel } from "@quickbite/shared"; +import { markOrderAccepted, markOrderReady } from "./db.ts"; + +// Kitchen owns the cooking workflow but only ever writes to its OWN +// database (kitchen_db). Without subscribing to its events here, order's +// own row — the one GET /orders/:id actually reads — would stay "placed" +// forever. Extracted into its own function (same pattern as +// startOutboxPoller) so both index.ts and tests can wire it up identically. +export async function startOrderEventConsumer(channel: ResilientChannel): Promise { + const idem = new RedisIdempotency("order"); + + await consume( + channel, + { queue: "order.order-events", routingKeys: [EventName.OrderAccepted, EventName.OrderReady] }, + async (event) => { + const e = event as OrderAccepted | OrderReady; + if (await idem.alreadyProcessed(e.eventId)) return; + + if (e.type === EventName.OrderAccepted) await markOrderAccepted(e.orderId); + else await markOrderReady(e.orderId); + }, + ); +} diff --git a/tests/e2e/order-flow.test.ts b/tests/e2e/order-flow.test.ts index 414763b..4dcb1bf 100644 --- a/tests/e2e/order-flow.test.ts +++ b/tests/e2e/order-flow.test.ts @@ -42,6 +42,7 @@ beforeAll(async () => { process.env.DATABASE_URL = dbUrl(postgresC, "order_db"); const orderServerModule = await import("../../services/order/src/server.ts"); const orderOutbox = await import("../../services/order/src/outbox.ts"); + const orderEvents = await import("../../services/order/src/order-events.ts"); process.env.DATABASE_URL = dbUrl(postgresC, "kitchen_db"); process.env.PORT = "0"; @@ -51,10 +52,12 @@ beforeAll(async () => { process.env.PORT = "0"; await import("../../services/notification/src/index.ts"); - // Step 3: order's own outbox poller needs its own RabbitMQ channel, - // exactly like its real startup chain in index.ts. + // Step 3: order's own outbox poller AND its event consumer need their own + // RabbitMQ channel, exactly like its real startup chain in index.ts. + process.env.DATABASE_URL = dbUrl(postgresC, "order_db"); const { channel } = await connect(rabbitmq.getAmqpUrl()); orderOutbox.startOutboxPoller(channel, 200); + await orderEvents.startOrderEventConsumer(channel); orderServer = await orderServerModule.buildServer(); }, 120_000); @@ -101,4 +104,12 @@ test("place order -> kitchen accepts and readies -> notification logs all 3 even expect(kitchenRow?.status).toBe("ready"); expect(kitchenRow?.readyAt).not.toBeNull(); expect(notifications.map((n) => n.eventType).sort()).toEqual(["order.accepted", "order.placed", "order.ready"]); + + // Step 6: The actual customer-facing check — order's OWN status via the + // real public endpoint, not an internal database. This is what would have + // caught order never having subscribed to kitchen's events: kitchen + // reaching "ready" in ITS database proves nothing about what a customer + // polling GET /orders/:id would actually see. + const statusResponse = await orderServer.inject({ method: "GET", url: `/orders/${orderId}` }); + expect(statusResponse.json().status).toBe("ready"); });