From d1779b9b31d820ad78d3a25cc56242a9ae732c76 Mon Sep 17 00:00:00 2001 From: Matt Calthrop Date: Mon, 13 Apr 2026 10:23:10 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20PLAN=20=C2=A75.1=E2=80=935.3=20?= =?UTF-8?q?=E2=80=94=20Pact=20contract=20testing=20(consumer,=20publish,?= =?UTF-8?q?=20provider=20verify)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Pact consumer test (apps/web/pact/recipes.pact.test.ts) and publish script - Add provider verify script and pact npm bindings (apps/api) - Add docker-compose.yml for local Pact broker - Add root pact:verify helper script and Turbo pipeline tasks - Pin apps/api Python to 3.13 (.python-version) and update README accordingly - Update CI workflow, .gitignore, and vite config for Pact integration Made-with: Cursor --- .cursor/rules/google-shell-style.mdc | 29 + .cursor/rules/long-command-options.mdc | 24 + .github/workflows/ci.yml | 3 + .gitignore | 3 + .vscode/settings.json | 6 +- PLAN.md | 6 +- README.md | 38 +- apps/api/.python-version | 1 + apps/api/README.md | 12 +- apps/api/package.json | 4 + apps/api/scripts/pact-provider-verify.sh | 111 +++ apps/web/README.md | 23 + apps/web/biome.json | 2 +- apps/web/package.json | 10 +- apps/web/pact/recipes.pact.test.ts | 112 +++ apps/web/scripts/pact-publish.sh | 60 ++ apps/web/vite.config.ts | 41 +- docker-compose.yml | 42 ++ package.json | 6 + pnpm-lock.yaml | 881 ++++++++++++++++++++++- scripts/pact-verify.sh | 46 ++ turbo.json | 9 + 22 files changed, 1449 insertions(+), 20 deletions(-) create mode 100644 .cursor/rules/google-shell-style.mdc create mode 100644 .cursor/rules/long-command-options.mdc create mode 100644 apps/api/.python-version create mode 100644 apps/api/scripts/pact-provider-verify.sh create mode 100644 apps/web/pact/recipes.pact.test.ts create mode 100644 apps/web/scripts/pact-publish.sh create mode 100644 docker-compose.yml create mode 100644 scripts/pact-verify.sh diff --git a/.cursor/rules/google-shell-style.mdc b/.cursor/rules/google-shell-style.mdc new file mode 100644 index 0000000..90e07e3 --- /dev/null +++ b/.cursor/rules/google-shell-style.mdc @@ -0,0 +1,29 @@ +--- +description: Follow Google Shell Style Guide for Bash scripts +globs: **/*.sh +alwaysApply: false +--- + +# Google Shell Style Guide + +- For Bash scripts, follow the [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html). +- Prefer Bash patterns, naming, quoting, functions, and error-handling conventions from that guide. +- Keep scripts readable and conservative: use `set -euo pipefail` when appropriate, quote expansions, and prefer clear long-form options when available. +- Write new Bash in a style consistent with the guide rather than ad hoc shell habits. + +Examples: + +```bash +# Prefer +main() { + local branch_name="$1" + git fetch origin --prune + git checkout "${branch_name}" +} + +main "$@" + +# Avoid +f(){ git fetch origin -p; git checkout $1; } +f $@ +``` diff --git a/.cursor/rules/long-command-options.mdc b/.cursor/rules/long-command-options.mdc new file mode 100644 index 0000000..3d54776 --- /dev/null +++ b/.cursor/rules/long-command-options.mdc @@ -0,0 +1,24 @@ +--- +description: Prefer long-form command-line options +alwaysApply: true +--- + +# Long-Form Command Options + +- Prefer long-form command-line options whenever the tool supports them. +- Avoid short flags such as `-u`, `-p`, `-r`, or `-m` when the equivalent long option is available. +- This applies to shell commands, scripts, documentation examples, and generated command snippets. + +Examples: + +```bash +# Prefer +git fetch --prune +git push --set-upstream origin HEAD +docker compose up --detach + +# Avoid +git fetch -p +git push -u origin HEAD +docker compose up -d +``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b54fb..b750208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Test (API 100% line coverage gate) run: pnpm test:coverage + - name: Pact contract flow + run: pnpm pact:verify + - name: Generate OpenAPI code run: pnpm openapi:generate diff --git a/.gitignore b/.gitignore index 6f5b91f..c3a1b05 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ htmlcov/ *.egg-info/ dist/ build/ + +# Pact +apps/web/pacts/ diff --git a/.vscode/settings.json b/.vscode/settings.json index a420741..5547a2f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,5 +30,9 @@ "source.organizeImports.ruff": "explicit" } }, - "cSpell.words": ["dataclass", "dataclasses"] + "cSpell.words": [ + "dataclass", + "dataclasses", + "healthcheck" + ] } diff --git a/PLAN.md b/PLAN.md index 52c672e..ec1ac59 100644 --- a/PLAN.md +++ b/PLAN.md @@ -36,9 +36,9 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O ## 5. Contract testing (Pact) -- [ ] **5.1** Consumer: add Pact tests for the API usage the UI relies on; publish pacts (targeting your broker URL via secrets in CI). -- [ ] **5.2** Provider: verify the Python API against published pacts in CI; fail the build on verification failures. -- [ ] **5.3** Document self-hosted Pact Broker expectations (URL, auth, tags/branches) and wire GitHub Actions secrets accordingly. +- [x] **5.1** Consumer: add Pact tests for the API usage the UI relies on; publish pacts (targeting your broker URL via secrets in CI). +- [x] **5.2** Provider: verify the Python API against published pacts in CI; fail the build on verification failures. +- [x] **5.3** Document self-hosted Pact Broker expectations (URL, auth, tags/branches) and wire GitHub Actions secrets accordingly. ## 6. CI/CD and local quality gates diff --git a/README.md b/README.md index d3a4f65..c88c247 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ You can work in other editors, but Cursor is the intended environment. The REST API is defined in **`packages/openapi/openapi.yaml`** (shared by the Python API and the front end). From the repository root, **`pnpm lint`** runs **[Redocly](https://redocly.com/docs/cli/)** on that spec (via **`@solid-pact/openapi`**) together with the other workspace lint tasks. -The **CI** workflow (`.github/workflows/ci.yml`) runs **`pnpm lint`**, **`pnpm test`**, **`pnpm openapi:generate`**, and **`pnpm openapi:validate`** (among the other setup steps) on pushes to `main` and on every pull request. +The **CI** workflow (`.github/workflows/ci.yml`) runs **`pnpm lint`**, **`pnpm test`**, **`pnpm pact:verify`**, **`pnpm openapi:generate`**, and **`pnpm openapi:validate`** (among the other setup steps) on pushes to `main` and on every pull request. When you add or upgrade Node dependencies, run **`pnpm install`** with the **pnpm** version pinned in **`packageManager`** (via Corepack). Using a different pnpm release can rewrite **`pnpm-lock.yaml`** in an incompatible way (for example changing the lockfile format). @@ -54,13 +54,17 @@ See [pnpm installation](https://pnpm.io/installation) for other options. ## Scripts (root delegates to workspaces) -All root **`package.json`** scripts delegate to **Turborepo**, which runs the matching script in each workspace (e.g. `apps/web`, `apps/api`). Run them from the repository root with **pnpm** only: +Most root **`package.json`** scripts delegate to **Turborepo**; the Pact scripts target the relevant workspace directly with **pnpm --filter** because only the web and API packages participate in that flow. Run them from the repository root with **pnpm** only: ```bash pnpm build pnpm dev pnpm lint pnpm test +pnpm pact:consumer-test +pnpm pact:consumer-publish +pnpm pact:provider-verify +pnpm pact:verify pnpm openapi:generate pnpm openapi:validate ``` @@ -77,3 +81,33 @@ Or use Turborepo’s filter directly: ```bash pnpm turbo build --filter=web ``` + +## Pact Broker + +The repo includes a local self-hosted Pact Broker in **`docker-compose.yml`** (PostgreSQL + Pact Broker). Development defaults: + +| Setting | Value | +| ------- | ----- | +| URL | `http://127.0.0.1:9292` | +| Username | `pact` | +| Password | `pact` | + +Run the full local flow from the repository root: + +```bash +pnpm pact:verify +``` + +That helper starts Docker Compose, waits for the broker heartbeat, runs the web consumer Pact tests, publishes the generated pacts, verifies the Python provider, then shuts the broker down again. + +If you want to run the pieces separately: + +```bash +pnpm pact:broker:up +pnpm pact:consumer-test +pnpm pact:consumer-publish +pnpm pact:provider-verify +pnpm pact:broker:down +``` + +CI uses the same local Docker Compose broker as the manual **`pnpm pact:verify`** command, so it does not require Pact Broker secrets. diff --git a/apps/api/.python-version b/apps/api/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/apps/api/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/apps/api/README.md b/apps/api/README.md index 4042dd4..2bb0e1a 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -21,7 +21,7 @@ If you create the venv yourself (without **`pnpm install`**), install the editab ```bash cd apps/api -python3 -m venv .venv +python3.13 -m venv .venv source .venv/bin/activate pip install -e ".[dev]" ``` @@ -42,6 +42,16 @@ pnpm test:coverage The coverage variant runs **`pytest`** with **line coverage** for the **`app`** package, **fails under 100%**, and **omits** generated **`app/openapi/generated/`** (codegen output). Configuration lives in **`pyproject.toml`** (**`[tool.pytest.ini_options]`**, **`[tool.coverage.*]`**). +Provider contract verification: + +```bash +pnpm pact:provider-verify +``` + +If **`PACT_BROKER_BASE_URL`** is set, the verifier pulls contracts from that broker and publishes verification results in CI. Without broker settings, it falls back to local pact files under **`apps/web/pacts/`** so you can run the consumer + provider loop locally after **`pnpm pact:consumer-test`**. + +In the current GitHub Actions workflow, the full Pact flow is exercised via the root **`pnpm pact:verify`** helper, which starts the local broker from **`docker-compose.yml`** before publishing and verifying. + ## Import order (Ruff / isort) Imports are checked with **[Ruff](https://docs.astral.sh/ruff/)** using the **`I`** rules (PEP 8–style ordering compatible with **isort**). Configuration lives under **`[tool.ruff]`** in **`pyproject.toml`** (`known-first-party = ["app"]`; generated OpenAPI models under **`app/openapi/generated/`** are excluded). **Ruff** is a **dev** dependency; after **[Setup](#setup)** ( **`pnpm install`** or **`pip install -e ".[dev]"`** ), **`apps/api/.venv/bin/ruff`** is available. diff --git a/apps/api/package.json b/apps/api/package.json index 7704c13..d2902cb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,8 +9,12 @@ "openapi:validate": "bash scripts/validate_openapi_generated.sh", "lint": "pnpm build && .venv/bin/ruff check app", "lint:fix": ".venv/bin/ruff check app --fix", + "pact:provider-verify": "bash scripts/pact-provider-verify.sh", "test": ".venv/bin/python -m pytest -v", "test:coverage": "pnpm test --cov=app --cov-report=term-missing", "postinstall": "bash -e -c 'test -d .venv || python3 -m venv .venv; .venv/bin/python -m pip install -e \".[dev]\"'" + }, + "devDependencies": { + "@pact-foundation/pact-cli": "18.0.0" } } diff --git a/apps/api/scripts/pact-provider-verify.sh b/apps/api/scripts/pact-provider-verify.sh new file mode 100644 index 0000000..2452bd8 --- /dev/null +++ b/apps/api/scripts/pact-provider-verify.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "$0")" && pwd)" +readonly script_dir + +package_root="$(cd "${script_dir}/.." && pwd)" +readonly package_root + +repo_root="$(cd "${package_root}/../.." && pwd)" +readonly repo_root + +provider_name="${PACT_PROVIDER_NAME:?Missing required environment variable: PACT_PROVIDER_NAME}" +readonly provider_name + +provider_base_url="${PACT_PROVIDER_BASE_URL:?Missing required environment variable: PACT_PROVIDER_BASE_URL}" +readonly provider_base_url + +provider_hostname="${PACT_PROVIDER_HOSTNAME:?Missing required environment variable: PACT_PROVIDER_HOSTNAME}" +readonly provider_hostname + +provider_port="${PACT_PROVIDER_PORT:?Missing required environment variable: PACT_PROVIDER_PORT}" +readonly provider_port + +provider_transport="${PACT_PROVIDER_TRANSPORT:?Missing required environment variable: PACT_PROVIDER_TRANSPORT}" +readonly provider_transport + +wait_for_healthy_provider() { + local healthcheck_url="${provider_base_url}/health" + local attempt + + for ((attempt = 1; attempt <= 60; attempt += 1)); do + if wget --quiet --output-document=/dev/null "${healthcheck_url}"; then + return + fi + sleep 0.5 + done + + echo "Provider did not become healthy at ${healthcheck_url}" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${provider_pid:-}" ]]; then + kill -TERM "${provider_pid}" 2>/dev/null || true + wait "${provider_pid}" 2>/dev/null || true + fi +} + +run_local_verification() { + local command=( + "${package_root}/node_modules/.bin/pact-verifier" + --dir "${repo_root}/apps/web/pacts" + --provider-name "${provider_name}" + --hostname "${provider_hostname}" + --port "${provider_port}" + --transport "${provider_transport}" + ) + + "${command[@]}" +} + +run_broker_verification() { + local command + + command=( + "${package_root}/node_modules/.bin/pact-verifier" + --broker-url "${PACT_BROKER_BASE_URL}" + --provider-name "${provider_name}" + --hostname "${provider_hostname}" + --port "${provider_port}" + --transport "${provider_transport}" + --consumer-version-selectors '{"mainBranch":true}' + --consumer-version-selectors '{"matchingBranch":true}' + --enable-pending + --provider-branch "${PACT_PROVIDER_BRANCH:?Missing required environment variable: PACT_PROVIDER_BRANCH}" + --user "${PACT_BROKER_USERNAME:?Missing required environment variable: PACT_BROKER_USERNAME}" + --password "${PACT_BROKER_PASSWORD:?Missing required environment variable: PACT_BROKER_PASSWORD}" + ) + + if [[ "${CI:-}" == "true" ]]; then + command+=( + --publish + --provider-version "${PACT_PROVIDER_VERSION:?Missing required environment variable: PACT_PROVIDER_VERSION}" + ) + fi + + "${command[@]}" +} + +main() { + cd "${package_root}" + + ./.venv/bin/python -m uvicorn app.main:app --host "${provider_hostname}" --port "${provider_port}" & + provider_pid=$! + readonly provider_pid + + trap cleanup EXIT + + wait_for_healthy_provider + + if [[ -n "${PACT_BROKER_BASE_URL:-}" ]]; then + run_broker_verification + return + fi + + run_local_verification +} + +main "$@" diff --git a/apps/web/README.md b/apps/web/README.md index a86e422..606b247 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -30,6 +30,8 @@ Run from **`apps/web`** (or via **`pnpm --filter web