diff --git a/.claude/CONTEXT.md b/.claude/CONTEXT.md deleted file mode 100644 index 60928b2..0000000 --- a/.claude/CONTEXT.md +++ /dev/null @@ -1,89 +0,0 @@ -# QuickBite — Session Context - -> Claude reads this at the start of every session. Update the "Current state" and "Next step" sections at the end of each phase. - -## Project - -Event-driven food-ordering platform. Graded assignment for Kvalitetssäkring systemnivå (System-Level QA). - -Working directory: `~/quickbite/quickbite/` - -Full plan: `~/.claude/plans/lets-read-the-files-unified-galaxy.md` - -## Build phases - -| Phase | Goal | Status | -|---|---|---| -| 0 | Bootstrap — unzip, git, CLAUDE.md, CONTEXT.md, hooks, Redis in compose | ✅ Done | -| 1a | Postgres init.sql — create per-service databases | ✅ Done | -| 1b | Menu service — Postgres table + Redis cache | ✅ Done | -| 1c | Order service — Postgres tables | ✅ Done | -| 1d | Kitchen service — Postgres table | ✅ Done | -| 1e | Notification service — Postgres table (audit log) | ✅ Done | -| 2 | Outbox pattern — order service | ✅ Done | -| 3 | Redis idempotency — replace in-memory Idempotency class | ✅ Done | -| 4 | Retry-with-backoff in mq.ts | ✅ Done | -| 5 | Testcontainers integration tests | ✅ Done | -| 6 | End-to-end test | ✅ Done | - -## Current state - -**ALL PHASES COMPLETE (0 through 6) — the build plan is finished.** Phase 6 (`tests/e2e/order-flow.test.ts`, new `@quickbite/e2e` workspace package) wires order, kitchen, and notification together in one test: one Postgres container hosting all 3 databases (`order_db`/`kitchen_db`/`notification_db`, created via raw `CREATE DATABASE` statements — mirroring `docker/init.sql`'s shape), one shared RabbitMQ + Redis container, each service's real entrypoint dynamically imported in sequence (same env-var + dynamic-import technique as Phase 5). A real `POST /orders` via `server.inject()` drives the whole chain; the test polls both kitchen's and notification's own databases until the full happy path completes. - -**The one real architectural problem this phase surfaced, and the actual fix**: running `bun test` from the repo root executes every `*.test.ts` file in ALL packages within a SINGLE OS process by default. Since every service's `db.ts` reads `DATABASE_URL` from `process.env` once at module-load time, and Bun caches dynamically-imported modules by absolute path, a later test file calling `await import("../../services/order/src/server.ts")` could receive an already-cached module instance still pointing at an EARLIER test file's (already-stopped) Testcontainers Postgres. Tried Bun's `--isolate` flag (gives each file a fresh module/global context) — it didn't fully fix this, because `process.env` is OS-process-wide regardless of `--isolate`, and it also silently stopped honoring `bunfig.toml`'s test timeout, causing new failures. The actual fix: every package that has tests now has its own `"test": "bun test"` script, and the root script became `bun run --filter '*' --sequential test` — Bun's workspace filter spawns each package's tests as a genuinely separate OS process, which is real isolation, not just module-context isolation. Root `package.json`, every relevant `package.json`, and `.github/workflows/ci.yml`'s test step were updated to match. - -**Also worth knowing for next time**: a `docker upgrade`-adjacent slip — an exploratory `strings $(which bun)`-adjacent command accidentally triggered `bun upgrade --canary`, bumping the global Bun install from `1.3.10` to a `1.4.0-canary` prerelease. Caught immediately and reverted via `bun upgrade --stable` (landed on `1.3.14`, the current stable patch — not byte-identical to `1.3.10`, but the same stable channel). And earlier in Phase 5: a Docker cleanup command filtered by `ancestor=postgres:16` accidentally matched and removed the real, long-running `quickbite-postgres-1` container — no data lost (named volume `pgdata` persisted) but worth remembering: never filter Docker cleanup by image name when other real containers share that image. - -Snyk: 0 issues across every changed package, including `tests/e2e`. Typecheck green (now also covers `tests/e2e`). `bun run test` from the repo root: every package's suite passes (order 4, kitchen 2, notification 2, menu 2, shared 2, e2e 1 — 13 total), confirmed stable across 4 repeated full runs, exit code 0 every time, no orphaned Testcontainers left running (Ryuk reaper confirmed clean each time). - -**Workflow reminder**: every phase gets its own branch off `main`, pushed with a PR via `gh pr create` — never commit straight to `main`. No Claude/Anthropic references in commits, PRs, or files (user's explicit preference). Repo: https://github.com/devinder-dev/quickbite (public). - -## Fix-up pass: exam-criteria gaps (branch `fix-exam-criteria`) - -After all 6 phases were done, a pass comparing the system against the actual examination brief (single public entry point, README accuracy, pure unit tests for logic) surfaced and fixed: - -1. **Single public entry point lockdown** — `docker-compose.yml` no longer publishes host ports for `postgres`, `redis`, `rabbitmq`, or `gateway`; nginx is now `80:80` (was `8080:80`). A new `docker-compose.dev.yml` (NOT auto-merged — must be passed explicitly via `-f`) restores host ports for `postgres`/`redis`/`rabbitmq` for local debugging only. Verified empirically: `docker compose ps` shows only nginx with a port mapping, internal ports closed to the host, full order flow works through nginx alone, dev override restores infra ports without exposing the gateway. -2. **README rewritten** to match the actual built system and state the single entry point (`http://localhost`, port 80, via nginx) explicitly. -3. **Two pure unit tests added**, satisfying the brief's explicit (separate from integration/e2e) unit-testing requirement: `services/order/src/pricing.ts` (`computeTotalCents`, extracted out of `server.ts`'s route handler) and `packages/shared/src/mq.ts`'s `backoffMs` (now exported). Cache-aside and Redis-NX idempotency logic deliberately left to existing integration tests, not forced into mocks. -4. **Real bug found and fixed — gateway proxy was completely broken.** `@fastify/http-proxy` (via `@fastify/reply-from`) forwards through a Node-style undici `Pool` whose `.request()` Bun's runtime doesn't implement compatibly — every proxied call threw `pool.request is not a function`. This had never been caught because nothing had tested the real flow through nginx until this pass. Fixed by hand-rolling the proxy in `services/gateway/src/index.ts` with Bun's native `fetch()`; removed `@fastify/http-proxy` from `services/gateway/package.json`. Verified end-to-end through the real Docker stack (menu read, place order, order status, 404, full kitchen/notification event chain). -5. **Second real bug found and fixed — `RedisIdempotency` read `REDIS_URL` into a module-level constant at import time**, not inside the constructor. Any test file that statically imports `@quickbite/shared` (which re-exports `idempotency.ts`) before setting `process.env.REDIS_URL` in `beforeAll` locks in the fallback `redis://localhost:6379` — a port nothing is listening on — causing every Redis call in that test run to fail with `ioredis Unhandled error event`. This looked exactly like Docker Desktop network flakiness (same symptom, same unchanged-test-also-fails evidence) but survived a full Docker Desktop restart, which is what proved it was a code bug, not an environment issue. Fixed by moving the `process.env.REDIS_URL` read into `RedisIdempotency`'s constructor default parameter. **Lesson: any module-level `const X = process.env.Y ?? fallback` in a file reachable via a package barrel (`packages/shared/src/index.ts`) is unsafe for tests that set env vars in `beforeAll` — only safe if the env read happens inside a function/constructor, not at import time.** - -Snyk: 0 issues. Typecheck and full `bun run test` (all 6 packages) green after both bug fixes. - -## Addition: React frontend (branch `feature/frontend`, additive, not graded) - -After backend submission, the user wanted a UI to click-test the system themselves before the oral defense. New top-level `frontend/` (React + Vite + TypeScript, react-router-dom), its own 7th Docker container with a healthcheck, served by a hand-rolled Bun static file server (`frontend/serve.ts` — path-traversal-safe, SPA-fallback for client routes). `nginx.conf` now splits traffic: `/api/*` and `= /health` → gateway (unchanged), everything else → the new `frontend` upstream — nginx remains the system's only published port. - -Key decisions, each one a deliberate fix for a concrete risk surfaced during planning/implementation, not guesswork: -- **Did NOT import `OrderItem` from `@quickbite/shared`** for client-side cart validation — that package's barrel re-exports `mq.ts`/`idempotency.ts`, which pull in `amqplib`/`ioredis` (Node-only, not browser-safe). Duplicated a minimal zod schema in `frontend/src/lib/orderItem.ts` instead, same "client previews, server stays authoritative" reasoning as `cartTotal.ts` vs. `services/order/src/pricing.ts`. -- **`useOrderPolling` hook**: setTimeout-chain (never raw `setInterval`, to avoid overlapping in-flight requests), AbortController + `cancelled` flag (no setState-after-unmount), treats a 404 as "not found yet" within a bounded ~60s window (not fatal — the outbox pattern means the row can briefly lag right after `POST /orders` returns), explicit "timed-out" UI state if `"ready"` is never reached. -- **Real test-infra bug found and fixed**: `@testing-library/react`'s `screen` binds to `document.body` once, at *module-load time* — and ESM hoists all `import` statements above any other code regardless of where they're written. A static `import { cleanup } from "@testing-library/react"` at the top of `frontend/tests/setup.ts`, written after `GlobalRegistrator.register()` in source order, was still being evaluated *before* it at runtime, permanently breaking every `screen.*` query. Fixed with a dynamic `await import("@testing-library/react")` placed after registration. Also needed an `afterEach(cleanup)` — without it, happy-dom's shared document persists rendered output across test files, causing false "multiple elements found" errors. -- Frontend's own `zod` was pinned to `^3.23.8` to match the rest of the repo (it resolved to v4 by default) — same major version everywhere, deliberately. -- Verified live, not just via tests: full `docker compose up --build` → `frontend` reaches `healthy` → curl-driven `placed → accepted → ready` through nginx → confirmed a hard-refresh on a deep link like `/orders/` returns the SPA shell (200), not a 404 → confirmed path-traversal attempts on `serve.ts` fall through safely to the SPA fallback, never leak a file outside `dist/` → confirmed the public-entry-point invariant still holds (only nginx has a published port; frontend's internal port 3005 is unreachable from the host). - -Snyk: 0 issues. Typecheck and full `bun run test` (now 7 packages, frontend included — pure `lib/` unit tests + `happy-dom`-backed component tests) green. - -**Per explicit user instruction: do not merge this branch automatically.** Wait for the user's own manual click-through in a real browser before merging — unlike every prior phase, where merge was pre-approved. - -## Next step - -Frontend branch is implemented, tested, and verified server-side — pending the user's own manual browser click-through, then commit/push/PR/merge. After that: no more build work planned. The user's next intent is a Q&A/walkthrough session to fully understand the codebase ahead of the oral defense (explain architecture, justify decisions, describe traffic flow, name an error scenario and its handling). Possible further follow-ups if wanted later: a real notification send (email/push, still a `TODO` in `services/notification/src/index.ts`), CI badge/status in `README.md`, or load-testing the outbox poller under concurrent order volume. - -## Key files to know - -| File | Purpose | -|---|---| -| `packages/shared/src/events.ts` | Single source of truth for all event Zod schemas | -| `packages/shared/src/mq.ts` | RabbitMQ connect/publish/consume helpers | -| `docker-compose.yml` | All infra — Postgres, RabbitMQ, Redis, Nginx, services | -| `.env.example` | Template for all env vars — copy to `.env` | - -## Environment - -Each service reads the same env var name, `DATABASE_URL` — docker-compose.yml sets a different value per container: - -```bash -RABBITMQ_URL=amqp://guest:guest@localhost:5672 -DATABASE_URL=postgres://quickbite:quickbite@localhost:5432/_db # order_db | menu_db | kitchen_db | notification_db -REDIS_URL=redis://localhost:6379 -``` diff --git a/.claude/agents/explore.md b/.claude/agents/explore.md deleted file mode 100644 index be7cb6c..0000000 --- a/.claude/agents/explore.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: explore -description: Read-only exploration of the QuickBite repo. Use before planning a change to map relevant files without polluting the main context. -tools: Read, Grep, Glob ---- -You are a read-only explorer. Map the files relevant to the user's task across -packages/shared and services/*. Report which services produce/consume the -events involved, where schemas live, and any rules in CLAUDE.md that apply. -Do not edit files. diff --git a/.claude/commands/new-event.md b/.claude/commands/new-event.md deleted file mode 100644 index 40fbd15..0000000 --- a/.claude/commands/new-event.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -description: Add a new domain event end to end, following the project contract ---- -Add a new event named "$ARGUMENTS". - -Steps (use plan mode, get approval before editing): -1. Define its zod schema + type in packages/shared/src/events.ts and register it in eventSchemas. -2. Identify the producing service and publish it only after the local commit. -3. Identify consuming services, bind the routing key, and make the handler idempotent. -4. Update the event catalog table in CLAUDE.md. -5. Add a unit test for the schema and update integration tests. diff --git a/.claude/commands/new-migration.md b/.claude/commands/new-migration.md deleted file mode 100644 index 05d959f..0000000 --- a/.claude/commands/new-migration.md +++ /dev/null @@ -1,36 +0,0 @@ -# /new-migration - -Scaffold a new SQL migration file for a service. - -## Usage - -``` -/new-migration -``` - -Example: `/new-migration order add-outbox-table` - -## What this does - -Creates `services//src/db/migrations/_.sql` with a template: - -```sql --- Migration: --- Service: --- Created: - --- Step 1: Create table -CREATE TABLE IF NOT EXISTS ... ( - -); - --- Step 2: Add indexes -CREATE INDEX IF NOT EXISTS ... ON ...; -``` - -Then explains: -- WHY this migration exists (what problem it solves) -- WHAT the table structure means -- How to run it (the service auto-runs migrations on startup via `db.ts`) - -Always wait for confirmation before creating the file. diff --git a/.claude/commands/run-tests.md b/.claude/commands/run-tests.md deleted file mode 100644 index 1057f22..0000000 --- a/.claude/commands/run-tests.md +++ /dev/null @@ -1,27 +0,0 @@ -# /run-tests - -Run the right tier of tests for the current service or the whole workspace. - -## Usage - -``` -/run-tests [service] [tier] -``` - -- `service`: `shared`, `order`, `menu`, `kitchen`, `notification` — or omit for all -- `tier`: `unit`, `integration`, `e2e` — or omit for unit + integration - -## What each tier does - -| Tier | Command | What it tests | -|---|---|---| -| unit | `bun test` | Pure logic, no I/O, no containers | -| integration | `bun test` (Testcontainers) | Real Postgres + RabbitMQ spun up in Docker | -| e2e | `bun test tests/e2e/` | Full order flow across all services | - -## Rules - -- Always run unit tests first. If they fail, fix before running integration. -- Integration tests need Docker running (`docker compose up -d rabbitmq postgres redis`). -- After each phase, run the relevant tier before declaring done. -- Report: pass count, fail count, and any failing test names with the error. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 3ae7ccc..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "cd /Users/devindersingh/quickbite/quickbite && bun run typecheck 2>&1 | tail -20" - } - ] - } - ] - } -} diff --git a/.env.example b/.env.example index 7da6822..97c6680 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,4 @@ REDIS_URL=redis://localhost:6379 # Service URLs the gateway proxies to (local dev defaults) MENU_URL=http://localhost:3001 ORDER_URL=http://localhost:3002 +KITCHEN_URL=http://localhost:3003 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 21bdc44..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,114 +0,0 @@ -# CLAUDE.md — QuickBite - -> Event-driven food-ordering platform. Microservices in a Bun workspace, talking over RabbitMQ. Built as a system-level QA project. Keep this file tight — it loads every session. - -## Architecture (invariants — do not violate) - -- Microservices, each owning **only its own** PostgreSQL database. A service **never** reads or writes another service's tables. -- Inter-service workflow communication is via **RabbitMQ events only**. No direct service-to-service HTTP calls for the order workflow. -- Synchronous HTTP exists **only** on the path `client → Nginx → API gateway → service` for user-facing reads (menu, order status). -- **Nginx is the single public entry point** — it routes `/api/*` and `/health` to the gateway, and everything else to the `frontend` container. Neither the gateway, the frontend, nor any individual service is exposed publicly. - -Flow: `Order` publishes `order.placed` → `Kitchen` consumes it, then publishes `order.accepted` and `order.ready` → `Notification` consumes all order events. - -## Tech stack - -- Runtime/framework: **Bun + Fastify + TypeScript** (strict, ESM) in every service -- Messaging: **RabbitMQ** (topic exchange, durable queues, DLQ) -- Data: **PostgreSQL**, one database per service -- Cache + idempotency: **Redis** (menu cache with TTL, event dedup with SETNX + TTL) -- Edge: **Nginx** reverse proxy / load balancer in front of the gateway and the frontend -- Frontend: **React + Vite + TypeScript**, served as static files by its own Bun-based static server (`frontend/serve.ts`) — additive, non-graded, for manual click-testing -- Local orchestration: **Docker Compose** -- CI/CD: **GitHub Actions** -- Tests: **bun test** + **Testcontainers** for integration - -## Repo layout (Bun workspace monorepo) - -``` -quickbite/ -├── docker-compose.yml -├── nginx/nginx.conf -├── .github/workflows/ci.yml -├── packages/ -│ └── shared/ # event types, zod schemas, mq + db helpers -├── services/ -│ ├── gateway/ -│ ├── menu/ -│ ├── order/ -│ ├── kitchen/ -│ └── notification/ -├── frontend/ # React + Vite UI — its own container, not a "service" (no DB, no events) -└── .claude/{commands,agents}/ -``` - -Each service: `src/{index.ts, routes/, events/, db/}`, `tests/`, `Dockerfile`, `package.json`. - -## Event catalog (the contract — source of truth in packages/shared) - -All payloads share an envelope: `{ eventId: uuid, occurredAt: ISO8601, ... }`. - -| Event | Published by | Consumed by | Payload | -|-------------------|--------------|------------------------|------------------------------------------------------| -| `order.placed` | order | kitchen, notification | `orderId, customerId, items[], totalCents` | -| `order.accepted` | kitchen | order, notification | `orderId, etaMinutes` | -| `order.ready` | kitchen | notification | `orderId` | - -Add new events here first, define the zod schema in `packages/shared`, then implement. - -## Hard rules - -- Event consumers **must be idempotent** — dedupe on `eventId` (store processed IDs). RabbitMQ can redeliver. -- Publish an event **only after** the local DB transaction commits (outbox pattern preferred). -- **Validate every event payload** with the zod schema from `packages/shared` on both publish and consume. -- Failed messages retry with backoff, then go to a **dead-letter queue** — never infinite-loop a poison message. -- Every service exposes `GET /health`. -- Config via env vars; `.env` is gitignored. **Never hardcode secrets or connection strings.** - -## Code conventions - -- TypeScript strict, no `any`, named exports, ESM imports. -- Validate all external input (HTTP bodies + event payloads) with **zod**. -- Structured logging with **pino**, include `eventId`/`orderId` in log context. -- Errors: throw typed errors, map to proper HTTP status at the route layer. -- **Step comments** in every file: `// Step 1: Connect to database`, `// Step 2: ...` -- **Emojis** in all log/console messages: ✅ success, ❌ error, 🚀 startup, ⚠️ warning -- **`.ts` extensions** on all local imports: `import { foo } from "./foo.ts"` -- **`buildServer` pattern**: separate `async function buildServer()` from `buildServer().then(start)` — never inline startup -- Use `server` as the Fastify variable name, never `fastify` -- Clean sections separated by blank lines and step comments — any teammate must be able to read the file - -## Testing (system-level QA — this is graded) - -Follow the testing pyramid: -- **Unit** — pure domain logic, no I/O. -- **Integration** — real Postgres + RabbitMQ via Testcontainers (no mocks for the broker/db). -- **End-to-end** — one happy-path flow: place order → kitchen ready → customer notified. - -CI must be green before anything merges. Run the relevant tier locally before declaring a task done. - -## Common commands - -```bash -bun install # install workspace deps -docker compose up -d # start rabbitmq, postgres, services -docker compose logs -f # tail one service -bun run --filter dev # run one service in watch mode -bun test # unit + integration for current package -bun run typecheck # tsc --noEmit across workspace -``` - -## Workflow rules for Claude - -- **Use plan mode (Shift+Tab) before any new service or feature.** Present the plan and wait for approval before editing files. -- One service / one concern per change. Small commits, **conventional commit** messages (`feat(order): ...`). -- Before saying a task is done: run `bun run typecheck` and `bun test` for the touched service. -- **Never** run `docker compose down -v`, drop a database, or delete migrations without asking first. -- Don't push to `main` or deploy. Make a branch and open a PR (`gh pr create`). -- **Always fetch library docs via Context7 MCP** before using any library API — never guess, never rely on training data. This applies to postgres.js, ioredis, amqplib, fastify, zod, testcontainers, etc. -- **Always run Snyk** (`snyk_code_scan`) after writing new first-party TypeScript code. Fix any issues before continuing. -- **Go file by file.** Complete and explain one file at a time. Wait for confirmation before the next. -- **Explain every step**: WHY we're doing it, WHAT it means, and how it compares to alternatives. This is a learning project — understanding matters more than speed. -- Read `.claude/CONTEXT.md` at the start of every session to know the current project state. Update it at the end of each phase. - -> Update this file when you correct the same mistake twice — add the shortest rule that would have prevented it, and remove any rule it contradicts. diff --git a/README.md b/README.md index 2d5c5d1..0999205 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,269 @@ +
+ # QuickBite -Event-driven food-ordering platform: a public API gateway in front of 4 -microservices (menu/product, order, kitchen, notification), talking to each -other over RabbitMQ and each owning its own PostgreSQL database. Built as a -system-level QA project — the event flow, persistence, and failure handling -are the actual point, not just the CRUD surface. +**An event-driven food-ordering platform where four microservices coordinate over RabbitMQ instead of calling each other directly.** -## Architecture +A systems-design project, not a CRUD tutorial — the point is the event flow, per-service data ownership, and failure handling, not the menu screen. +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Bun](https://img.shields.io/badge/Bun-workspaces-000000?logo=bun&logoColor=white)](https://bun.sh) +[![Fastify](https://img.shields.io/badge/Fastify-5-000000?logo=fastify&logoColor=white)](https://fastify.dev) +[![RabbitMQ](https://img.shields.io/badge/RabbitMQ-topic%20exchange-FF6600?logo=rabbitmq&logoColor=white)](https://www.rabbitmq.com/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?logo=postgresql&logoColor=white)](https://www.postgresql.org) +[![Redis](https://img.shields.io/badge/Redis-7-DC382D?logo=redis&logoColor=white)](https://redis.io) +[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://react.dev) +[![nginx](https://img.shields.io/badge/nginx-reverse%20proxy-009639?logo=nginx&logoColor=white)](https://nginx.org) +[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) +[![CI](https://github.com/devinder-dev/quickbite/actions/workflows/ci.yml/badge.svg)](https://github.com/devinder-dev/quickbite/actions/workflows/ci.yml) + +
+ +--- + +## What this is + +Four Fastify services — **menu**, **order**, **kitchen**, **notification** — sit +behind a gateway and an nginx entry point, each owning its own PostgreSQL +database and never touching another service's tables. Placing an order is a +synchronous HTTP call; everything that happens *after* that (kitchen staff +accepting it, cooking, marking it ready, order syncing its own status, +notification logging every step) happens entirely through `order.placed` / +`order.accepted` / `order.cooking` / `order.ready` events on a RabbitMQ topic +exchange. Adding the notification service required zero changes to order or +kitchen — that's the actual argument for doing it this way. + +## How it works + +```mermaid +sequenceDiagram + actor Customer + actor Staff as Kitchen staff + participant Nginx as nginx (:80) + participant GW as Gateway + participant Order as Order service + participant MQ as RabbitMQ + participant Kitchen as Kitchen service + participant Notif as Notification service + + Customer->>Nginx: POST /api/orders + Nginx->>GW: proxy + GW->>Order: proxy + Order->>Order: persist order + outbox row (1 transaction) + Order-->>Customer: 201 {orderId, status: placed} + Order->>MQ: outbox poller publishes order.placed + + MQ->>Kitchen: order.placed + Kitchen->>Kitchen: create kitchen_orders row (idempotent by eventId) + + Staff->>Nginx: POST /api/kitchen/orders/:id/accept + Nginx->>GW: proxy + GW->>Kitchen: proxy + Kitchen->>Kitchen: write order row + outbox row (1 transaction) + Kitchen->>MQ: outbox poller publishes order.accepted + MQ->>Order: order.accepted → sync status + MQ->>Notif: order.accepted → record + log + + Note over Staff,Notif: staff repeats the same pattern for
start-cooking → order.cooking, ready → order.ready + + Customer->>Nginx: GET /api/orders/:id (polling) + Nginx->>GW: proxy + GW->>Order: proxy + Order-->>Customer: current status ``` - +-- / --> frontend (React UI) -client → nginx (:80) --------+ - +-- /api/*, /health --> gateway → menu / order (synchronous reads) - | - v - order.placed (RabbitMQ) - | - +------------+------------+ - v v - kitchen notification - (accepts, then ready) (logs every order event) -``` -- **nginx is the system's only public entry point.** Every other service — - RabbitMQ, Postgres, Redis, the gateway, and the frontend container itself — - is reachable only from other containers on the internal Docker network, - never from the host. -- Synchronous HTTP exists only on the path client → nginx → gateway → service, - for user-facing reads (menu, order status). The actual order workflow - (placed → accepted → ready → notified) happens entirely over RabbitMQ events. -- Each service owns its own Postgres database; none of them ever reads or - writes another service's tables. +- **nginx is the only public entry point.** RabbitMQ, Postgres, Redis, the + gateway, and the frontend container are all reachable only from other + containers on the internal Docker network — never from the host. +- **No automatic timer anywhere.** Kitchen staff drive placed → accepted → + cooking → ready by hand from the dashboard; each click is what publishes + the next event. +- **Transactional outbox, not a direct publish.** Order and kitchen both + write their event to an `outbox` table in the same DB transaction as the + actual state change, then a separate poller drains and publishes it. A + crash between commit and publish can't silently lose an event. + +## Status -## Run it +| Component | State | +|---|---| +| nginx entry point + gateway proxy (menu / order / kitchen) | ✅ | +| Menu, order, kitchen, notification services — each own Postgres DB | ✅ | +| Transactional outbox (order + kitchen) | ✅ | +| RabbitMQ topic exchange, automatic reconnection | ✅ | +| Redis-backed idempotency, per-consumer namespacing | ✅ | +| Retry with exponential backoff (1s/2s/4s) → dead-letter queue | ✅ | +| React frontend — cart, live order tracking, toast notifications | ✅ | +| Kitchen dashboard — accept / start cooking / mark ready | ✅ | +| Unit + integration (Testcontainers) + e2e tests, across every service and the frontend | ✅ | +| CI: typecheck + full test suite on every push/PR | ✅ | -One command, no manual setup steps — database schemas and seed data are -created automatically on first boot (`docker/init.sql` creates one database -per service; each service creates its own tables and seeds itself on startup): +Unit, integration, and end-to-end suites exist for all five backend +services and the frontend (`services/*/tests`, `frontend/tests`, +`tests/e2e`) — no infrastructure is mocked; integration and e2e tests spin +up real Postgres/RabbitMQ/Redis via Testcontainers. + +## Quick start ```bash +git clone https://github.com/devinder-dev/quickbite.git +cd quickbite docker compose up --build ``` -**Public entry point:** `http://localhost` (port 80, via nginx). This is the -only address the system exposes — RabbitMQ's management UI, Postgres, and -Redis are not reachable from the host with this command. +Database schemas and seed data are created automatically on first boot — no +manual setup steps. Public entry point: **http://localhost** (nginx on port +80; nothing else is exposed to the host with this command). ```bash # Place an order curl -X POST http://localhost/api/orders \ -H 'content-type: application/json' \ - -d '{"customerId":"33333333-3333-3333-3333-333333333333","items":[{"menuItemId":"44444444-4444-4444-4444-444444444444","name":"Margherita","quantity":2,"priceCents":1200}]}' + -d '{"customerId":"33333333-3333-3333-3333-333333333333","items":[{"menuItemId":"44444444-4444-4444-4444-444444444444","name":"Gyros","quantity":2,"priceCents":1100}]}' # Check its status (replace with the orderId from the response above) curl http://localhost/api/orders/ -# Read the menu -curl http://localhost/api/menu +# Kitchen staff actions — a human drives each step, no timer: +curl -X POST http://localhost/api/kitchen/orders//accept +curl -X POST http://localhost/api/kitchen/orders//start-cooking +curl -X POST http://localhost/api/kitchen/orders//ready +``` + +Frontend: **http://localhost/** (customer app) and **http://localhost/kitchen** +(staff dashboard) — same nginx, same port, so there's no CORS config anywhere. + +
+Full architecture reasoning (click) + +``` + +-- / --> frontend (React UI: customer app + kitchen dashboard) +client → nginx (:80) --------+ + +-- /api/*, /health --> gateway → menu / order / kitchen (synchronous reads + staff actions) + | + v + order.placed (RabbitMQ) + | + v + kitchen + (a human accepts, starts cooking, marks ready — + no automatic timer; each step publishes its own event) + | + +------------+------------+ + v v + order notification + (syncs its own status) (logs every order event) ``` -Watch `docker compose logs -f kitchen notification` to see the event flow: -kitchen accepts the order immediately, marks it ready ~3s later (simulated -cook time), and notification logs all 3 order events as they happen. +- Synchronous HTTP exists only on the path client → nginx → gateway → + service, for user-facing reads (menu, order status) **and** kitchen staff + actions (accept / start cooking / mark ready). The order workflow itself + (placed → accepted → cooking → ready → notified) happens entirely over + RabbitMQ events. +- Each service owns its own Postgres database (`docker/init.sql` creates one + database per service on first boot); none of them ever reads or writes + another service's tables. +- The gateway (`services/gateway/src/index.ts`) is a small hand-rolled proxy + built on Bun's native `fetch()` rather than `@fastify/http-proxy` — that + plugin's underlying `undici` Pool isn't fully compatible with Bun's + runtime. + +
+ +
+Event catalog (click) + +All events are published on a single topic exchange (`quickbite.events`) and +schema-validated with Zod on both publish and consume +(`packages/shared/src/events.ts`): + +| Event | Published by | Consumed by | +|---|---|---| +| `order.placed` | order (via outbox) | kitchen, notification | +| `order.accepted` | kitchen (via outbox) | order, notification | +| `order.cooking` | kitchen (via outbox) | order, notification | +| `order.ready` | kitchen (via outbox) | order, notification | + +Every event carries an envelope (`eventId`, `occurredAt`) used for +idempotent consumption — each consumer dedupes redelivered events by +`eventId` in Redis, namespaced per consumer (`RedisIdempotency("kitchen")`, +`"order"`, `"notification"`) so two services consuming the same event never +interfere with each other. + +
-## Frontend +
+Resilience features (click) -A small React UI lives at `http://localhost/` (same nginx, same port 80) — -menu → cart → place order → a status screen that polls -`GET /api/orders/:id` live as it moves placed → accepted → ready, plus a -localStorage-backed order history. It's same-origin with the API (nginx -serves both under port 80), so there's no CORS configuration anywhere — -that's deliberate, not an oversight. +- **Transactional outbox** (order + kitchen) — events are written to an + outbox table in the same DB transaction as the state change, then + published by a separate 1s poller. A crash between commit and publish + can never silently drop an event. +- **Automatic RabbitMQ reconnection** — a dropped broker connection + recovers on its own; consumers and outbox pollers resume without a + service restart. +- **Redis-backed idempotency** — every consumer dedupes redelivered events + by `eventId`, namespaced per consumer. +- **Retry with exponential backoff** — a failing handler gets 3 retries + (1s / 2s / 4s) via a dead-letter-based delay queue before landing in the + permanent dead-letter queue. +- **Ordered delivery where it matters** — order's consumer for + `order.accepted` / `order.cooking` / `order.ready` uses `prefetch: 1` so + the three state transitions for one order are always applied in the + order they happened, never processed concurrently out of order. + +
+ +
+Frontend (click) -For local frontend development with hot reload, run the backend stack as -usual and run the frontend separately on the host: +A React 19 UI lives at `http://localhost/` — menu → cart → place order → a +status screen that polls `GET /api/orders/:id` live as it moves placed → +accepted → cooking → ready, plus a localStorage-backed order history. +Order tracking is lifted to the app level (`OrderTrackingContext`) rather +than scoped to one page, so a persistent progress banner and toast +notifications follow you while browsing the menu or order history. + +**Kitchen dashboard** (`/kitchen`) is the staff-facing side of the same app: +every order not yet ready, each with exactly one button for its current +stage. Clicking through is what actually publishes the next event — no +authentication, matching the rest of this no-auth course project. ```bash docker compose up -d bun run dev:frontend # Vite dev server; proxies /api to http://localhost:80 ``` -### Local development +
+ +
+Local development & tests (click) -For direct access to Postgres/RabbitMQ/Redis (e.g. a DB GUI, or running one -service bare with `bun run dev:` against dockerized infra), use the -dev override, which is not loaded by default: +For direct access to Postgres/RabbitMQ/Redis (a DB GUI, or running one +service bare with `bun run dev:` against dockerized infra): ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up ``` -## Tests - ```bash bun run test # unit + integration (Testcontainers) + end-to-end bun run typecheck ``` -Runs automatically in CI on every push and pull request (`.github/workflows/ci.yml`). -No mocks for any infrastructure — integration and e2e tests spin up real -Postgres/RabbitMQ/Redis containers. The frontend's tests are split the same -way: pure logic (`frontend/tests/lib/`, no DOM) and component-rendering -tests (`frontend/tests/components/`, real DOM via happy-dom + -Testing Library) — no backend changes needed for either. - -## Resilience features - -A few things worth knowing about for the oral defense, beyond the basic flow: - -- **Transactional outbox** (order service) — events are written to an outbox - table in the same DB transaction as the order, then published by a separate - poller. A crash between commit and publish can never silently drop an event. -- **Automatic RabbitMQ reconnection** — a dropped broker connection recovers - on its own; consumers and the outbox poller resume without a service restart. -- **Redis-backed idempotency** — every consumer dedupes redelivered events by - `eventId`, namespaced per consumer so two services consuming the same event - don't interfere with each other. -- **Retry with exponential backoff** — a failing handler gets 3 retries - (1s/2s/4s) via a dead-letter-based delay queue before landing in the - permanent dead-letter queue. +Runs automatically in CI on every push and pull request +(`.github/workflows/ci.yml`). No mocks for any infrastructure — integration +and e2e tests spin up real Postgres/RabbitMQ/Redis containers. The +frontend's tests are split the same way: pure logic (`frontend/tests/lib/`, +no DOM) and component-rendering tests (`frontend/tests/components/`, real +DOM via happy-dom + Testing Library). + +
+ +--- + +
+ +**Devinder Singh** · Fullstack Developer (YH) student at Chas Academy, Stockholm + +
diff --git a/docker-compose.yml b/docker-compose.yml index 556a747..98ec932 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,7 +39,8 @@ services: PORT: 3000 MENU_URL: http://menu:3001 ORDER_URL: http://order:3002 - depends_on: [menu, order] + KITCHEN_URL: http://kitchen:3003 + depends_on: [menu, order, kitchen] menu: build: { context: ., dockerfile: services/menu/Dockerfile } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1bfce7..c41b753 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,20 +1,38 @@ import { BrowserRouter, Link, Route, Routes } from "react-router-dom"; +import { ActiveOrderBanner } from "./components/ActiveOrderBanner.tsx"; +import { ToastList } from "./components/ToastList.tsx"; +import { OrderTrackingProvider, useOrderTracking } from "./context/OrderTrackingContext.tsx"; +import { KitchenDashboardPage } from "./pages/KitchenDashboardPage.tsx"; import { MenuPage } from "./pages/MenuPage.tsx"; import { OrderHistoryPage } from "./pages/OrderHistoryPage.tsx"; import { OrderStatusPage } from "./pages/OrderStatusPage.tsx"; +// Global toasts live here, fed by the same OrderTrackingProvider that +// drives ActiveOrderBanner — both need to render on every page, not just +// /orders/:id, so they sit above rather than inside any one page. +function GlobalToasts() { + const { toasts, dismissToast } = useOrderTracking(); + return ; +} + export function App() { return ( - - - } /> - } /> - } /> - + + + + + + } /> + } /> + } /> + } /> + + ); } diff --git a/frontend/src/ErrorBoundary.tsx b/frontend/src/ErrorBoundary.tsx new file mode 100644 index 0000000..354148f --- /dev/null +++ b/frontend/src/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +type Props = { children: ReactNode }; +type State = { error: Error | null }; + +// A backstop, not the fix for any specific bug — render errors anywhere +// below this (a bad localStorage shape, a malformed API response, anything +// unanticipated) should never silently unmount the whole app to a blank +// white page. Catching it here means there's always SOMETHING visible to +// react to, instead of a page that looks like it just didn't load. +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error("❌ unhandled render error", error, info.componentStack); + } + + render() { + if (this.state.error) { + return ( +
+

