Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions services/order/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ export async function markOutboxPublished(id: string): Promise<void> {
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<void> {
await sql`UPDATE orders SET status = 'accepted' WHERE order_id = ${orderId}`;
}

export async function markOrderReady(orderId: string): Promise<void> {
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<StoredOrder | null> {
Expand Down
9 changes: 6 additions & 3 deletions services/order/src/index.ts
Original file line number Diff line number Diff line change
@@ -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" }))
Expand Down
23 changes: 23 additions & 0 deletions services/order/src/order-events.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
},
);
}
15 changes: 13 additions & 2 deletions tests/e2e/order-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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");
});
Loading