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/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..91ea10b 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Redis are not reachable from the host with this command. # 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/ 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/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/OrderStatus.tsx b/frontend/src/components/OrderStatus.tsx index 2b99c4f..f91590e 100644 --- a/frontend/src/components/OrderStatus.tsx +++ b/frontend/src/components/OrderStatus.tsx @@ -22,7 +22,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) => ( 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/MenuPage.tsx b/frontend/src/pages/MenuPage.tsx index 321fbf8..20b4ac3 100644 --- a/frontend/src/pages/MenuPage.tsx +++ b/frontend/src/pages/MenuPage.tsx @@ -52,7 +52,15 @@ 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({}); navigate(`/orders/${summary.orderId}`); } catch (err) { @@ -67,7 +75,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/styles.css b/frontend/src/styles.css index 8f6402a..79e6fff 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,74 +1,280 @@ +: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; } 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/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..b9e8e1f 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,20 @@ 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("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/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/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/order/src/order-events.ts b/services/order/src/order-events.ts index 71e0b4e..e7614fa 100644 --- a/services/order/src/order-events.ts +++ b/services/order/src/order-events.ts @@ -16,8 +16,13 @@ export async function startOrderEventConsumer(channel: ResilientChannel): Promis const e = event as OrderAccepted | OrderReady; if (await idem.alreadyProcessed(e.eventId)) return; - if (e.type === EventName.OrderAccepted) await markOrderAccepted(e.orderId); - else await markOrderReady(e.orderId); + if (e.type === EventName.OrderAccepted) { + await markOrderAccepted(e.orderId); + console.log(`✅ order ${e.orderId} synced to accepted`); + } else { + await markOrderReady(e.orderId); + console.log(`✅ order ${e.orderId} synced to ready`); + } }, ); }