Something went wrong displaying this page.

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ae60cf1..9187f96 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,5 @@ import { OrderItems } from "./lib/orderItem.ts"; -import type { CartLine, MenuItem, OrderDetail, OrderSummary } from "./types.ts"; +import type { CartLine, KitchenOrder, MenuItem, OrderDetail, OrderSummary } from "./types.ts"; export class ApiError extends Error { constructor( @@ -60,3 +60,31 @@ export async function getOrder(orderId: string, signal?: AbortSignal): Promise { + const res = await fetch("/api/kitchen/orders", { signal }); + if (!res.ok) throw new ApiError("failed to load kitchen orders", res.status); + return res.json(); +} + +async function postKitchenAction(orderId: string, action: string): Promise { + const res = await fetch(`/api/kitchen/orders/${orderId}/${action}`, { method: "POST" }); + if (res.status === 404) throw new OrderNotFoundError(orderId); + if (!res.ok) throw new ApiError(`failed to ${action} order`, res.status); + return res.json(); +} + +export function acceptOrder(orderId: string): Promise { + return postKitchenAction(orderId, "accept"); +} + +export function startCooking(orderId: string): Promise { + return postKitchenAction(orderId, "start-cooking"); +} + +export function markReady(orderId: string): Promise { + return postKitchenAction(orderId, "ready"); +} diff --git a/frontend/src/components/ActiveOrderBanner.tsx b/frontend/src/components/ActiveOrderBanner.tsx new file mode 100644 index 0000000..1eaedba --- /dev/null +++ b/frontend/src/components/ActiveOrderBanner.tsx @@ -0,0 +1,36 @@ +import { Link } from "react-router-dom"; +import { useOrderTracking } from "../context/OrderTrackingContext.tsx"; +import { OrderStages } from "./OrderStages.tsx"; + +// Rendered once in App.tsx, above — visible on every page, not +// just the order's own /orders/:id detail view. That's the actual fix for +// "progress is only in order history, not on the front": this is on the +// front (and everywhere else) now. +export function ActiveOrderBanner() { + const { activeOrderId, pollState, isBannerDismissed, dismissBanner } = useOrderTracking(); + + if (!activeOrderId || isBannerDismissed) return null; + if (pollState.phase === "loading" || pollState.phase === "error") return null; + + // Narrowed to "polling" | "ready" | "timed-out" here — all three carry + // `order`, though timed-out's can still be null if the very first poll + // never resolved before the deadline. + const { order } = pollState; + if (!order) return null; + + return ( +
+
+ Order #{order.orderId.slice(0, 8)} + +
+ + {pollState.phase === "timed-out" ? ( +

Still processing — taking longer than expected.

+ ) : null} + View details +
+ ); +} diff --git a/frontend/src/components/MenuList.tsx b/frontend/src/components/MenuList.tsx index d8d4049..15b8ff0 100644 --- a/frontend/src/components/MenuList.tsx +++ b/frontend/src/components/MenuList.tsx @@ -1,3 +1,4 @@ +import { getDishInfo } from "../lib/dishInfo.ts"; import { formatCents } from "../lib/money.ts"; import type { MenuItem } from "../types.ts"; @@ -12,27 +13,36 @@ export function MenuList({ items, quantities, onChangeQuantity }: Props) {
    {items.map((item) => { const quantity = quantities[item.id] ?? 0; + const { emoji, description } = getDishInfo(item.name); return ( -
  • - {item.name} - ${formatCents(item.price_cents)} -
    - - {quantity} - +
  • + +
    +
    + {item.name} + ${formatCents(item.price_cents)} +
    + {description ?

    {description}

    : null} +
    + + {quantity} + +
  • ); diff --git a/frontend/src/components/OrderStages.tsx b/frontend/src/components/OrderStages.tsx new file mode 100644 index 0000000..8336aed --- /dev/null +++ b/frontend/src/components/OrderStages.tsx @@ -0,0 +1,17 @@ +const STAGES = ["placed", "accepted", "cooking", "ready"] as const; + +// Extracted out of OrderStatus.tsx so the persistent ActiveOrderBanner can +// render the identical stage tracker without duplicating it. +export function OrderStages({ status }: { status: string }) { + const currentIndex = STAGES.indexOf(status as (typeof STAGES)[number]); + + return ( +
      + {STAGES.map((stage, index) => ( +
    1. + {stage} +
    2. + ))} +
    + ); +} diff --git a/frontend/src/components/OrderStatus.tsx b/frontend/src/components/OrderStatus.tsx index 2b99c4f..170d9a5 100644 --- a/frontend/src/components/OrderStatus.tsx +++ b/frontend/src/components/OrderStatus.tsx @@ -1,7 +1,6 @@ import { formatCents } from "../lib/money.ts"; import type { PollState } from "../hooks/useOrderPolling.ts"; - -const STAGES = ["placed", "accepted", "ready"] as const; +import { OrderStages } from "./OrderStages.tsx"; export function OrderStatus({ state }: { state: PollState }) { if (state.phase === "loading") return

    Loading order…

    ; @@ -22,7 +21,15 @@ export function OrderStatus({ state }: { state: PollState }) { return (
    -

    Order #{order.orderId}

    + {order.status === "ready" ? ( +

    + ✅ Ready! This update arrived automatically — the kitchen service published an event over RabbitMQ, no + page refresh needed. +

    + ) : ( +

    🔄 Checking for updates every 1.5s — this page will update itself.

    + )} +

    Order #{order.orderId.slice(0, 8)}

    Total: ${formatCents(order.totalCents)}

      {order.items.map((item) => ( @@ -34,17 +41,3 @@ export function OrderStatus({ state }: { state: PollState }) {
    ); } - -function OrderStages({ status }: { status: string }) { - const currentIndex = STAGES.indexOf(status as (typeof STAGES)[number]); - - return ( -
      - {STAGES.map((stage, index) => ( -
    1. - {stage} -
    2. - ))} -
    - ); -} diff --git a/frontend/src/components/ToastList.tsx b/frontend/src/components/ToastList.tsx new file mode 100644 index 0000000..77ccfed --- /dev/null +++ b/frontend/src/components/ToastList.tsx @@ -0,0 +1,23 @@ +import type { Toast } from "../hooks/useToasts.ts"; + +type Props = { + toasts: Toast[]; + onDismiss: (id: string) => void; +}; + +export function ToastList({ toasts, onDismiss }: Props) { + if (toasts.length === 0) return null; + + return ( +
    + {toasts.map((toast) => ( +
    + {toast.message} + +
    + ))} +
    + ); +} diff --git a/frontend/src/context/OrderTrackingContext.tsx b/frontend/src/context/OrderTrackingContext.tsx new file mode 100644 index 0000000..ce1c0bc --- /dev/null +++ b/frontend/src/context/OrderTrackingContext.tsx @@ -0,0 +1,97 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { useOrderPolling, type PollState } from "../hooks/useOrderPolling.ts"; +import { useToasts, type Toast } from "../hooks/useToasts.ts"; +import { getOrderHistory } from "../lib/orderHistory.ts"; + +const STATUS_MESSAGES: Record = { + placed: "📥 Order placed!", + accepted: "👨‍🍳 Kitchen accepted your order", + cooking: "🔥 Your order is cooking", + ready: "✅ Order ready!", +}; + +type OrderTrackingValue = { + activeOrderId: string | null; + pollState: PollState; + toasts: Toast[]; + dismissToast: (id: string) => void; + isBannerDismissed: boolean; + dismissBanner: () => void; + setActiveOrderId: (orderId: string) => void; +}; + +const OrderTrackingContext = createContext(null); + +// Lives above in App.tsx — tracking and toasts must survive +// navigation, since the whole point of this is that a customer placing an +// order and then browsing the menu (or checking history, or anything else) +// should still get notified when the kitchen actually does something. A +// page-scoped version of this (the original design) dies the moment the +// user leaves that one page. +export function OrderTrackingProvider({ children }: { children: ReactNode }) { + // Seeded from the existing order-history localStorage list (most recent + // first, see lib/orderHistory.ts) — no new storage needed, and it means + // reloading the page still shows the tracker for an order that hasn't + // reached "ready" yet. + const [activeOrderId, setActiveOrderIdState] = useState( + () => getOrderHistory()[0]?.orderId ?? null, + ); + const [dismissedAtStatus, setDismissedAtStatus] = useState(null); + const pollState = useOrderPolling(activeOrderId ?? ""); + const { toasts, pushToast, dismiss } = useToasts(); + const lastAnnouncedStatus = useRef(null); + + function setActiveOrderId(orderId: string): void { + lastAnnouncedStatus.current = null; + setDismissedAtStatus(null); + setActiveOrderIdState(orderId); + } + + // Every distinct status the poller observes gets its own visible notice + // — moved here from OrderStatusPage specifically so it fires regardless + // of which page is currently mounted, not just while the user happens to + // be staring at /orders/:id. + useEffect(() => { + const currentStatus = + pollState.phase === "polling" || pollState.phase === "ready" ? pollState.order.status : undefined; + if (!currentStatus || currentStatus === lastAnnouncedStatus.current) return; + + lastAnnouncedStatus.current = currentStatus; + pushToast(STATUS_MESSAGES[currentStatus] ?? `Status: ${currentStatus}`); + }, [pollState, pushToast]); + + const currentStatus = + pollState.phase === "polling" || pollState.phase === "ready" ? pollState.order.status : null; + + // Dismissing hides the banner only for the status it was dismissed at — + // the moment status changes again (new information), it reappears + // automatically. Dismissing at "ready" hides it for good, since status + // never changes after that. + const isBannerDismissed = currentStatus !== null && dismissedAtStatus === currentStatus; + + function dismissBanner(): void { + if (currentStatus) setDismissedAtStatus(currentStatus); + } + + return ( + + {children} + + ); +} + +export function useOrderTracking(): OrderTrackingValue { + const ctx = useContext(OrderTrackingContext); + if (!ctx) throw new Error("useOrderTracking must be used within an OrderTrackingProvider"); + return ctx; +} diff --git a/frontend/src/hooks/useKitchenOrders.ts b/frontend/src/hooks/useKitchenOrders.ts new file mode 100644 index 0000000..7371116 --- /dev/null +++ b/frontend/src/hooks/useKitchenOrders.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from "react"; +import { getKitchenOrders } from "../api.ts"; +import type { KitchenOrder } from "../types.ts"; + +const POLL_INTERVAL_MS = 2000; + +export type KitchenOrdersState = + | { phase: "loading" } + | { phase: "loaded"; orders: KitchenOrder[] } + | { phase: "error"; message: string }; + +// Same safeguards as useOrderPolling (setTimeout-chain, AbortController + +// cancelled flag, no setState-after-unmount) but for an ongoing list with +// no terminal state — the dashboard polls for as long as it's mounted. +// Exposes `refetch` so a button click can update immediately instead of +// waiting for the next scheduled tick. +export function useKitchenOrders(): { state: KitchenOrdersState; refetch: () => void } { + const [state, setState] = useState({ phase: "loading" }); + const refetchRef = useRef<() => void>(() => {}); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + const controller = new AbortController(); + + async function poll(): Promise { + try { + const orders = await getKitchenOrders(controller.signal); + if (cancelled) return; + setState({ phase: "loaded", orders }); + } catch (err) { + if (cancelled) return; + setState({ phase: "error", message: err instanceof Error ? err.message : "unknown error" }); + } + + if (cancelled) return; + timer = setTimeout(poll, POLL_INTERVAL_MS); + } + + refetchRef.current = () => { + if (timer) clearTimeout(timer); + poll(); + }; + + poll(); + + return () => { + cancelled = true; + controller.abort(); + if (timer) clearTimeout(timer); + }; + }, []); + + return { state, refetch: () => refetchRef.current() }; +} diff --git a/frontend/src/hooks/useOrderPolling.ts b/frontend/src/hooks/useOrderPolling.ts index 343c486..8419877 100644 --- a/frontend/src/hooks/useOrderPolling.ts +++ b/frontend/src/hooks/useOrderPolling.ts @@ -3,7 +3,12 @@ import { getOrder, OrderNotFoundError } from "../api.ts"; import type { OrderDetail } from "../types.ts"; const POLL_INTERVAL_MS = 1500; -const TIMEOUT_MS = 60_000; +// 30 minutes, not 60 seconds — the kitchen workflow is human-paced now (a +// real person has to notice an order and click three buttons), not a +// 3-second simulated timer. A 60s giveup made sense for the old automatic +// flow; it doesn't for one where the customer might reasonably keep this +// tracked in the background while a real kitchen catches up. +const TIMEOUT_MS = 30 * 60_000; export type PollState = | { phase: "loading" } @@ -34,7 +39,7 @@ export function useOrderPolling(orderId: string): PollState { const [state, setState] = useState({ phase: "loading" }); useEffect(() => { - if (!orderId) return; // defensive only — react-router guarantees this route param is present + if (!orderId) return; // OrderStatusPage's route param is always present; OrderTrackingContext legitimately has none until an order is placed let cancelled = false; let timer: ReturnType | undefined; diff --git a/frontend/src/hooks/useToasts.ts b/frontend/src/hooks/useToasts.ts new file mode 100644 index 0000000..b791e14 --- /dev/null +++ b/frontend/src/hooks/useToasts.ts @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type Toast = { id: string; message: string }; + +const AUTO_DISMISS_MS = 5000; + +// A small, dependency-free notification queue. `pushToast` appends a +// message; it's removed automatically after AUTO_DISMISS_MS, or earlier via +// `dismiss`. Pending timers are tracked and cleared on unmount — the same +// "no setState after unmount" discipline as useOrderPolling, just for a +// list instead of a single value. +export function useToasts(): { toasts: Toast[]; pushToast: (message: string) => void; dismiss: (id: string) => void } { + const [toasts, setToasts] = useState([]); + const timers = useRef(new Map>()); + + useEffect(() => { + const map = timers.current; + return () => { + for (const timer of map.values()) clearTimeout(timer); + map.clear(); + }; + }, []); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + const timer = timers.current.get(id); + if (timer) { + clearTimeout(timer); + timers.current.delete(id); + } + }, []); + + const pushToast = useCallback( + (message: string) => { + const id = crypto.randomUUID(); + setToasts((prev) => [...prev, { id, message }]); + timers.current.set( + id, + setTimeout(() => dismiss(id), AUTO_DISMISS_MS), + ); + }, + [dismiss], + ); + + return { toasts, pushToast, dismiss }; +} diff --git a/frontend/src/lib/dishInfo.ts b/frontend/src/lib/dishInfo.ts new file mode 100644 index 0000000..6fa92ff --- /dev/null +++ b/frontend/src/lib/dishInfo.ts @@ -0,0 +1,20 @@ +// Cosmetic-only, client-side display info keyed by dish name — the backend +// has no concept of emoji/descriptions, it only ever returns +// {id, name, price_cents}. Keyed by lowercased name so a seed-data casing +// change doesn't silently break the lookup. Falls back to a generic plate +// for any dish not in this list, so a future menu change can never produce +// a broken-looking card — worst case it just shows a plain plate. +const DISH_INFO: Record = { + gyros: { emoji: "🥙", description: "Grilled meat, pita, tzatziki, tomato, onion" }, + souvlaki: { emoji: "🍢", description: "Skewered, charcoal-grilled and marinated in herbs" }, + "greek salad": { emoji: "🥗", description: "Tomato, cucumber, olives, feta, red onion" }, + moussaka: { emoji: "🍆", description: "Layered eggplant, spiced beef, béchamel" }, + spanakopita: { emoji: "🥟", description: "Spinach and feta wrapped in crisp phyllo" }, + baklava: { emoji: "🍯", description: "Layered phyllo, walnuts, honey syrup" }, +}; + +const FALLBACK = { emoji: "🍽️", description: "" }; + +export function getDishInfo(name: string): { emoji: string; description: string } { + return DISH_INFO[name.toLowerCase()] ?? FALLBACK; +} diff --git a/frontend/src/lib/orderHistory.ts b/frontend/src/lib/orderHistory.ts index 7deaaac..92e45b4 100644 --- a/frontend/src/lib/orderHistory.ts +++ b/frontend/src/lib/orderHistory.ts @@ -1,7 +1,38 @@ +import type { CartLine } from "../types.ts"; + const STORAGE_KEY = "quickbite.orderHistory"; const MAX_ENTRIES = 10; -export type HistoryEntry = { orderId: string; placedAt: string }; +// Stores a snapshot of what was actually submitted (items + total), not +// just the bare id — captured at placement time in MenuPage, from the same +// cart lines that were just sent to POST /api/orders. Lets the history +// page show something a human can read ("2x Gyros, 1x Greek Salad — $24.00") +// instead of a raw UUID, with zero extra network calls. +export type HistoryEntry = { + orderId: string; + placedAt: string; + items: CartLine[]; + totalCents: number; +}; + +// A real bug this caught: an earlier version of this app stored entries as +// just {orderId, placedAt}. A browser with THAT data already in localStorage +// loading THIS version would otherwise pass old-shape entries straight +// through — OrderHistoryPage's `entry.items.map(...)` would then throw on +// `items: undefined`, and with no error boundary, crash the whole page to +// blank white. Validate every entry's actual shape, not just "is it an +// array" — anything that doesn't match gets silently dropped, same as if it +// had never been there. +function isHistoryEntry(value: unknown): value is HistoryEntry { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.orderId === "string" && + typeof v.placedAt === "string" && + typeof v.totalCents === "number" && + Array.isArray(v.items) + ); +} // `storage` is injectable (defaults to the real localStorage) for the same // "no I/O" pure-unit-test reason as lib/customerId.ts. @@ -11,7 +42,8 @@ export function getOrderHistory(storage: Storage = localStorage): HistoryEntry[] try { const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; + if (!Array.isArray(parsed)) return []; + return parsed.filter(isHistoryEntry); } catch { // Corrupt/foreign localStorage content must never crash the app — treat // it the same as "no history yet". diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0e4abaf..c4f97d3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.tsx"; +import { ErrorBoundary } from "./ErrorBoundary.tsx"; import "./styles.css"; const root = document.getElementById("root"); @@ -8,6 +9,8 @@ if (!root) throw new Error("missing #root element in index.html"); createRoot(root).render( - + + + , ); diff --git a/frontend/src/pages/KitchenDashboardPage.tsx b/frontend/src/pages/KitchenDashboardPage.tsx new file mode 100644 index 0000000..906e762 --- /dev/null +++ b/frontend/src/pages/KitchenDashboardPage.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; +import { acceptOrder, markReady, startCooking } from "../api.ts"; +import { ToastList } from "../components/ToastList.tsx"; +import { useKitchenOrders } from "../hooks/useKitchenOrders.ts"; +import { useToasts } from "../hooks/useToasts.ts"; +import { formatCents } from "../lib/money.ts"; +import type { KitchenOrder } from "../types.ts"; + +const ACTION_LABEL: Record = { + pending: "Accept", + accepted: "Start cooking", + cooking: "Mark ready", +}; + +export function KitchenDashboardPage() { + const { state, refetch } = useKitchenOrders(); + const { toasts, pushToast, dismiss } = useToasts(); + const [actingOn, setActingOn] = useState(null); + + // `null` until the first real poll lands — that distinction matters: on + // the very first load, every order is "new" to the page, but none of + // them should toast (they were already there before staff opened the + // dashboard). Only orders that appear in a LATER poll are genuinely new. + const seenOrderIds = useRef | null>(null); + + useEffect(() => { + if (state.phase !== "loaded") return; + + if (seenOrderIds.current) { + for (const order of state.orders) { + if (!seenOrderIds.current.has(order.orderId)) { + pushToast(`🆕 New order: #${order.orderId.slice(0, 8)}`); + } + } + } + seenOrderIds.current = new Set(state.orders.map((o) => o.orderId)); + }, [state, pushToast]); + + async function handleAction(order: KitchenOrder): Promise { + setActingOn(order.orderId); + try { + if (order.status === "pending") await acceptOrder(order.orderId); + else if (order.status === "accepted") await startCooking(order.orderId); + else if (order.status === "cooking") await markReady(order.orderId); + refetch(); + } catch { + pushToast(`⚠️ Failed to update order #${order.orderId.slice(0, 8)}`); + } finally { + setActingOn(null); + } + } + + return ( +
    + +

    🍳 Kitchen Dashboard

    + + {state.phase === "loading" &&

    Loading orders…

    } + {state.phase === "error" &&

    Something went wrong: {state.message}

    } + {state.phase === "loaded" && state.orders.length === 0 &&

    No active orders.

    } + + {state.phase === "loaded" && ( +
      + {state.orders.map((order) => { + const label = ACTION_LABEL[order.status]; + return ( +
    • +
      + #{order.orderId.slice(0, 8)} + {order.status} +
      +
        + {order.items.map((item) => ( +
      • + {item.quantity} × {item.name} +
      • + ))} +
      +

      Total: ${formatCents(order.totalCents)}

      + {label ? ( + + ) : null} +
    • + ); + })} +
    + )} +
    + ); +} diff --git a/frontend/src/pages/MenuPage.tsx b/frontend/src/pages/MenuPage.tsx index 321fbf8..b482c12 100644 --- a/frontend/src/pages/MenuPage.tsx +++ b/frontend/src/pages/MenuPage.tsx @@ -3,12 +3,14 @@ import { useNavigate } from "react-router-dom"; import { ApiError, getMenu, placeOrder } from "../api.ts"; import { Cart } from "../components/Cart.tsx"; import { MenuList } from "../components/MenuList.tsx"; +import { useOrderTracking } from "../context/OrderTrackingContext.tsx"; import { getOrCreateCustomerId } from "../lib/customerId.ts"; import { addOrderToHistory } from "../lib/orderHistory.ts"; import type { CartLine, MenuItem } from "../types.ts"; export function MenuPage() { const navigate = useNavigate(); + const { setActiveOrderId } = useOrderTracking(); const [items, setItems] = useState(null); const [loadError, setLoadError] = useState(null); const [quantities, setQuantities] = useState>({}); @@ -52,8 +54,20 @@ export function MenuPage() { try { const customerId = getOrCreateCustomerId(); const summary = await placeOrder(customerId, lines); - addOrderToHistory({ orderId: summary.orderId, placedAt: new Date().toISOString() }); + // Snapshot of what was actually submitted — the same `lines` just sent + // to POST /api/orders — so the history page can show real item names + // and a total instead of a bare UUID, with no extra fetch needed. + addOrderToHistory({ + orderId: summary.orderId, + placedAt: new Date().toISOString(), + items: lines, + totalCents: summary.totalCents, + }); setQuantities({}); + // Makes the order trackable from EVERY page, not just the detail + // view we're about to navigate to — that's the actual fix for + // toasts/progress only showing up while pinned to /orders/:id. + setActiveOrderId(summary.orderId); navigate(`/orders/${summary.orderId}`); } catch (err) { setPlaceError(err instanceof ApiError ? err.message : "Failed to place order."); @@ -67,7 +81,8 @@ export function MenuPage() { return (
    -

    QuickBite

    +

    🫒 QuickBite Taverna

    +

    A taste of Greece, delivered fast.

    diff --git a/frontend/src/pages/OrderHistoryPage.tsx b/frontend/src/pages/OrderHistoryPage.tsx index 3a60aaf..26d09b6 100644 --- a/frontend/src/pages/OrderHistoryPage.tsx +++ b/frontend/src/pages/OrderHistoryPage.tsx @@ -1,4 +1,5 @@ import { Link } from "react-router-dom"; +import { formatCents } from "../lib/money.ts"; import { getOrderHistory } from "../lib/orderHistory.ts"; export function OrderHistoryPage() { @@ -8,12 +9,20 @@ export function OrderHistoryPage() { return (
      - {history.map((entry) => ( -
    • - {entry.orderId} - — placed {new Date(entry.placedAt).toLocaleString()} -
    • - ))} + {history.map((entry) => { + const summary = entry.items.map((item) => `${item.quantity}× ${item.name}`).join(", "); + return ( +
    • + + Order #{entry.orderId.slice(0, 8)} + {summary} + + ${formatCents(entry.totalCents)} · placed {new Date(entry.placedAt).toLocaleString()} + + +
    • + ); + })}
    ); } diff --git a/frontend/src/pages/OrderStatusPage.tsx b/frontend/src/pages/OrderStatusPage.tsx index 3da5cf0..a1de5be 100644 --- a/frontend/src/pages/OrderStatusPage.tsx +++ b/frontend/src/pages/OrderStatusPage.tsx @@ -2,6 +2,12 @@ import { Link, useParams } from "react-router-dom"; import { OrderStatus } from "../components/OrderStatus.tsx"; import { useOrderPolling } from "../hooks/useOrderPolling.ts"; +// This page is a generic "view any order's current status" view — reached +// either right after placing one, or later from order history. It no +// longer owns toast-announcing: that's OrderTrackingContext's job now, +// living above so it fires regardless of which page is mounted. +// This page would otherwise double-announce the same transitions for the +// active order. export function OrderStatusPage() { const { orderId } = useParams<{ orderId: string }>(); // The hook is always called, unconditionally, regardless of whether diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8f6402a..660e2bb 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,74 +1,406 @@ +:root { + --terracotta: #c1440e; + --terracotta-dark: #9c3409; + --olive: #6b7a3a; + --olive-dark: #4f5a2b; + --aegean: #1b4965; + --cream: #fff8e7; + --cream-dark: #f3e9d2; + --ink: #2c2418; +} + +* { + box-sizing: border-box; +} + body { - font-family: system-ui, sans-serif; - max-width: 640px; - margin: 2rem auto; - padding: 0 1rem; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--cream); + color: var(--ink); + max-width: 680px; + margin: 0 auto; + padding: 0 1rem 3rem; +} + +h1 { + font-size: 2.25rem; + margin-bottom: 0.25rem; + color: var(--terracotta-dark); } .app-nav { display: flex; - gap: 1rem; + gap: 1.5rem; + padding: 1.25rem 0; + margin-bottom: 1rem; + border-bottom: 3px solid var(--olive); +} + +.app-nav a { + color: var(--aegean); + font-weight: 600; + text-decoration: none; +} + +.app-nav a:hover { + text-decoration: underline; +} + +.menu-page__tagline { + color: var(--olive-dark); + margin-top: 0; margin-bottom: 1.5rem; + font-style: italic; } .menu-list { list-style: none; padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; } -.menu-list__item { +.menu-card { display: flex; - align-items: center; + align-items: flex-start; gap: 1rem; - padding: 0.5rem 0; - border-bottom: 1px solid #eee; + background: white; + border: 1px solid var(--cream-dark); + border-radius: 12px; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } -.menu-list__name { +.menu-card__emoji { + font-size: 2.5rem; + line-height: 1; +} + +.menu-card__body { flex: 1; } -.menu-list__stepper { +.menu-card__top { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 1.1rem; +} + +.menu-card__name { + font-weight: 700; +} + +.menu-card__price { + color: var(--terracotta-dark); + font-weight: 700; +} + +.menu-card__description { + color: #6b6354; + font-size: 0.9rem; + margin: 0.25rem 0 0.5rem; +} + +.menu-card__stepper { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; +} + +.menu-card__stepper button { + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 1px solid var(--olive); + background: var(--cream); + color: var(--olive-dark); + font-size: 1.1rem; + cursor: pointer; +} + +.menu-card__stepper button:disabled { + opacity: 0.4; + cursor: not-allowed; } .cart { margin-top: 2rem; - padding-top: 1rem; - border-top: 2px solid #333; + padding: 1.25rem; + background: var(--aegean); + color: white; + border-radius: 12px; +} + +.cart h2 { + margin-top: 0; +} + +.cart ul { + list-style: none; + padding: 0; +} + +.cart__total { + font-size: 1.25rem; + font-weight: 700; } .cart__error, .menu-page__error, .order-status__error { - color: #b00020; + color: #ffb4a2; + font-weight: 600; +} + +.cart button { + background: var(--terracotta); + color: white; + border: none; + border-radius: 8px; + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 700; + cursor: pointer; +} + +.cart button:hover:not(:disabled) { + background: var(--terracotta-dark); +} + +.error-boundary { + margin-top: 2rem; + padding: 1.25rem; + background: #ffe8e4; + border: 1px solid var(--terracotta); + border-radius: 12px; +} + +.error-boundary button { + margin-top: 0.5rem; + background: var(--terracotta); + color: white; + border: none; + border-radius: 8px; + padding: 0.5rem 1.25rem; + cursor: pointer; +} + +.cart button:disabled { + background: #8a8a8a; + cursor: not-allowed; } .order-status__timeout { - color: #b06000; + color: var(--terracotta-dark); + font-weight: 600; +} + +.order-status__ready-banner { + background: #e6f4ea; + border: 1px solid var(--olive); + border-radius: 8px; + padding: 0.75rem 1rem; + color: var(--olive-dark); + font-weight: 600; +} + +.order-status__live-note { + color: var(--aegean); + font-size: 0.9rem; +} + +.order-status__id { + font-family: monospace; + color: #6b6354; } .order-stages { display: flex; - gap: 1rem; + gap: 0.75rem; list-style: none; padding: 0; } .order-stages li { - padding: 0.25rem 0.75rem; - border: 1px solid #ccc; + padding: 0.4rem 1rem; + border: 1px solid var(--cream-dark); border-radius: 1rem; text-transform: capitalize; + background: white; } .order-stages li[data-done="true"] { - background: #e6f4ea; - border-color: #34a853; + background: var(--olive); + border-color: var(--olive-dark); + color: white; } .order-stages li[aria-current="true"] { font-weight: bold; + box-shadow: 0 0 0 2px var(--terracotta); +} + +.order-history { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.order-history__entry { + background: white; + border: 1px solid var(--cream-dark); + border-radius: 10px; +} + +.order-history__link { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.85rem 1rem; + text-decoration: none; + color: var(--ink); +} + +.order-history__id { + font-weight: 700; + color: var(--terracotta-dark); +} + +.order-history__summary { + color: var(--ink); +} + +.order-history__meta { + font-size: 0.85rem; + color: #6b6354; +} + +.toast-list { + position: fixed; + top: 1rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 100; + max-width: 320px; +} + +.toast { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + background: var(--aegean); + color: white; + border-radius: 8px; + padding: 0.6rem 0.9rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + animation: toast-in 0.15s ease-out; +} + +.toast button { + background: none; + border: none; + color: white; + font-size: 1.1rem; + cursor: pointer; + line-height: 1; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.kitchen-orders { + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.kitchen-order-card { + background: white; + border: 1px solid var(--cream-dark); + border-radius: 12px; + padding: 1rem; +} + +.kitchen-order-card__top { + display: flex; + justify-content: space-between; + font-weight: 700; +} + +.kitchen-order-card__id { + font-family: monospace; +} + +.kitchen-order-card__status { + text-transform: capitalize; + color: var(--terracotta-dark); +} + +.kitchen-order-card button { + background: var(--olive); + color: white; + border: none; + border-radius: 8px; + padding: 0.5rem 1.25rem; + font-weight: 700; + cursor: pointer; + margin-top: 0.5rem; +} + +.kitchen-order-card button:hover:not(:disabled) { + background: var(--olive-dark); +} + +.kitchen-order-card button:disabled { + background: #8a8a8a; + cursor: not-allowed; +} + +.active-order-banner { + background: white; + border: 2px solid var(--olive); + border-radius: 12px; + padding: 0.85rem 1rem; + margin-bottom: 1.5rem; +} + +.active-order-banner__top { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.active-order-banner__top button { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + color: var(--ink); + line-height: 1; +} + +.active-order-banner a { + display: inline-block; + margin-top: 0.5rem; + color: var(--aegean); + font-weight: 600; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da10416..4f6f0a2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -19,7 +19,7 @@ export type CartLine = { quantity: number; }; -export type OrderStatus = "placed" | "accepted" | "ready"; +export type OrderStatus = "placed" | "accepted" | "cooking" | "ready"; export type OrderSummary = { orderId: string; @@ -34,3 +34,20 @@ export type OrderDetail = { totalCents: number; items: CartLine[]; }; + +// Mirrors services/kitchen/src/db.ts#KitchenOrder exactly — its routes +// return the already-camelCased object from db.ts's own fromRow(), not the +// raw snake_case Postgres columns, so this is camelCase too (unlike +// MenuItem above, which IS the raw row shape since menu's route returns +// straight from sql` with no transform step). +export type KitchenOrder = { + orderId: string; + customerId: string; + items: CartLine[]; + totalCents: number; + etaMinutes: number | null; + status: "pending" | "accepted" | "cooking" | "ready" | string; + createdAt: string; + acceptedAt: string | null; + readyAt: string | null; +}; diff --git a/frontend/tests/components/ActiveOrderBanner.test.tsx b/frontend/tests/components/ActiveOrderBanner.test.tsx new file mode 100644 index 0000000..c472c7b --- /dev/null +++ b/frontend/tests/components/ActiveOrderBanner.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { ActiveOrderBanner } from "../../src/components/ActiveOrderBanner.tsx"; +import { OrderTrackingProvider } from "../../src/context/OrderTrackingContext.tsx"; +import { addOrderToHistory } from "../../src/lib/orderHistory.ts"; + +const ORDER_ID = "11111111-1111-1111-1111-111111111111"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function orderPayload(status: string) { + return { + orderId: ORDER_ID, + customerId: "cust-1", + status, + totalCents: 1100, + items: [{ menuItemId: "1", name: "Gyros", priceCents: 1100, quantity: 1 }], + }; +} + +function renderBanner() { + return render( + + + + + , + ); +} + +describe("ActiveOrderBanner", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test("renders nothing when there's no active order", () => { + renderBanner(); + expect(screen.queryByText(/Order #/)).toBeNull(); + }); + + test("shows the current stage for the active order — visible regardless of which page hosts it", async () => { + addOrderToHistory({ orderId: ORDER_ID, placedAt: new Date().toISOString(), items: [], totalCents: 1100 }); + global.fetch = (async () => jsonResponse(orderPayload("cooking"))) as typeof fetch; + + renderBanner(); + + await waitFor(() => expect(screen.getByText(`Order #${ORDER_ID.slice(0, 8)}`)).toBeDefined()); + expect(screen.getByText("cooking").getAttribute("aria-current")).toBe("true"); + }); + + test("clicking dismiss hides it", async () => { + addOrderToHistory({ orderId: ORDER_ID, placedAt: new Date().toISOString(), items: [], totalCents: 1100 }); + global.fetch = (async () => jsonResponse(orderPayload("accepted"))) as typeof fetch; + + renderBanner(); + await waitFor(() => expect(screen.getByText(`Order #${ORDER_ID.slice(0, 8)}`)).toBeDefined()); + + fireEvent.click(screen.getByLabelText("Dismiss order tracker")); + expect(screen.queryByText(`Order #${ORDER_ID.slice(0, 8)}`)).toBeNull(); + }); +}); diff --git a/frontend/tests/components/Cart.test.tsx b/frontend/tests/components/Cart.test.tsx index ff23003..89dcb20 100644 --- a/frontend/tests/components/Cart.test.tsx +++ b/frontend/tests/components/Cart.test.tsx @@ -3,8 +3,8 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { Cart } from "../../src/components/Cart.tsx"; const LINES = [ - { menuItemId: "1", name: "Margherita", priceCents: 1200, quantity: 2 }, - { menuItemId: "2", name: "Pepperoni", priceCents: 1400, quantity: 1 }, + { menuItemId: "1", name: "Gyros", priceCents: 1200, quantity: 2 }, + { menuItemId: "2", name: "Souvlaki", priceCents: 1400, quantity: 1 }, ]; describe("Cart", () => { diff --git a/frontend/tests/components/ErrorBoundary.test.tsx b/frontend/tests/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..d97a4b0 --- /dev/null +++ b/frontend/tests/components/ErrorBoundary.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { render, screen } from "@testing-library/react"; +import { ErrorBoundary } from "../../src/ErrorBoundary.tsx"; + +function Bomb(): never { + throw new Error("boom"); +} + +describe("ErrorBoundary", () => { + test("renders children normally when nothing throws", () => { + render( + +

    all good

    +
    , + ); + expect(screen.getByText("all good")).toBeDefined(); + }); + + test("shows a recoverable message instead of a blank page when a child throws", () => { + render( + + + , + ); + expect(screen.getByText("Something went wrong displaying this page.")).toBeDefined(); + expect(screen.getByText("Try again")).toBeDefined(); + }); +}); diff --git a/frontend/tests/components/KitchenDashboardPage.test.tsx b/frontend/tests/components/KitchenDashboardPage.test.tsx new file mode 100644 index 0000000..999204e --- /dev/null +++ b/frontend/tests/components/KitchenDashboardPage.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { KitchenDashboardPage } from "../../src/pages/KitchenDashboardPage.tsx"; + +const PENDING_ORDER = { + orderId: "11111111-1111-1111-1111-111111111111", + customerId: "cust-1", + items: [{ menuItemId: "1", name: "Gyros", priceCents: 1100, quantity: 1 }], + totalCents: 1100, + etaMinutes: null, + status: "pending", + createdAt: new Date().toISOString(), + acceptedAt: null, + readyAt: null, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +describe("KitchenDashboardPage", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + test("shows the order and the right action button for its status", async () => { + global.fetch = (async () => jsonResponse([PENDING_ORDER])) as typeof fetch; + + render(); + + await waitFor(() => expect(screen.getByText("Accept")).toBeDefined()); + expect(screen.getByText("#11111111")).toBeDefined(); + expect(screen.getByText("1 × Gyros")).toBeDefined(); + }); + + test("clicking Accept calls the accept endpoint", async () => { + let acceptCalled = false; + global.fetch = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/accept")) { + acceptCalled = true; + return jsonResponse({ ...PENDING_ORDER, status: "accepted" }); + } + return jsonResponse([PENDING_ORDER]); + }) as typeof fetch; + + render(); + await waitFor(() => expect(screen.getByText("Accept")).toBeDefined()); + + fireEvent.click(screen.getByText("Accept")); + await waitFor(() => expect(acceptCalled).toBe(true)); + }); + + test("shows an error message if loading orders fails, never a blank page", async () => { + global.fetch = (async () => jsonResponse({ error: "boom" }, 500)) as typeof fetch; + + render(); + + await waitFor(() => expect(screen.getByText(/Something went wrong/)).toBeDefined()); + }); + + test("shows a message when there are no active orders", async () => { + global.fetch = (async () => jsonResponse([])) as typeof fetch; + + render(); + + await waitFor(() => expect(screen.getByText("No active orders.")).toBeDefined()); + }); +}); diff --git a/frontend/tests/components/MenuList.test.tsx b/frontend/tests/components/MenuList.test.tsx index f7e5bfa..c8c4a74 100644 --- a/frontend/tests/components/MenuList.test.tsx +++ b/frontend/tests/components/MenuList.test.tsx @@ -3,17 +3,31 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { MenuList } from "../../src/components/MenuList.tsx"; const ITEMS = [ - { id: "1", name: "Margherita", price_cents: 1200 }, - { id: "2", name: "Pepperoni", price_cents: 1400 }, + { id: "1", name: "Gyros", price_cents: 1100 }, + { id: "2", name: "Souvlaki", price_cents: 1300 }, ]; describe("MenuList", () => { - test("renders every item with its formatted price", () => { + test("renders every item with its formatted price, emoji, and description", () => { render( {}} />); - expect(screen.getByText("Margherita")).toBeDefined(); - expect(screen.getByText("$12.00")).toBeDefined(); - expect(screen.getByText("Pepperoni")).toBeDefined(); - expect(screen.getByText("$14.00")).toBeDefined(); + expect(screen.getByText("Gyros")).toBeDefined(); + expect(screen.getByText("$11.00")).toBeDefined(); + expect(screen.getByText("Souvlaki")).toBeDefined(); + expect(screen.getByText("$13.00")).toBeDefined(); + expect(screen.getByText("🥙")).toBeDefined(); + expect(screen.getByText(/tzatziki/)).toBeDefined(); + }); + + test("falls back to a generic plate emoji for an unknown dish, never breaks", () => { + render( + {}} + />, + ); + expect(screen.getByText("Mystery Dish")).toBeDefined(); + expect(screen.getByText("🍽️")).toBeDefined(); }); test("the decrease button is disabled at zero quantity", () => { @@ -24,10 +38,8 @@ describe("MenuList", () => { test("clicking increase calls onChangeQuantity with quantity + 1", () => { let lastCall: [string, number] | null = null; - render( - (lastCall = [id, qty])} />, - ); - fireEvent.click(screen.getByLabelText("Increase Margherita quantity")); + render( (lastCall = [id, qty])} />); + fireEvent.click(screen.getByLabelText("Increase Gyros quantity")); expect(lastCall).toEqual(["1", 3]); }); }); diff --git a/frontend/tests/components/OrderHistoryPage.test.tsx b/frontend/tests/components/OrderHistoryPage.test.tsx new file mode 100644 index 0000000..2fdb9b9 --- /dev/null +++ b/frontend/tests/components/OrderHistoryPage.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { OrderHistoryPage } from "../../src/pages/OrderHistoryPage.tsx"; +import { addOrderToHistory } from "../../src/lib/orderHistory.ts"; + +describe("OrderHistoryPage", () => { + test("shows a message when there's no history yet", () => { + localStorage.clear(); + render(, { wrapper: MemoryRouter }); + expect(screen.getByText("No past orders yet on this browser.")).toBeDefined(); + }); + + test("shows a readable summary instead of a raw UUID", () => { + localStorage.clear(); + addOrderToHistory({ + orderId: "11111111-2222-3333-4444-555555555555", + placedAt: "2026-01-01T12:00:00Z", + items: [ + { menuItemId: "1", name: "Gyros", priceCents: 1100, quantity: 2 }, + { menuItemId: "2", name: "Greek Salad", priceCents: 900, quantity: 1 }, + ], + totalCents: 3100, + }); + + render(, { wrapper: MemoryRouter }); + + // Shortened order number, not the full UUID. + expect(screen.getByText("Order #11111111")).toBeDefined(); + expect(screen.queryByText("11111111-2222-3333-4444-555555555555")).toBeNull(); + + expect(screen.getByText("2× Gyros, 1× Greek Salad")).toBeDefined(); + expect(screen.getByText(/\$31\.00/)).toBeDefined(); + }); +}); diff --git a/frontend/tests/components/OrderStatus.test.tsx b/frontend/tests/components/OrderStatus.test.tsx index 66b4040..db3091a 100644 --- a/frontend/tests/components/OrderStatus.test.tsx +++ b/frontend/tests/components/OrderStatus.test.tsx @@ -7,7 +7,7 @@ const ORDER = { orderId: "abc-123", customerId: "cust-1", totalCents: 1200, - items: [{ menuItemId: "1", name: "Margherita", priceCents: 1200, quantity: 1 }], + items: [{ menuItemId: "1", name: "Gyros", priceCents: 1200, quantity: 1 }], }; describe("OrderStatus", () => { @@ -16,18 +16,29 @@ describe("OrderStatus", () => { expect(screen.getByText("Loading order…")).toBeDefined(); }); - test("marks the current stage when polling", () => { + test("marks the current stage when polling, and explains it's auto-updating", () => { const state: PollState = { phase: "polling", order: { ...ORDER, status: "accepted" } }; render(); const accepted = screen.getByText("accepted"); expect(accepted.getAttribute("aria-current")).toBe("true"); + expect(screen.getByText(/Checking for updates every 1.5s/)).toBeDefined(); }); - test("shows the ready stage and order details once ready", () => { + test("marks the cooking stage as a distinct step between accepted and ready", () => { + const state: PollState = { phase: "polling", order: { ...ORDER, status: "cooking" } }; + render(); + const cooking = screen.getByText("cooking"); + expect(cooking.getAttribute("aria-current")).toBe("true"); + expect(screen.getByText("accepted").getAttribute("data-done")).toBe("true"); + expect(screen.getByText("ready").getAttribute("data-done")).toBe("false"); + }); + + test("shows the ready stage, order details, and an explanatory banner once ready", () => { const state: PollState = { phase: "ready", order: { ...ORDER, status: "ready" } }; render(); expect(screen.getByText("Order #abc-123")).toBeDefined(); expect(screen.getByText("ready").getAttribute("aria-current")).toBe("true"); + expect(screen.getByText(/published an event over RabbitMQ/)).toBeDefined(); }); test("shows an explicit message on timeout, never a silent spinner", () => { diff --git a/frontend/tests/context/OrderTrackingContext.test.tsx b/frontend/tests/context/OrderTrackingContext.test.tsx new file mode 100644 index 0000000..146556e --- /dev/null +++ b/frontend/tests/context/OrderTrackingContext.test.tsx @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { OrderTrackingProvider, useOrderTracking } from "../../src/context/OrderTrackingContext.tsx"; +import { addOrderToHistory } from "../../src/lib/orderHistory.ts"; + +const ORDER_ID = "11111111-1111-1111-1111-111111111111"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function orderPayload(status: string) { + return { + orderId: ORDER_ID, + customerId: "cust-1", + status, + totalCents: 1100, + items: [{ menuItemId: "1", name: "Gyros", priceCents: 1100, quantity: 1 }], + }; +} + +describe("OrderTrackingProvider", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test("seeds activeOrderId from the most recent history entry on mount", async () => { + addOrderToHistory({ orderId: ORDER_ID, placedAt: new Date().toISOString(), items: [], totalCents: 1100 }); + global.fetch = (async () => jsonResponse(orderPayload("accepted"))) as typeof fetch; + + const { result } = renderHook(() => useOrderTracking(), { wrapper: OrderTrackingProvider }); + + expect(result.current.activeOrderId).toBe(ORDER_ID); + await waitFor(() => expect(result.current.pollState.phase).toBe("polling")); + }); + + test("has no active order when history is empty — this is what would have caused the original bug if untested", async () => { + const { result } = renderHook(() => useOrderTracking(), { wrapper: OrderTrackingProvider }); + expect(result.current.activeOrderId).toBeNull(); + }); + + test("fires a toast when the polled status changes — this is the actual fix for missing cooking/ready notices", async () => { + addOrderToHistory({ orderId: ORDER_ID, placedAt: new Date().toISOString(), items: [], totalCents: 1100 }); + global.fetch = (async () => jsonResponse(orderPayload("cooking"))) as typeof fetch; + + const { result } = renderHook(() => useOrderTracking(), { wrapper: OrderTrackingProvider }); + + await waitFor(() => expect(result.current.toasts.length).toBeGreaterThan(0)); + expect(result.current.toasts[0]?.message).toMatch(/cooking/i); + }); + + test("dismissBanner hides the banner only for the status it was dismissed at", async () => { + addOrderToHistory({ orderId: ORDER_ID, placedAt: new Date().toISOString(), items: [], totalCents: 1100 }); + global.fetch = (async () => jsonResponse(orderPayload("accepted"))) as typeof fetch; + + const { result } = renderHook(() => useOrderTracking(), { wrapper: OrderTrackingProvider }); + await waitFor(() => expect(result.current.pollState.phase).toBe("polling")); + + expect(result.current.isBannerDismissed).toBe(false); + act(() => result.current.dismissBanner()); + expect(result.current.isBannerDismissed).toBe(true); + }); + + test("setActiveOrderId resets the dismissed state for a freshly placed order", async () => { + addOrderToHistory({ orderId: ORDER_ID, placedAt: new Date().toISOString(), items: [], totalCents: 1100 }); + global.fetch = (async () => jsonResponse(orderPayload("accepted"))) as typeof fetch; + + const { result } = renderHook(() => useOrderTracking(), { wrapper: OrderTrackingProvider }); + await waitFor(() => expect(result.current.pollState.phase).toBe("polling")); + + act(() => result.current.dismissBanner()); + expect(result.current.isBannerDismissed).toBe(true); + + const newOrderId = "22222222-2222-2222-2222-222222222222"; + act(() => result.current.setActiveOrderId(newOrderId)); + expect(result.current.activeOrderId).toBe(newOrderId); + expect(result.current.isBannerDismissed).toBe(false); + }); +}); diff --git a/frontend/tests/hooks/useToasts.test.tsx b/frontend/tests/hooks/useToasts.test.tsx new file mode 100644 index 0000000..d51ade7 --- /dev/null +++ b/frontend/tests/hooks/useToasts.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { act, renderHook } from "@testing-library/react"; +import { useToasts } from "../../src/hooks/useToasts.ts"; + +describe("useToasts", () => { + test("pushToast adds a toast", () => { + const { result } = renderHook(() => useToasts()); + + act(() => result.current.pushToast("hello")); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0]?.message).toBe("hello"); + }); + + test("dismiss removes a specific toast, not all of them", () => { + const { result } = renderHook(() => useToasts()); + + act(() => result.current.pushToast("first")); + act(() => result.current.pushToast("second")); + const firstId = result.current.toasts[0]!.id; + + act(() => result.current.dismiss(firstId)); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0]?.message).toBe("second"); + }); + + test("multiple pushes produce independent toasts with distinct ids", () => { + const { result } = renderHook(() => useToasts()); + + act(() => { + result.current.pushToast("a"); + result.current.pushToast("b"); + }); + + expect(result.current.toasts).toHaveLength(2); + expect(result.current.toasts[0]?.id).not.toBe(result.current.toasts[1]?.id); + }); +}); diff --git a/frontend/tests/lib/dishInfo.test.ts b/frontend/tests/lib/dishInfo.test.ts new file mode 100644 index 0000000..9362b48 --- /dev/null +++ b/frontend/tests/lib/dishInfo.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import { getDishInfo } from "../../src/lib/dishInfo.ts"; + +describe("getDishInfo", () => { + test("returns known emoji/description for a known dish", () => { + expect(getDishInfo("Gyros")).toEqual({ emoji: "🥙", description: "Grilled meat, pita, tzatziki, tomato, onion" }); + }); + + test("is case-insensitive", () => { + expect(getDishInfo("GREEK SALAD").emoji).toBe("🥗"); + }); + + test("falls back to a generic plate for an unknown dish, never throws", () => { + expect(getDishInfo("Mystery Dish")).toEqual({ emoji: "🍽️", description: "" }); + }); +}); diff --git a/frontend/tests/lib/orderHistory.test.ts b/frontend/tests/lib/orderHistory.test.ts index 7627c56..52f259d 100644 --- a/frontend/tests/lib/orderHistory.test.ts +++ b/frontend/tests/lib/orderHistory.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; import { addOrderToHistory, getOrderHistory } from "../../src/lib/orderHistory.ts"; +import type { CartLine } from "../../src/types.ts"; + +const ITEMS: CartLine[] = [{ menuItemId: "1", name: "Gyros", priceCents: 1100, quantity: 2 }]; function fakeStorage(): Storage { const map = new Map(); @@ -25,31 +28,56 @@ describe("getOrderHistory", () => { storage.setItem("quickbite.orderHistory", "{not json"); expect(getOrderHistory(storage)).toEqual([]); }); + + test("drops old-shape entries (no items/totalCents) instead of crashing — regression for a real bug", () => { + const storage = fakeStorage(); + // This is exactly what an earlier version of addOrderToHistory wrote — + // a browser that already had this in localStorage before the items/ + // totalCents fields were added must not crash on it. + storage.setItem("quickbite.orderHistory", JSON.stringify([{ orderId: "old", placedAt: "2026-01-01T00:00:00Z" }])); + expect(getOrderHistory(storage)).toEqual([]); + }); + + test("keeps valid entries and drops only the malformed ones from a mixed list", () => { + const storage = fakeStorage(); + const valid = { orderId: "valid", placedAt: "2026-01-01T00:00:00Z", items: ITEMS, totalCents: 2200 }; + storage.setItem("quickbite.orderHistory", JSON.stringify([valid, { orderId: "old", placedAt: "x" }])); + expect(getOrderHistory(storage)).toEqual([valid]); + }); }); describe("addOrderToHistory", () => { - test("adds the most recent order to the front", () => { + test("adds the most recent order to the front, keeping its items/total snapshot", () => { const storage = fakeStorage(); - addOrderToHistory({ orderId: "a", placedAt: "2026-01-01T00:00:00Z" }, storage); - const updated = addOrderToHistory({ orderId: "b", placedAt: "2026-01-02T00:00:00Z" }, storage); + addOrderToHistory({ orderId: "a", placedAt: "2026-01-01T00:00:00Z", items: ITEMS, totalCents: 2200 }, storage); + const updated = addOrderToHistory( + { orderId: "b", placedAt: "2026-01-02T00:00:00Z", items: ITEMS, totalCents: 2200 }, + storage, + ); expect(updated.map((e) => e.orderId)).toEqual(["b", "a"]); + expect(updated[0]?.items).toEqual(ITEMS); + expect(updated[0]?.totalCents).toBe(2200); }); test("de-duplicates by orderId, moving the existing entry to the front", () => { const storage = fakeStorage(); - addOrderToHistory({ orderId: "a", placedAt: "2026-01-01T00:00:00Z" }, storage); - addOrderToHistory({ orderId: "b", placedAt: "2026-01-02T00:00:00Z" }, storage); - const updated = addOrderToHistory({ orderId: "a", placedAt: "2026-01-03T00:00:00Z" }, storage); - expect(updated).toEqual([ - { orderId: "a", placedAt: "2026-01-03T00:00:00Z" }, - { orderId: "b", placedAt: "2026-01-02T00:00:00Z" }, - ]); + addOrderToHistory({ orderId: "a", placedAt: "2026-01-01T00:00:00Z", items: ITEMS, totalCents: 2200 }, storage); + addOrderToHistory({ orderId: "b", placedAt: "2026-01-02T00:00:00Z", items: ITEMS, totalCents: 2200 }, storage); + const updated = addOrderToHistory( + { orderId: "a", placedAt: "2026-01-03T00:00:00Z", items: ITEMS, totalCents: 2200 }, + storage, + ); + expect(updated.map((e) => e.orderId)).toEqual(["a", "b"]); + expect(updated[0]?.placedAt).toBe("2026-01-03T00:00:00Z"); }); test("caps history at 10 entries", () => { const storage = fakeStorage(); for (let i = 0; i < 12; i++) { - addOrderToHistory({ orderId: `order-${i}`, placedAt: new Date().toISOString() }, storage); + addOrderToHistory( + { orderId: `order-${i}`, placedAt: new Date().toISOString(), items: ITEMS, totalCents: 2200 }, + storage, + ); } expect(getOrderHistory(storage)).toHaveLength(10); }); diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 601edce..8cef80a 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -11,6 +11,7 @@ export const EXCHANGE = "quickbite.events"; export const EventName = { OrderPlaced: "order.placed", OrderAccepted: "order.accepted", + OrderCooking: "order.cooking", OrderReady: "order.ready", } as const; export type EventName = (typeof EventName)[keyof typeof EventName]; @@ -45,6 +46,12 @@ export const OrderAccepted = envelope.extend({ }); export type OrderAccepted = z.infer; +export const OrderCooking = envelope.extend({ + type: z.literal(EventName.OrderCooking), + orderId: z.string().uuid(), +}); +export type OrderCooking = z.infer; + export const OrderReady = envelope.extend({ type: z.literal(EventName.OrderReady), orderId: z.string().uuid(), @@ -55,5 +62,6 @@ export type OrderReady = z.infer; export const eventSchemas = { [EventName.OrderPlaced]: OrderPlaced, [EventName.OrderAccepted]: OrderAccepted, + [EventName.OrderCooking]: OrderCooking, [EventName.OrderReady]: OrderReady, } as const; diff --git a/packages/shared/tests/events.test.ts b/packages/shared/tests/events.test.ts index c48fc7f..5b4a624 100644 --- a/packages/shared/tests/events.test.ts +++ b/packages/shared/tests/events.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { OrderPlaced, EventName } from "../src/events"; +import { OrderCooking, OrderPlaced, EventName } from "../src/events"; test("valid order.placed event passes schema", () => { const event = { @@ -33,3 +33,23 @@ test("order.placed with empty items is rejected", () => { }; expect(() => OrderPlaced.parse(bad)).toThrow(); }); + +test("valid order.cooking event passes schema", () => { + const event = { + type: EventName.OrderCooking, + eventId: "11111111-1111-1111-1111-111111111111", + occurredAt: new Date().toISOString(), + orderId: "22222222-2222-2222-2222-222222222222", + }; + expect(() => OrderCooking.parse(event)).not.toThrow(); +}); + +test("order.cooking with the wrong type literal is rejected", () => { + const bad = { + type: EventName.OrderReady, + eventId: "11111111-1111-1111-1111-111111111111", + occurredAt: new Date().toISOString(), + orderId: "22222222-2222-2222-2222-222222222222", + }; + expect(() => OrderCooking.parse(bad)).toThrow(); +}); diff --git a/services/gateway/src/index.ts b/services/gateway/src/index.ts index 862e064..b89c8a5 100644 --- a/services/gateway/src/index.ts +++ b/services/gateway/src/index.ts @@ -3,6 +3,7 @@ import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; const PORT = Number(process.env.PORT ?? 3000); const MENU_URL = process.env.MENU_URL ?? "http://localhost:3001"; const ORDER_URL = process.env.ORDER_URL ?? "http://localhost:3002"; +const KITCHEN_URL = process.env.KITCHEN_URL ?? "http://localhost:3003"; const app = Fastify({ logger: true }); @@ -20,10 +21,18 @@ function proxyTo(prefix: string, upstreamBase: string, rewriteTo: string) { const suffix = req.url.slice(prefix.length); const targetUrl = `${upstreamBase}${rewriteTo}${suffix}`; - const hasBody = req.method !== "GET" && req.method !== "HEAD"; + // req.body is undefined for a request with no body at all (e.g. the + // kitchen dashboard's bodyless POST /accept,/start-cooking,/ready + // actions) — every prior POST endpoint here (place an order) always + // sent a real JSON payload, so forcing this header unconditionally + // never surfaced a problem until those bodyless routes existed. + // Sending content-type: application/json with NO body makes the + // upstream Fastify server reject it outright (FST_ERR_CTP_EMPTY_JSON_BODY) + // before the route handler ever runs. + const hasBody = req.method !== "GET" && req.method !== "HEAD" && req.body !== undefined; const upstreamRes = await fetch(targetUrl, { method: req.method, - headers: { "content-type": "application/json" }, + headers: hasBody ? { "content-type": "application/json" } : undefined, body: hasBody ? JSON.stringify(req.body) : undefined, }); @@ -45,6 +54,16 @@ const orderProxy = proxyTo("/api/orders", ORDER_URL, "/orders"); app.all("/api/orders", orderProxy); app.all("/api/orders/*", orderProxy); +// "" not "/orders" here — unlike /api/orders (whose gateway segment happens +// to equal the order service's own route name, so the suffix needs that +// name re-added), /api/kitchen is a DIFFERENT segment than kitchen's own +// "/orders" routes. After stripping "/api/kitchen", the suffix already IS +// the full upstream path (e.g. "/orders//accept") — re-adding "/orders" +// here would double it up. +const kitchenProxy = proxyTo("/api/kitchen", KITCHEN_URL, ""); +app.all("/api/kitchen", kitchenProxy); +app.all("/api/kitchen/*", kitchenProxy); + app.listen({ port: PORT, host: "0.0.0.0" }).then(() => { app.log.info(`gateway listening on ${PORT}`); }); diff --git a/services/kitchen/src/db.ts b/services/kitchen/src/db.ts index 3a3a7a6..bb97896 100644 --- a/services/kitchen/src/db.ts +++ b/services/kitchen/src/db.ts @@ -1,58 +1,187 @@ import postgres from "postgres"; +import { EventName, type OrderItem } from "@quickbite/shared"; // Step 1: Connect to this service's own Postgres database (kitchen_db). const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://quickbite:quickbite@localhost:5432/kitchen_db"; const sql = postgres(DATABASE_URL); -// Step 2: Ensure the table exists. ready_at is nullable — it's only set -// once the order actually becomes ready, not at accept time. +// Step 2: Ensure the table exists. eta_minutes/accepted_at are nullable — +// unknown until a human actually accepts the order. customer_id/items/ +// total_cents are kitchen's own snapshot of the order.placed payload it +// already receives — kitchen still owns only its own data, this just lets +// the dashboard render real order contents without reading order_db. async function ensureSchema(): Promise { await sql` CREATE TABLE IF NOT EXISTS kitchen_orders ( order_id uuid PRIMARY KEY, - eta_minutes int NOT NULL, + customer_id uuid NOT NULL, + items jsonb NOT NULL, + total_cents int NOT NULL, + eta_minutes int, status text NOT NULL, - accepted_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + accepted_at timestamptz, ready_at timestamptz ) `; + // Same outbox pattern as order_db — see services/order/src/db.ts. Now + // that accept/start-cooking/ready are HTTP-triggered actions (not an + // event-handler chaining straight into another publish), they get the + // same durability guarantee: an event can only ever exist if the status + // change that caused it actually committed. + await sql` + CREATE TABLE IF NOT EXISTS outbox ( + id uuid PRIMARY KEY, + routing_key text NOT NULL, + payload jsonb NOT NULL, + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() + ) + `; } await ensureSchema(); -// Step 3: Record acceptance. The order_id primary key means a redelivered -// order.placed for an order we've already accepted would throw here instead -// of silently double-inserting — a useful second layer behind the in-memory -// idempotency check, not a replacement for it (that's Phase 3's job). -export async function acceptOrder(orderId: string, etaMinutes: number): Promise { +export type KitchenOrder = { + orderId: string; + customerId: string; + items: OrderItem[]; + totalCents: number; + etaMinutes: number | null; + status: string; + createdAt: Date; + acceptedAt: Date | null; + readyAt: Date | null; +}; + +type Row = { + order_id: string; + customer_id: string; + items: OrderItem[]; + total_cents: number; + eta_minutes: number | null; + status: string; + created_at: Date; + accepted_at: Date | null; + ready_at: Date | null; +}; + +function fromRow(row: Row): KitchenOrder { + return { + orderId: row.order_id, + customerId: row.customer_id, + items: row.items, + totalCents: row.total_cents, + etaMinutes: row.eta_minutes, + status: row.status, + createdAt: row.created_at, + acceptedAt: row.accepted_at, + readyAt: row.ready_at, + }; +} + +// Step 3: Record that an order arrived — no event to publish here, this is +// just kitchen learning about it. The order_id primary key means a +// redelivered order.placed for an order kitchen already knows about throws +// here instead of silently double-inserting — a second layer behind the +// idempotency check in index.ts, not a replacement for it. +export async function createPendingOrder( + orderId: string, + customerId: string, + items: OrderItem[], + totalCents: number, +): Promise { await sql` - INSERT INTO kitchen_orders (order_id, eta_minutes, status) - VALUES (${orderId}, ${etaMinutes}, 'accepted') + INSERT INTO kitchen_orders (order_id, customer_id, items, total_cents, status) + VALUES (${orderId}, ${customerId}, ${sql.json(items)}, ${totalCents}, 'pending') `; } -// Step 4: Record readiness on the same row. +// Step 4: Each staff action below is transactional — the status UPDATE and +// the outbox INSERT commit together, exactly like services/order/src/db.ts#createOrder. +// A publish can only ever happen for a status change that actually stuck. +export async function acceptOrder(orderId: string, etaMinutes: number): Promise { + await sql.begin(async (tx) => { + await tx` + UPDATE kitchen_orders SET status = 'accepted', eta_minutes = ${etaMinutes}, accepted_at = now() + WHERE order_id = ${orderId} + `; + await tx` + INSERT INTO outbox (id, routing_key, payload) + VALUES (${crypto.randomUUID()}, ${EventName.OrderAccepted}, ${tx.json({ + type: EventName.OrderAccepted, + eventId: crypto.randomUUID(), + occurredAt: new Date().toISOString(), + orderId, + etaMinutes, + })}) + `; + }); +} + +export async function startCooking(orderId: string): Promise { + await sql.begin(async (tx) => { + await tx`UPDATE kitchen_orders SET status = 'cooking' WHERE order_id = ${orderId}`; + await tx` + INSERT INTO outbox (id, routing_key, payload) + VALUES (${crypto.randomUUID()}, ${EventName.OrderCooking}, ${tx.json({ + type: EventName.OrderCooking, + eventId: crypto.randomUUID(), + occurredAt: new Date().toISOString(), + orderId, + })}) + `; + }); +} + export async function markOrderReady(orderId: string): Promise { - await sql` - UPDATE kitchen_orders - SET status = 'ready', ready_at = now() - WHERE order_id = ${orderId} + await sql.begin(async (tx) => { + await tx`UPDATE kitchen_orders SET status = 'ready', ready_at = now() WHERE order_id = ${orderId}`; + await tx` + INSERT INTO outbox (id, routing_key, payload) + VALUES (${crypto.randomUUID()}, ${EventName.OrderReady}, ${tx.json({ + type: EventName.OrderReady, + eventId: crypto.randomUUID(), + occurredAt: new Date().toISOString(), + orderId, + })}) + `; + }); +} + +// Step 5: Read a single row back, same convention as order's getOrderById — +// null on a miss. +export async function getKitchenOrder(orderId: string): Promise { + const [row] = await sql<[Row?]>`SELECT * FROM kitchen_orders WHERE order_id = ${orderId}`; + return row ? fromRow(row) : null; +} + +// Step 6: For the dashboard — everything not yet ready, oldest first, so +// staff naturally work through orders in the order they arrived. +export async function listActiveOrders(): Promise { + const rows = await sql` + SELECT * FROM kitchen_orders WHERE status != 'ready' ORDER BY created_at `; + return rows.map(fromRow); } -export type KitchenOrder = { - orderId: string; - etaMinutes: number; - status: string; - readyAt: Date | null; +export type OutboxRow = { + id: string; + routingKey: string; + payload: unknown; }; -// Step 5: Read a row back. Returns null on a miss, same convention as -// order service's getOrderById. -export async function getKitchenOrder(orderId: string): Promise { - const [row] = await sql<[{ order_id: string; eta_minutes: number; status: string; ready_at: Date | null }?]>` - SELECT order_id, eta_minutes, status, ready_at FROM kitchen_orders WHERE order_id = ${orderId} +// Same as services/order/src/db.ts — read by outbox.ts's poller. +export async function getUnpublishedOutboxRows(limit = 20): Promise { + const rows = await sql<{ id: string; routing_key: string; payload: unknown }[]>` + SELECT id, routing_key, payload FROM outbox + WHERE published_at IS NULL + ORDER BY created_at + LIMIT ${limit} `; - if (!row) return null; - return { orderId: row.order_id, etaMinutes: row.eta_minutes, status: row.status, readyAt: row.ready_at }; + return rows.map((row) => ({ id: row.id, routingKey: row.routing_key, payload: row.payload })); +} + +export async function markOutboxPublished(id: string): Promise { + await sql`UPDATE outbox SET published_at = now() WHERE id = ${id}`; } diff --git a/services/kitchen/src/index.ts b/services/kitchen/src/index.ts index dc6dcc9..54cb063 100644 --- a/services/kitchen/src/index.ts +++ b/services/kitchen/src/index.ts @@ -1,25 +1,27 @@ -import Fastify, { type FastifyInstance } from "fastify"; -import { - connect, consume, publish, RedisIdempotency, - EventName, type OrderPlaced, -} from "@quickbite/shared"; -import { acceptOrder, markOrderReady } from "./db.ts"; +import { connect, consume, EventName, RedisIdempotency, type OrderPlaced } from "@quickbite/shared"; +import { createPendingOrder } from "./db.ts"; +import { startOutboxPoller } from "./outbox.ts"; +import { buildServer } from "./server.ts"; const PORT = Number(process.env.PORT ?? 3003); const RABBITMQ_URL = process.env.RABBITMQ_URL ?? "amqp://guest:guest@localhost:5672"; -// Step 1: Build the server as its own function, separate from starting it. -async function buildServer(): Promise { - const server = Fastify({ logger: true }); - server.get("/health", async () => ({ status: "ok", service: "kitchen" })); - return server; -} - -// Step 2: Connect to RabbitMQ, then subscribe to order.placed. +// Step 1: Connect to RabbitMQ, start the outbox poller, subscribe to +// order.placed. Top-level await, not a .then() chain — a test (or anything +// else) that imports this module needs the consumer to actually be +// registered by the time the import resolves, or an event published right +// after import has nowhere to land (a topic exchange silently drops a +// message with no matching binding yet — not a retryable failure, a lost +// one). Only the HTTP listen below is allowed to stay fire-and-forget, +// since nothing's timing depends on it. const { channel } = await connect(RABBITMQ_URL); +startOutboxPoller(channel); + const idem = new RedisIdempotency("kitchen"); -// Kitchen reacts to placed orders: persist + accept, then mark ready a bit later. +// Kitchen no longer decides anything automatically — it just records that +// an order arrived. A human (via the dashboard's HTTP routes in server.ts) +// accepts it, starts cooking, and marks it ready. await consume( channel, { queue: "kitchen.order-events", routingKeys: [EventName.OrderPlaced] }, @@ -27,38 +29,12 @@ await consume( const e = event as OrderPlaced; if (await idem.alreadyProcessed(e.eventId)) return; // safe on redelivery, durable across restarts - const etaMinutes = 20; - - // Step 3: Persist the acceptance, then publish — same persist-then-publish - // ordering the order service uses, so a downstream consumer never reacts - // to a kitchen decision that isn't actually recorded here yet. - await acceptOrder(e.orderId, etaMinutes); - console.log(`✅ kitchen accepted order ${e.orderId}`); - - publish(channel, EventName.OrderAccepted, { - type: EventName.OrderAccepted, - eventId: crypto.randomUUID(), - occurredAt: new Date().toISOString(), - orderId: e.orderId, - etaMinutes, - }); - - // Simulate cooking time, then mark ready and announce it. - setTimeout(async () => { - await markOrderReady(e.orderId); - console.log(`✅ kitchen marked order ${e.orderId} ready`); - - publish(channel, EventName.OrderReady, { - type: EventName.OrderReady, - eventId: crypto.randomUUID(), - occurredAt: new Date().toISOString(), - orderId: e.orderId, - }); - }, 3000); + await createPendingOrder(e.orderId, e.customerId, e.items, e.totalCents); + console.log(`✅ kitchen received order ${e.orderId}, awaiting staff action`); }, ); -// Step 4: Build and start the HTTP server (health check only). +// Step 2: Build and start the HTTP server. buildServer() .then((server) => server.listen({ port: PORT, host: "0.0.0.0" })) .then(() => console.log(`🚀 kitchen on ${PORT}`)) diff --git a/services/kitchen/src/outbox.ts b/services/kitchen/src/outbox.ts new file mode 100644 index 0000000..1303406 --- /dev/null +++ b/services/kitchen/src/outbox.ts @@ -0,0 +1,38 @@ +import { publish, type EventName } from "@quickbite/shared"; +import { getUnpublishedOutboxRows, markOutboxPublished } from "./db.ts"; + +type Channel = Parameters[0]; + +// Identical pattern to services/order/src/outbox.ts — see that file for the +// full rationale. Kitchen's accept/start-cooking/ready actions are now +// HTTP-triggered just like order's POST /orders, so they get the same +// outbox durability guarantee. +async function drainOnce(channel: Channel): Promise { + const rows = await getUnpublishedOutboxRows(); + + for (const row of rows) { + try { + publish(channel, row.routingKey as EventName, row.payload); + await markOutboxPublished(row.id); + } catch (err) { + console.error(`❌ outbox: failed to publish ${row.id} (${row.routingKey})`, err); + } + } +} + +export function startOutboxPoller(channel: Channel, intervalMs = 1000): ReturnType { + let draining = false; + + const timer = setInterval(() => { + if (draining) return; + draining = true; + drainOnce(channel) + .catch((err) => console.error("❌ outbox: drain tick failed", err)) + .finally(() => { + draining = false; + }); + }, intervalMs); + + console.log(`🚀 outbox poller started (every ${intervalMs}ms)`); + return timer; +} diff --git a/services/kitchen/src/server.ts b/services/kitchen/src/server.ts new file mode 100644 index 0000000..7b6dbeb --- /dev/null +++ b/services/kitchen/src/server.ts @@ -0,0 +1,43 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { acceptOrder, getKitchenOrder, listActiveOrders, markOrderReady, startCooking } from "./db.ts"; + +// Build the server as its own function, separate from starting it — same +// split as services/order/src/server.ts, specifically so tests can import +// buildServer() and call .inject() without index.ts's connect()/consume()/ +// listen() side effects ever running. Routes only ever talk to the +// database — never to the RabbitMQ channel — the outbox poller is the only +// thing that ever publishes, on its own schedule. +export async function buildServer(): Promise { + const server = Fastify({ logger: true }); + + server.get("/health", async () => ({ status: "ok", service: "kitchen" })); + + // For the kitchen dashboard: every order not yet marked ready. + server.get("/orders", async () => listActiveOrders()); + + // One route per staff action: 404 if the order doesn't exist, otherwise + // do the transition and hand back the updated row so the dashboard can + // update optimistically without a second round trip. + server.post("/orders/:id/accept", async (req, reply) => { + const { id } = req.params as { id: string }; + if (!(await getKitchenOrder(id))) return reply.code(404).send({ error: "not found" }); + await acceptOrder(id, 20); // ETA stays fixed for now — see CLAUDE.md + return getKitchenOrder(id); + }); + + server.post("/orders/:id/start-cooking", async (req, reply) => { + const { id } = req.params as { id: string }; + if (!(await getKitchenOrder(id))) return reply.code(404).send({ error: "not found" }); + await startCooking(id); + return getKitchenOrder(id); + }); + + server.post("/orders/:id/ready", async (req, reply) => { + const { id } = req.params as { id: string }; + if (!(await getKitchenOrder(id))) return reply.code(404).send({ error: "not found" }); + await markOrderReady(id); + return getKitchenOrder(id); + }); + + return server; +} diff --git a/services/kitchen/tests/order-events.test.ts b/services/kitchen/tests/order-events.test.ts index 808b916..53c341f 100644 --- a/services/kitchen/tests/order-events.test.ts +++ b/services/kitchen/tests/order-events.test.ts @@ -3,11 +3,13 @@ import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testconta import { RabbitMQContainer, type StartedRabbitMQContainer } from "@testcontainers/rabbitmq"; import { RedisContainer, type StartedRedisContainer } from "@testcontainers/redis"; import { Wait } from "testcontainers"; -import { connect, publish, EventName } from "@quickbite/shared"; +import { connect, consume, publish, EventName, type OrderAccepted, type OrderCooking, type OrderReady } from "@quickbite/shared"; +import type { FastifyInstance } from "fastify"; let postgresC: StartedPostgreSqlContainer; let rabbitmq: StartedRabbitMQContainer; let redis: StartedRedisContainer; +let server: FastifyInstance; beforeAll(async () => { postgresC = await new PostgreSqlContainer("postgres:16") @@ -20,12 +22,15 @@ beforeAll(async () => { process.env.DATABASE_URL = postgresC.getConnectionUri(); process.env.RABBITMQ_URL = rabbitmq.getAmqpUrl(); process.env.REDIS_URL = redis.getConnectionUrl(); - process.env.PORT = "0"; // ephemeral port — this test never touches kitchen's HTTP layer - // Importing index.ts is the real entrypoint: it runs the actual connect() - // + consume() + idempotency wiring exactly as production does, not a - // reimplementation of kitchen's logic in test code. + // index.ts is the real entrypoint for the EVENT side (connect, consume, + // outbox poller) — importing it runs that exactly as production does. + // server.ts is imported separately so the HTTP routes can be exercised + // via .inject() without needing a real listening port. + process.env.PORT = "0"; await import("../src/index.ts"); + const { buildServer } = await import("../src/server.ts"); + server = await buildServer(); }, 120_000); afterAll(async () => { @@ -41,63 +46,100 @@ function buildOrderPlaced(orderId: string, eventId: string) { occurredAt: new Date().toISOString(), orderId, customerId: crypto.randomUUID(), - items: [{ menuItemId: crypto.randomUUID(), name: "Margherita", quantity: 1, priceCents: 1200 }], - totalCents: 1200, + items: [{ menuItemId: crypto.randomUUID(), name: "Gyros", quantity: 1, priceCents: 1100 }], + totalCents: 1100, }; } -test("accepts an order.placed event and marks it ready after the simulated cook time", async () => { +async function waitForPendingOrder(orderId: string) { const { getKitchenOrder } = await import("../src/db.ts"); + const deadline = Date.now() + 3000; + let row = await getKitchenOrder(orderId); + while (Date.now() < deadline && !row) { + await new Promise((resolve) => setTimeout(resolve, 100)); + row = await getKitchenOrder(orderId); + } + return row; +} +test("order.placed creates a pending order, with no automatic accept/ready", async () => { const { channel, conn } = await connect(rabbitmq.getAmqpUrl()); const orderId = crypto.randomUUID(); publish(channel, EventName.OrderPlaced, buildOrderPlaced(orderId, crypto.randomUUID())); - // Should be accepted almost immediately. - const acceptedDeadline = Date.now() + 3000; - let row = await getKitchenOrder(orderId); - while (Date.now() < acceptedDeadline && !row) { - await new Promise((resolve) => setTimeout(resolve, 100)); - row = await getKitchenOrder(orderId); - } - expect(row?.status).toBe("accepted"); - expect(row?.etaMinutes).toBe(20); + const row = await waitForPendingOrder(orderId); + expect(row?.status).toBe("pending"); + expect(row?.etaMinutes).toBeNull(); + expect(row?.acceptedAt).toBeNull(); - // Then ready after the ~3s simulated cook time. - const readyDeadline = Date.now() + 6000; - while (Date.now() < readyDeadline && row?.status !== "ready") { + await conn.close(); +}); + +test("staff actions (accept -> start cooking -> ready) drive the real HTTP routes and publish the matching events", async () => { + const orderId = crypto.randomUUID(); + const { channel: publishChannel, conn: publishConn } = await connect(rabbitmq.getAmqpUrl()); + publish(publishChannel, EventName.OrderPlaced, buildOrderPlaced(orderId, crypto.randomUUID())); + await waitForPendingOrder(orderId); + + // Listen for kitchen's own outbox-published events so we can assert they + // actually went out, not just that the database changed. + const seen: Record = {}; + const { channel: listenChannel, conn: listenConn } = await connect(rabbitmq.getAmqpUrl()); + await consume( + listenChannel, + { queue: `test.kitchen-events.${orderId}`, routingKeys: [EventName.OrderAccepted, EventName.OrderCooking, EventName.OrderReady] }, + async (event) => { + const e = event as OrderAccepted | OrderCooking | OrderReady; + if (e.orderId === orderId) seen[e.type] = e; + }, + ); + + const acceptRes = await server.inject({ method: "POST", url: `/orders/${orderId}/accept` }); + expect(acceptRes.statusCode).toBe(200); + expect(acceptRes.json().status).toBe("accepted"); + expect(acceptRes.json().etaMinutes).toBe(20); + + const cookingRes = await server.inject({ method: "POST", url: `/orders/${orderId}/start-cooking` }); + expect(cookingRes.statusCode).toBe(200); + expect(cookingRes.json().status).toBe("cooking"); + + const readyRes = await server.inject({ method: "POST", url: `/orders/${orderId}/ready` }); + expect(readyRes.statusCode).toBe(200); + expect(readyRes.json().status).toBe("ready"); + expect(readyRes.json().readyAt).not.toBeNull(); + + // The outbox poller runs on its own 1s interval — give it a few ticks to + // actually drain and publish all three events before asserting. + const deadline = Date.now() + 5000; + while (Date.now() < deadline && Object.keys(seen).length < 3) { await new Promise((resolve) => setTimeout(resolve, 200)); - row = await getKitchenOrder(orderId); } - expect(row?.status).toBe("ready"); - expect(row?.readyAt).not.toBeNull(); + expect(Object.keys(seen).sort()).toEqual([EventName.OrderAccepted, EventName.OrderCooking, EventName.OrderReady].sort()); - await conn.close(); + await publishConn.close(); + await listenConn.close(); }); -test("redelivering the same eventId does not double-process (Redis idempotency)", async () => { - const { getKitchenOrder } = await import("../src/db.ts"); +test("accepting an unknown order id returns 404, not a crash", async () => { + const res = await server.inject({ method: "POST", url: `/orders/${crypto.randomUUID()}/accept` }); + expect(res.statusCode).toBe(404); +}); +test("a redelivered order.placed (same eventId) does not double-insert (Redis idempotency)", async () => { const { channel, conn } = await connect(rabbitmq.getAmqpUrl()); const orderId = crypto.randomUUID(); const eventId = crypto.randomUUID(); const payload = buildOrderPlaced(orderId, eventId); - // Publish the SAME event twice in a row, simulating a RabbitMQ - // redelivery. If idempotency failed, the second delivery would attempt a - // second INSERT against the order_id primary key and throw — which would - // dead-letter the message rather than silently succeed. + // Publish the SAME event twice — if idempotency failed, the second + // delivery would attempt a second INSERT against the order_id primary + // key and throw, dead-lettering the message instead of no-op-ing. publish(channel, EventName.OrderPlaced, payload); publish(channel, EventName.OrderPlaced, payload); - const deadline = Date.now() + 3000; - let row = await getKitchenOrder(orderId); - while (Date.now() < deadline && !row) { - await new Promise((resolve) => setTimeout(resolve, 100)); - row = await getKitchenOrder(orderId); - } + const row = await waitForPendingOrder(orderId); expect(row).not.toBeNull(); - expect(row?.status).toBe("accepted"); + expect(row?.status).toBe("pending"); await conn.close(); }); diff --git a/services/menu/src/db.ts b/services/menu/src/db.ts index 62d21c6..3549b00 100644 --- a/services/menu/src/db.ts +++ b/services/menu/src/db.ts @@ -34,8 +34,10 @@ async function ensureSeed(): Promise { await sql` INSERT INTO menu_items (id, name, price_cents) VALUES - ('44444444-4444-4444-4444-444444444444', 'Margherita', 1200), - ('55555555-5555-5555-5555-555555555555', 'Pepperoni', 1400) + ('44444444-4444-4444-4444-444444444444', 'Gyros', 1100), + ('55555555-5555-5555-5555-555555555555', 'Souvlaki', 1300), + ('66666666-6666-6666-6666-666666666666', 'Greek Salad', 900), + ('77777777-7777-7777-7777-777777777777', 'Moussaka', 1400) `; } diff --git a/services/menu/tests/menu.test.ts b/services/menu/tests/menu.test.ts index 00492ad..af55a6d 100644 --- a/services/menu/tests/menu.test.ts +++ b/services/menu/tests/menu.test.ts @@ -25,8 +25,8 @@ afterAll(async () => { test("getMenuItems returns the seeded items from Postgres", async () => { const { getMenuItems } = await import("../src/db.ts"); const items = await getMenuItems(); - expect(items).toHaveLength(2); - expect(items.map((i) => i.name).sort()).toEqual(["Margherita", "Pepperoni"]); + expect(items).toHaveLength(4); + expect(items.map((i) => i.name).sort()).toEqual(["Greek Salad", "Gyros", "Moussaka", "Souvlaki"]); }); test("cache-aside: a miss populates Redis, and the cached value matches Postgres", async () => { diff --git a/services/notification/src/index.ts b/services/notification/src/index.ts index 98a69de..9d78cfc 100644 --- a/services/notification/src/index.ts +++ b/services/notification/src/index.ts @@ -22,7 +22,7 @@ await consume( channel, { queue: "notification.order-events", - routingKeys: [EventName.OrderPlaced, EventName.OrderAccepted, EventName.OrderReady], + routingKeys: [EventName.OrderPlaced, EventName.OrderAccepted, EventName.OrderCooking, EventName.OrderReady], }, async (event, raw) => { const e = event as { eventId: string; orderId: string }; diff --git a/services/order/src/db.ts b/services/order/src/db.ts index 59e64fc..14e0cf7 100644 --- a/services/order/src/db.ts +++ b/services/order/src/db.ts @@ -142,6 +142,10 @@ export async function markOrderAccepted(orderId: string): Promise { await sql`UPDATE orders SET status = 'accepted' WHERE order_id = ${orderId}`; } +export async function markOrderCooking(orderId: string): Promise { + await sql`UPDATE orders SET status = 'cooking' WHERE order_id = ${orderId}`; +} + export async function markOrderReady(orderId: string): Promise { await sql`UPDATE orders SET status = 'ready' WHERE order_id = ${orderId}`; } diff --git a/services/order/src/order-events.ts b/services/order/src/order-events.ts index 71e0b4e..ca5ffaf 100644 --- a/services/order/src/order-events.ts +++ b/services/order/src/order-events.ts @@ -1,5 +1,13 @@ -import { consume, EventName, RedisIdempotency, type OrderAccepted, type OrderReady, type ResilientChannel } from "@quickbite/shared"; -import { markOrderAccepted, markOrderReady } from "./db.ts"; +import { + consume, + EventName, + RedisIdempotency, + type OrderAccepted, + type OrderCooking, + type OrderReady, + type ResilientChannel, +} from "@quickbite/shared"; +import { markOrderAccepted, markOrderCooking, 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 @@ -11,13 +19,37 @@ export async function startOrderEventConsumer(channel: ResilientChannel): Promis await consume( channel, - { queue: "order.order-events", routingKeys: [EventName.OrderAccepted, EventName.OrderReady] }, + { + queue: "order.order-events", + routingKeys: [EventName.OrderAccepted, EventName.OrderCooking, EventName.OrderReady], + // prefetch: 1 — these three events for the SAME order must be applied + // in the order they happened, and each handler's UPDATE has no + // ordering/version check of its own. The default prefetch (10) lets + // RabbitMQ deliver multiple unacked messages concurrently, so two + // concurrent async handlers for the same order could finish their + // UPDATEs out of order — e.g. "ready" committing before "cooking" + // does, leaving the row permanently stuck on "cooking" even though + // every event was actually delivered and handled. Forcing strictly + // sequential processing (ack one before the next is even delivered) + // is the simplest correct fix — this consumer's whole job is to + // mirror a sequence, so there's no benefit to concurrency here, only + // risk. + prefetch: 1, + }, async (event) => { - const e = event as OrderAccepted | OrderReady; + const e = event as OrderAccepted | OrderCooking | OrderReady; if (await idem.alreadyProcessed(e.eventId)) return; - if (e.type === EventName.OrderAccepted) await markOrderAccepted(e.orderId); - else await markOrderReady(e.orderId); + if (e.type === EventName.OrderAccepted) { + await markOrderAccepted(e.orderId); + console.log(`✅ order ${e.orderId} synced to accepted`); + } else if (e.type === EventName.OrderCooking) { + await markOrderCooking(e.orderId); + console.log(`✅ order ${e.orderId} synced to cooking`); + } else { + await markOrderReady(e.orderId); + console.log(`✅ order ${e.orderId} synced to ready`); + } }, ); } diff --git a/tests/e2e/order-flow.test.ts b/tests/e2e/order-flow.test.ts index 4dcb1bf..c779e55 100644 --- a/tests/e2e/order-flow.test.ts +++ b/tests/e2e/order-flow.test.ts @@ -11,6 +11,7 @@ let postgresC: StartedPostgreSqlContainer; let rabbitmq: StartedRabbitMQContainer; let redis: StartedRedisContainer; let orderServer: FastifyInstance; +let kitchenServer: FastifyInstance; function dbUrl(container: StartedPostgreSqlContainer, database: string): string { return `postgres://${container.getUsername()}:${container.getPassword()}@${container.getHost()}:${container.getPort()}/${database}`; @@ -44,9 +45,16 @@ beforeAll(async () => { const orderOutbox = await import("../../services/order/src/outbox.ts"); const orderEvents = await import("../../services/order/src/order-events.ts"); + // kitchen/src/index.ts itself uses a top-level await for connect()+ + // consume() (not a .then() chain) specifically so this import only + // resolves once its consumer is actually registered — otherwise the + // order.placed event published in the test below could be published + // before kitchen's queue binding exists and be silently dropped. process.env.DATABASE_URL = dbUrl(postgresC, "kitchen_db"); process.env.PORT = "0"; await import("../../services/kitchen/src/index.ts"); + const kitchenServerModule = await import("../../services/kitchen/src/server.ts"); + kitchenServer = await kitchenServerModule.buildServer(); process.env.DATABASE_URL = dbUrl(postgresC, "notification_db"); process.env.PORT = "0"; @@ -68,17 +76,15 @@ afterAll(async () => { await redis.stop(); }); -test("place order -> kitchen accepts and readies -> notification logs all 3 events", async () => { +test("place order -> kitchen staff accept/cook/ready -> order syncs -> notification logs all 4 events", async () => { // Step 4: Drive the flow exactly the way a real customer would — one - // HTTP request to the order service. Everything after this point (outbox - // -> RabbitMQ -> kitchen -> RabbitMQ -> notification) happens on its own, - // through the real production code paths for all three services. + // HTTP request to the order service. const response = await orderServer.inject({ method: "POST", url: "/orders", payload: { customerId: crypto.randomUUID(), - items: [{ menuItemId: crypto.randomUUID(), name: "Margherita", quantity: 1, priceCents: 1200 }], + items: [{ menuItemId: crypto.randomUUID(), name: "Gyros", quantity: 1, priceCents: 1100 }], }, }); expect(response.statusCode).toBe(201); @@ -87,29 +93,56 @@ test("place order -> kitchen accepts and readies -> notification logs all 3 even process.env.DATABASE_URL = dbUrl(postgresC, "kitchen_db"); const { getKitchenOrder } = await import("../../services/kitchen/src/db.ts"); + // Step 5: Wait for kitchen to actually receive the order.placed event + // (via order's outbox poller -> RabbitMQ) before driving the manual + // workflow — there's no automatic timer anymore, a human (this test, + // standing in for one) has to act at every step. + const pendingDeadline = Date.now() + 5000; + let kitchenRow = await getKitchenOrder(orderId); + while (Date.now() < pendingDeadline && !kitchenRow) { + await new Promise((resolve) => setTimeout(resolve, 200)); + kitchenRow = await getKitchenOrder(orderId); + } + expect(kitchenRow?.status).toBe("pending"); + + // Step 6: Drive the real HTTP routes kitchen's dashboard would call. + const acceptRes = await kitchenServer.inject({ method: "POST", url: `/orders/${orderId}/accept` }); + expect(acceptRes.statusCode).toBe(200); + + const cookingRes = await kitchenServer.inject({ method: "POST", url: `/orders/${orderId}/start-cooking` }); + expect(cookingRes.statusCode).toBe(200); + + const readyRes = await kitchenServer.inject({ method: "POST", url: `/orders/${orderId}/ready` }); + expect(readyRes.statusCode).toBe(200); + process.env.DATABASE_URL = dbUrl(postgresC, "notification_db"); const { getNotificationsForOrder } = await import("../../services/notification/src/db.ts"); - // Step 5: Poll for the end state across BOTH services' own databases — - // the actual proof that the chain worked, not an assumption about timing. + // Step 7: Poll for the end state across notification's own database — + // the actual proof the chain worked, not an assumption about timing. const deadline = Date.now() + 10_000; - let kitchenRow = await getKitchenOrder(orderId); let notifications = await getNotificationsForOrder(orderId); - while (Date.now() < deadline && (kitchenRow?.status !== "ready" || notifications.length < 3)) { + while (Date.now() < deadline && notifications.length < 4) { await new Promise((resolve) => setTimeout(resolve, 200)); - kitchenRow = await getKitchenOrder(orderId); notifications = await getNotificationsForOrder(orderId); } - - 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(notifications.map((n) => n.eventType).sort()).toEqual( + ["order.accepted", "order.cooking", "order.placed", "order.ready"].sort(), + ); + + // Step 8: The actual customer-facing check — order's OWN status via the + // real public endpoint, not an internal database. Proves order's + // consumer actually synced all the way through "cooking" to "ready". + // Polled, not a one-shot check: notification and order are two + // INDEPENDENT consumers reacting to the same broadcast events, with no + // ordering guarantee between them — notification finishing first (which + // the wait above just confirmed) says nothing about whether order's own + // consumer has caught up to the last event yet. + const statusDeadline = Date.now() + 5000; + let statusResponse = await orderServer.inject({ method: "GET", url: `/orders/${orderId}` }); + while (Date.now() < statusDeadline && statusResponse.json().status !== "ready") { + await new Promise((resolve) => setTimeout(resolve, 200)); + statusResponse = await orderServer.inject({ method: "GET", url: `/orders/${orderId}` }); + } expect(statusResponse.json().status).toBe("ready"); });