diff --git a/.claude/CONTEXT.md b/.claude/CONTEXT.md index 61c3e4f..5afcff8 100644 --- a/.claude/CONTEXT.md +++ b/.claude/CONTEXT.md @@ -38,9 +38,21 @@ Snyk: 0 issues across every changed package, including `tests/e2e`. Typecheck gr **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. + ## Next step -None — every phase in the build plan (`~/.claude/plans/lets-read-the-files-unified-galaxy.md`) is done. Possible follow-ups beyond the original plan, 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. +This fix-up pass needs to be committed, pushed, and merged via PR (same workflow as every phase — own branch, no Claude/Anthropic references). 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 diff --git a/README.md b/README.md index 4d0f7f8..41a3b1f 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,98 @@ # QuickBite -Event-driven food-ordering platform, built as a system-level QA project. -Microservices in a Bun workspace, communicating over RabbitMQ. +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. -## What's here +## Architecture -- `packages/shared` — event contracts (zod schemas) + RabbitMQ publish/consume helpers with a dead-letter queue and an idempotency guard. **This is the core.** -- `services/gateway` — public entry point; proxies reads to menu/order. -- `services/menu` — menu reads (static list for now). -- `services/order` — creates orders, publishes `order.placed`. -- `services/kitchen` — consumes `order.placed`, publishes `order.accepted` then `order.ready`. -- `services/notification` — consumes all order events, notifies the customer. +``` +client → nginx (:80) → 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, and even the gateway 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. ## Run it +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): + ```bash -cp .env.example .env docker compose up --build ``` -- Gateway via Nginx: http://localhost:8080 -- Gateway direct: http://localhost:3000 -- RabbitMQ management UI: http://localhost:15672 (guest / guest) - -Place an order and watch the event flow in the logs: +**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. ```bash -curl -X POST http://localhost:8080/api/orders \ +# 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}]}' + +# Check its status (replace with the orderId from the response above) +curl http://localhost/api/orders/ + +# Read the menu +curl http://localhost/api/menu ``` -You'll see order -> kitchen (accepted, then ready) -> notification in the container logs. +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. -## Local dev (without Docker) +### Local development -Start RabbitMQ (`docker compose up rabbitmq`), then in separate terminals: +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: ```bash -bun install -bun run dev:menu -bun run dev:order -bun run dev:kitchen -bun run dev:notification -bun run dev:gateway +docker compose -f docker-compose.yml -f docker-compose.dev.yml up ``` ## Tests ```bash -bun test # unit + (later) integration +bun run test # unit + integration (Testcontainers) + end-to-end bun run typecheck ``` -## Deliberately left as TODOs (your build work) - -These are stubbed so the scaffold runs immediately. Flesh them out — ideally -one per Claude Code plan-mode session: - -1. Replace in-memory stores with **per-service PostgreSQL** databases. -2. Add the **outbox pattern** so events publish only after the DB commit. -3. Persist the **idempotency** set to a `processed_events` table. -4. Add **retry-with-backoff** before dead-lettering in `mq.ts`. -5. Add **Testcontainers** integration tests (real Postgres + RabbitMQ). -6. Add an **end-to-end** test: place order -> ready -> notified. - -See `CLAUDE.md` for the rules these must follow. +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. + +## 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. diff --git a/bun.lock b/bun.lock index a4ee0ed..8332fd7 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,6 @@ "name": "@quickbite/gateway", "version": "0.0.0", "dependencies": { - "@fastify/http-proxy": "^10.0.0", "fastify": "^5.0.0", }, }, @@ -113,14 +112,10 @@ "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], - "@fastify/http-proxy": ["@fastify/http-proxy@10.0.2", "", { "dependencies": { "@fastify/reply-from": "^11.0.0", "fast-querystring": "^1.1.2", "fastify-plugin": "^5.0.0", "ws": "^8.16.0" } }, "sha512-3IlTjOu9xtX5UPd/ZxY3ebYb6caINuBlr7iyVxYmh3zZLV0K5ted8yfU9UCNXVNs33RwbXD2RhDucc3z5BNgEA=="], - "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], - "@fastify/reply-from": ["@fastify/reply-from@11.0.2", "", { "dependencies": { "@fastify/error": "^4.0.0", "end-of-stream": "^1.4.4", "fast-content-type-parse": "^2.0.0", "fast-querystring": "^1.1.2", "fastify-plugin": "^5.0.1", "toad-cache": "^3.7.0", "undici": "^6.11.1" } }, "sha512-VcHhe01PsHuVX2eVrkoskCs+pwNPgVfOOpwQJnSo3AwIKtISm0VCFB7bycQjHfxAuPYgkrI6ZvYoovdHx4sVMA=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], @@ -299,8 +294,6 @@ "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], - "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -315,8 +308,6 @@ "fastify": ["fastify@5.8.5", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q=="], - "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "find-my-way": ["find-my-way@9.6.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ=="], @@ -515,8 +506,6 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -529,8 +518,6 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@fastify/reply-from/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], - "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ccdbda4 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,19 @@ +# Local-development override — adds back host ports for direct access to +# infra while developing (e.g. running a service bare with `bun run dev:X` +# against dockerized Postgres/RabbitMQ/Redis, or poking at them with a GUI +# tool). NOT auto-merged by `docker compose up` (only docker-compose.override.yml +# is); use explicitly: +# +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# +# The gateway is deliberately NOT exposed here — even in dev, the only +# supported way to reach the application is through nginx. +services: + rabbitmq: + ports: ["5672:5672", "15672:15672"] # 15672 = management UI + + postgres: + ports: ["5432:5432"] + + redis: + ports: ["6379:6379"] diff --git a/docker-compose.yml b/docker-compose.yml index aaa0314..2f38df4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,10 @@ services: + # No published ports below except nginx — nginx is the system's only + # public entry point. RabbitMQ/Postgres/Redis/the gateway are reachable + # only from other containers on the internal Docker network, by service + # name. For local debugging access to these, use docker-compose.dev.yml. rabbitmq: image: rabbitmq:3-management - ports: ["5672:5672", "15672:15672"] # 15672 = management UI healthcheck: test: ["CMD", "rabbitmq-diagnostics", "ping"] interval: 10s @@ -13,7 +16,6 @@ services: environment: POSTGRES_USER: quickbite POSTGRES_PASSWORD: quickbite - ports: ["5432:5432"] volumes: - pgdata:/var/lib/postgresql/data - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro @@ -25,7 +27,6 @@ services: redis: image: redis:7-alpine - ports: ["6379:6379"] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -39,7 +40,6 @@ services: MENU_URL: http://menu:3001 ORDER_URL: http://order:3002 depends_on: [menu, order] - ports: ["3000:3000"] menu: build: { context: ., dockerfile: services/menu/Dockerfile } @@ -87,10 +87,12 @@ services: postgres: { condition: service_healthy } redis: { condition: service_healthy } + # The system's single public entry point — every other service above is + # unreachable from outside the Docker network. nginx: image: nginx:alpine volumes: ["./nginx/nginx.conf:/etc/nginx/nginx.conf:ro"] - ports: ["8080:80"] + ports: ["80:80"] depends_on: [gateway] volumes: diff --git a/packages/shared/src/idempotency.ts b/packages/shared/src/idempotency.ts index 9e5ab7b..0eebc7b 100644 --- a/packages/shared/src/idempotency.ts +++ b/packages/shared/src/idempotency.ts @@ -1,6 +1,5 @@ import Redis from "ioredis"; -const REDIS_URL = process.env.REDIS_URL ?? "redis://localhost:6379"; const EVENT_SEEN_TTL_SECONDS = 60 * 60 * 24; // 24h — comfortably covers any realistic redelivery window /** @@ -22,7 +21,12 @@ export class RedisIdempotency { private readonly redis: Redis; private readonly namespace: string; - constructor(namespace: string, redisUrl: string = REDIS_URL) { + // redisUrl is read from process.env here, inside the constructor, rather + // than into a module-level constant at import time — a test (or any + // caller) that sets REDIS_URL before constructing this class must have + // that value actually take effect, even if @quickbite/shared was already + // imported earlier with no REDIS_URL set yet. + constructor(namespace: string, redisUrl: string = process.env.REDIS_URL ?? "redis://localhost:6379") { this.namespace = namespace; this.redis = new Redis(redisUrl); } diff --git a/packages/shared/src/mq.ts b/packages/shared/src/mq.ts index 3074774..15012bc 100644 --- a/packages/shared/src/mq.ts +++ b/packages/shared/src/mq.ts @@ -48,8 +48,9 @@ export type ResilientChannel = ReturnType; const MAX_RETRIES = 3; -// Exponential backoff: 1s, 2s, 4s for retryCount 0, 1, 2. -function backoffMs(retryCount: number): number { +// Exponential backoff: 1s, 2s, 4s for retryCount 0, 1, 2. Exported so it has +// its own unit test, separate from the integration-level retry behavior. +export function backoffMs(retryCount: number): number { return 1000 * 2 ** retryCount; } diff --git a/packages/shared/tests/mq.test.ts b/packages/shared/tests/mq.test.ts new file mode 100644 index 0000000..0a82fab --- /dev/null +++ b/packages/shared/tests/mq.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { backoffMs } from "../src/mq.ts"; + +// Pure unit test of the retry-backoff math — separate from the integration +// tests (in each service's tests/) that exercise real retry/dead-letter +// behavior against a real RabbitMQ. +describe("backoffMs", () => { + test("doubles the delay on each retry: 1s, 2s, 4s", () => { + expect(backoffMs(0)).toBe(1000); + expect(backoffMs(1)).toBe(2000); + expect(backoffMs(2)).toBe(4000); + }); +}); diff --git a/services/gateway/package.json b/services/gateway/package.json index f9cf313..e8dbf73 100644 --- a/services/gateway/package.json +++ b/services/gateway/package.json @@ -7,7 +7,6 @@ "start": "bun run src/index.ts" }, "dependencies": { - "@fastify/http-proxy": "^10.0.0", "fastify": "^5.0.0" } } diff --git a/services/gateway/src/index.ts b/services/gateway/src/index.ts index a165a9b..862e064 100644 --- a/services/gateway/src/index.ts +++ b/services/gateway/src/index.ts @@ -1,5 +1,4 @@ -import Fastify from "fastify"; -import proxy from "@fastify/http-proxy"; +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"; @@ -9,10 +8,42 @@ const app = Fastify({ logger: true }); app.get("/health", async () => ({ status: "ok", service: "gateway" })); +// Step 1: A small hand-rolled proxy via Bun's native fetch(), instead of +// @fastify/http-proxy. That plugin's underlying @fastify/reply-from forwards +// requests through a Node-style undici Pool whose .request() method isn't +// implemented the same way under Bun's runtime — it throws +// "pool.request is not a function" on every forwarded call. fetch() is a +// first-class, fully-supported Bun API, so this avoids the incompatibility +// entirely rather than working around it. +function proxyTo(prefix: string, upstreamBase: string, rewriteTo: string) { + return async (req: FastifyRequest, reply: FastifyReply) => { + const suffix = req.url.slice(prefix.length); + const targetUrl = `${upstreamBase}${rewriteTo}${suffix}`; + + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + const upstreamRes = await fetch(targetUrl, { + method: req.method, + headers: { "content-type": "application/json" }, + body: hasBody ? JSON.stringify(req.body) : undefined, + }); + + const text = await upstreamRes.text(); + reply.code(upstreamRes.status); + reply.header("content-type", upstreamRes.headers.get("content-type") ?? "application/json"); + return reply.send(text); + }; +} + // The gateway is the only public entry point. It proxies user-facing READS -// to the owning service. The order WORKFLOW happens over events, not here. -await app.register(proxy, { upstream: MENU_URL, prefix: "/api/menu", rewritePrefix: "/menu" }); -await app.register(proxy, { upstream: ORDER_URL, prefix: "/api/orders", rewritePrefix: "/orders" }); +// (and order placement) to the owning service. The order WORKFLOW after +// that happens over events, not here. +const menuProxy = proxyTo("/api/menu", MENU_URL, "/menu"); +app.get("/api/menu", menuProxy); +app.all("/api/menu/*", menuProxy); + +const orderProxy = proxyTo("/api/orders", ORDER_URL, "/orders"); +app.all("/api/orders", orderProxy); +app.all("/api/orders/*", orderProxy); app.listen({ port: PORT, host: "0.0.0.0" }).then(() => { app.log.info(`gateway listening on ${PORT}`); diff --git a/services/order/src/pricing.ts b/services/order/src/pricing.ts new file mode 100644 index 0000000..1c4c649 --- /dev/null +++ b/services/order/src/pricing.ts @@ -0,0 +1,10 @@ +import type { OrderItem } from "@quickbite/shared"; + +// Pure logic, no I/O — the order's total is always derived from its items +// server-side, never trusted from client input. Extracted into its own +// function (rather than left inline in the route handler) specifically so +// it has a real unit test, separate from the integration tests that exercise +// the full HTTP + DB path. +export function computeTotalCents(items: Pick[]): number { + return items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0); +} diff --git a/services/order/src/server.ts b/services/order/src/server.ts index ad4680a..9aeb0cc 100644 --- a/services/order/src/server.ts +++ b/services/order/src/server.ts @@ -2,6 +2,7 @@ import Fastify, { type FastifyInstance } from "fastify"; import { z } from "zod"; import { OrderItem } from "@quickbite/shared"; import { createOrder, getOrderById } from "./db.ts"; +import { computeTotalCents } from "./pricing.ts"; const PlaceOrderBody = z.object({ customerId: z.string().uuid(), @@ -31,7 +32,7 @@ export async function buildServer(): Promise { const { customerId, items } = parsed.data; const orderId = crypto.randomUUID(); - const totalCents = items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0); + const totalCents = computeTotalCents(items); // Persist the order AND its outbox event in one transaction. No // publish() call here at all — the outbox poller is the only thing diff --git a/services/order/tests/pricing.test.ts b/services/order/tests/pricing.test.ts new file mode 100644 index 0000000..ae2ed84 --- /dev/null +++ b/services/order/tests/pricing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { computeTotalCents } from "../src/pricing.ts"; + +// Pure unit test — no containers, no I/O, no network. Separate from +// place-order.test.ts's integration coverage of the full HTTP + DB path. +describe("computeTotalCents", () => { + test("sums price * quantity across multiple items", () => { + const total = computeTotalCents([ + { priceCents: 1200, quantity: 2 }, + { priceCents: 1400, quantity: 1 }, + ]); + expect(total).toBe(3800); + }); + + test("handles a single item", () => { + expect(computeTotalCents([{ priceCents: 999, quantity: 1 }])).toBe(999); + }); + + test("returns 0 for an empty list", () => { + expect(computeTotalCents([])).toBe(0); + }); +});