diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..4309695 --- /dev/null +++ b/backend/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration test package for the MEP backend.""" diff --git a/backend/tests/integration/test_smoke.py b/backend/tests/integration/test_smoke.py new file mode 100644 index 0000000..19ed49f --- /dev/null +++ b/backend/tests/integration/test_smoke.py @@ -0,0 +1,33 @@ +"""Integration smoke test — verifies the API boots and answers on /api/health. + +This is the minimum bar for any deploy: if this test fails, nothing else +matters. It uses the shared conftest fixtures (SQLite-backed TestClient), so +it runs in CI without a PostgreSQL service, yet exercises the full FastAPI +stack: routing, dependency injection, and the health service. +""" +import pytest + + +@pytest.mark.integration +def test_health_endpoint_returns_200(client): + """GET /api/health must return HTTP 200 OK.""" + response = client.get("/api/health") + assert response.status_code == 200 + + +@pytest.mark.integration +def test_health_endpoint_reports_expected_shape(client): + """The health payload must expose status / version / environment / database.""" + body = client.get("/api/health").json() + assert body["status"] == "healthy" + assert "version" in body + assert "environment" in body + assert body["database"] in ("connected", "disconnected") + + +@pytest.mark.integration +def test_root_endpoint_returns_200(client): + """GET / must return HTTP 200 OK and identify the API.""" + response = client.get("/") + assert response.status_code == 200 + assert response.json()["message"] == "MEP API is running" diff --git a/docs/ci/quality-gate.yml b/docs/ci/quality-gate.yml index 2866839..93cebd6 100644 --- a/docs/ci/quality-gate.yml +++ b/docs/ci/quality-gate.yml @@ -8,6 +8,23 @@ on: pull_request: jobs: + # Guard rail: fail fast if any workflow step references a file or directory + # that no longer exists (e.g. after a repository restructure). + validation: + name: validation (workflow path references) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Verify workflow path references + run: python3 tools/verify_workflow_paths.py --verbose + # Fast, deterministic checks — no database, no network beyond pip. free-tier: name: free-tier (unit, lint, health, evals-p) @@ -89,7 +106,7 @@ jobs: EOF # env_*.sql scripts do not create their databases — deploy_all.sh does that - # before invoking them (see build/te_core_schema.sql:41). Mirror it here. + # before invoking them (see backend/migration/build/te_core_schema.sql:41). Mirror it here. - name: Create environment databases run: | for db in te_mgmt_dev te_mgmt_test; do diff --git a/preflight.ps1 b/preflight.ps1 index ecf3059..977dde8 100644 --- a/preflight.ps1 +++ b/preflight.ps1 @@ -101,16 +101,16 @@ Test-Check 'psql on PATH' { Write-Host "" Write-Host "Project files" -ForegroundColor Cyan -Test-Check 'in project root (build/, tests/, evals/ exist)' { - (Test-Path build) -and (Test-Path tests) -and (Test-Path evals) +Test-Check 'in project root (backend/migration present)' { + (Test-Path backend/migration/build) -and (Test-Path backend/migration/tests) -and (Test-Path backend/migration/evals) } -FixHint 'cd into the PostgreDataMigrationApp folder before running this script' -Test-Check 'evals/runner.py exists' { - Test-Path 'evals\runner.py' +Test-Check 'backend/migration/evals/runner.py exists' { + Test-Path 'backend\migration\evals\runner.py' } -Test-Check 'build/deploy_all.sh exists' { - Test-Path 'build\deploy_all.sh' +Test-Check 'backend/migration/build/deploy_all.sh exists' { + Test-Path 'backend\migration\build\deploy_all.sh' } # ----- Git state ----- diff --git a/preflight.sh b/preflight.sh index 787ec76..82e8e3a 100644 --- a/preflight.sh +++ b/preflight.sh @@ -79,10 +79,10 @@ check 'psql on PATH' opt 'Add PG client to PATH. brew install pos # ----- Project files ----- echo echo "${C}Project files${X}" -check 'in project root (build/ tests/ evals/)' req 'cd into the PostgreDataMigrationApp folder first' \ - bash -c '[ -d build ] && [ -d tests ] && [ -d evals ]' -check 'evals/runner.py exists' req 'Project files missing/corrupt' test -f evals/runner.py -check 'build/deploy_all.sh exists' req 'Project files missing/corrupt' test -f build/deploy_all.sh +check 'in project root (backend/migration present)' req 'cd into the PostgreDataMigrationApp folder first' \ + bash -c '[ -d backend/migration/build ] && [ -d backend/migration/tests ] && [ -d backend/migration/evals ]' +check 'backend/migration/evals/runner.py exists' req 'Project files missing/corrupt' test -f backend/migration/evals/runner.py +check 'backend/migration/build/deploy_all.sh exists' req 'Project files missing/corrupt' test -f backend/migration/build/deploy_all.sh # ----- Git state ----- echo diff --git a/scripts/eval_compare.py b/scripts/eval_compare.py index 8dda456..29edbf0 100644 --- a/scripts/eval_compare.py +++ b/scripts/eval_compare.py @@ -14,7 +14,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -REPORTS_DIR = ROOT / "evals" / "reports" +REPORTS_DIR = ROOT / "backend" / "migration" / "evals" / "reports" GREEN = "\033[0;32m" RED = "\033[0;31m" diff --git a/scripts/eval_list.py b/scripts/eval_list.py index b360273..bf830a3 100644 --- a/scripts/eval_list.py +++ b/scripts/eval_list.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""List all past eval runs stored in evals/reports/. +"""List all past eval runs stored in backend/migration/evals/reports/. Mirrors `bun run eval:list` in gstack. """ @@ -10,7 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -REPORTS_DIR = ROOT / "evals" / "reports" +REPORTS_DIR = ROOT / "backend" / "migration" / "evals" / "reports" GREEN = "\033[0;32m" RED = "\033[0;31m" @@ -22,7 +22,7 @@ def main() -> None: if not REPORTS_DIR.exists(): - print(f"{YELLOW}No eval reports found — run `python3 evals/runner.py` first.{NC}") + print(f"{YELLOW}No eval reports found — run `python3 backend/migration/evals/runner.py` first.{NC}") return runs = sorted( diff --git a/scripts/eval_summary.py b/scripts/eval_summary.py index 35ffc9b..b449c6d 100644 --- a/scripts/eval_summary.py +++ b/scripts/eval_summary.py @@ -10,7 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -REPORTS_DIR = ROOT / "evals" / "reports" +REPORTS_DIR = ROOT / "backend" / "migration" / "evals" / "reports" GREEN = "\033[0;32m" RED = "\033[0;31m" @@ -27,7 +27,7 @@ def main() -> None: runs = sorted(p for p in REPORTS_DIR.iterdir() if p.is_dir()) if not runs: - print(f"{YELLOW}No eval runs found — run `python3 evals/runner.py` first.{NC}") + print(f"{YELLOW}No eval runs found — run `python3 backend/migration/evals/runner.py` first.{NC}") return total_runs = 0 diff --git a/scripts/run_qa.ps1 b/scripts/run_qa.ps1 index 01c18c4..f92ef0e 100644 --- a/scripts/run_qa.ps1 +++ b/scripts/run_qa.ps1 @@ -21,29 +21,29 @@ $ErrorActionPreference = "Stop" switch ($Target) { "test-free" { - pytest -m "unit or regression or security or snapshot" tests/ -v + pytest -m "unit or regression or security or snapshot" backend/migration/tests/ -v } "test-gate" { - pytest -m "unit or regression or security or snapshot" tests/ ` - --cov=build/csv --cov=evals --cov-report=term-missing + pytest -m "unit or regression or security or snapshot" backend/migration/tests/ ` + --cov=backend/migration/build/csv --cov=backend/migration/evals --cov-report=term-missing } "test-evals" { - python evals/runner.py --tiers p,i,s --verbose + python backend/migration/evals/runner.py --tiers p,i,s --verbose } "test-e2e" { - pytest -m "e2e or integration or parity" tests/ -v + pytest -m "e2e or integration or parity" backend/migration/tests/ -v } "test-all" { - pytest tests/ --cov=build/csv --cov=evals --cov-report=term-missing - python evals/runner.py --tiers p,i,s --verbose + pytest backend/migration/tests/ --cov=backend/migration/build/csv --cov=backend/migration/evals --cov-report=term-missing + python backend/migration/evals/runner.py --tiers p,i,s --verbose } "lint" { $pyFiles = @( - "build/csv/validator.py", - "evals/runner.py", - "evals/gap_report.py", - "tests/test_csv_validator.py", - "tests/test_evals_runner.py" + "backend/migration/build/csv/validator.py", + "backend/migration/evals/runner.py", + "backend/migration/evals/gap_report.py", + "backend/migration/tests/test_csv_validator.py", + "backend/migration/tests/test_evals_runner.py" ) $scriptPy = Get-ChildItem scripts/*.py -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName } diff --git a/scripts/test.ps1 b/scripts/test.ps1 index f89a32e..099fa15 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -3,7 +3,7 @@ # Layers: # 1. pytest -m unit (Python unit tests, no DB needed) # 2. SQL test suite (5 suites against deployed env, needs PG) -# 3. evals/runner.py (Tier P offline + Tier I/S need PG) +# 3. backend/migration/evals/runner.py (Tier P offline + Tier I/S need PG) # # Usage: # .\scripts\test.ps1 # all three layers @@ -72,12 +72,12 @@ try { } else { $TestRunner = Join-Path $ProjectRoot 'tests\run_tests.sh' if (Test-Path $TestRunner) { - & bash tests/run_tests.sh $Env + & bash backend/migration/tests/run_tests.sh $Env $pass = $LASTEXITCODE -eq 0 Record -Layer 'sql suite' -Pass $pass -Detail "exit=$LASTEXITCODE" Write-Host "[layer 2] $(if ($pass) {'PASS'} else {'FAIL'})" -ForegroundColor $(if ($pass) {'Green'} else {'Red'}) } else { - Write-Host "[layer 2] SKIP: tests/run_tests.sh not found" -ForegroundColor Yellow + Write-Host "[layer 2] SKIP: backend/migration/tests/run_tests.sh not found" -ForegroundColor Yellow Record -Layer 'sql suite' -Pass $true -Detail 'skipped: runner missing' } } @@ -86,7 +86,7 @@ try { # --- Layer 3: evals --- if (-not ($SkipEvals -or $OnlyPython)) { Write-Host "" - Write-Host "[layer 3] evals/runner.py" -ForegroundColor Yellow + Write-Host "[layer 3] backend/migration/evals/runner.py" -ForegroundColor Yellow $Runner = Join-Path $ProjectRoot 'evals\runner.py' if (Test-Path $Runner) { $tiers = if ($env:PGHOST -and $env:PGPASSWORD) { 'p,i,s' } else { 'p' } @@ -95,7 +95,7 @@ try { Record -Layer "evals ($tiers)" -Pass $pass -Detail "exit=$LASTEXITCODE" Write-Host "[layer 3] $(if ($pass) {'PASS'} else {'FAIL'})" -ForegroundColor $(if ($pass) {'Green'} else {'Red'}) } else { - Write-Host "[layer 3] SKIP: evals/runner.py not found" -ForegroundColor Yellow + Write-Host "[layer 3] SKIP: backend/migration/evals/runner.py not found" -ForegroundColor Yellow Record -Layer 'evals' -Pass $true -Detail 'skipped: runner missing' } } diff --git a/scripts/test.sh b/scripts/test.sh index 0dbfe87..e16e082 100644 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,7 +4,7 @@ # Layers: # 1. pytest -m unit (Python unit tests) # 2. SQL test suite (5 suites, needs PG) -# 3. evals/runner.py (Tier P offline + Tier I/S need PG) +# 3. backend/migration/evals/runner.py (Tier P offline + Tier I/S need PG) # # Usage: # ./scripts/test.sh # all three @@ -82,11 +82,11 @@ if [ "$SKIP_SQL" -eq 0 ] && [ "$ONLY_PYTHON" -eq 0 ]; then elif [ -z "${PGPASSWORD:-}" ]; then echo "${Y}[layer 2] SKIP: PGPASSWORD not set${X}" record "sql suite" PASS "skipped: env vars" - elif [ ! -f tests/run_tests.sh ]; then - echo "${Y}[layer 2] SKIP: tests/run_tests.sh missing${X}" + elif [ ! -f backend/migration/tests/run_tests.sh ]; then + echo "${Y}[layer 2] SKIP: backend/migration/tests/run_tests.sh missing${X}" record "sql suite" PASS "skipped: runner missing" else - if bash tests/run_tests.sh "$ENV_TARGET"; then + if bash backend/migration/tests/run_tests.sh "$ENV_TARGET"; then echo "${G}[layer 2] PASS${X}" record "sql suite" PASS "exit=0" else @@ -99,16 +99,16 @@ fi # --- Layer 3: evals --- if [ "$SKIP_EVALS" -eq 0 ] && [ "$ONLY_PYTHON" -eq 0 ]; then echo - echo "${Y}[layer 3] evals/runner.py${X}" - if [ ! -f evals/runner.py ]; then - echo "${Y}[layer 3] SKIP: evals/runner.py missing${X}" + echo "${Y}[layer 3] backend/migration/evals/runner.py${X}" + if [ ! -f backend/migration/evals/runner.py ]; then + echo "${Y}[layer 3] SKIP: backend/migration/evals/runner.py missing${X}" record "evals" PASS "skipped: runner missing" else TIERS="p" if [ -n "${PGHOST:-}" ] && [ -n "${PGPASSWORD:-}" ]; then TIERS="p,i,s" fi - if python3 evals/runner.py --tiers "$TIERS"; then + if python3 backend/migration/evals/runner.py --tiers "$TIERS"; then echo "${G}[layer 3] PASS${X}" record "evals ($TIERS)" PASS "exit=0" else diff --git a/tools/verify_workflow_paths.py b/tools/verify_workflow_paths.py new file mode 100644 index 0000000..e99d10d --- /dev/null +++ b/tools/verify_workflow_paths.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Verify that file paths referenced in GitHub Actions workflows exist. + +After the repository was restructured (tests/ -> backend/migration/tests/, +build/ -> backend/migration/build/, evals/ -> backend/migration/evals/), a +number of workflow steps kept pointing at the old locations and CI broke in +confusing ways. This script prevents that class of failure permanently: + + * It parses every ``.github/workflows/*.yml`` / ``*.yaml`` file. + * For every ``run:`` block it extracts path-like tokens (tokens that contain + a ``/`` or end in a known source extension) and checks that they exist in + the working tree. + * For every ``uses:`` reference to a *local* action (``./path/to/action``) + it checks the action directory/file exists. Marketplace actions + (``owner/repo@ref``) are ignored — they do not live in this repository. + +Exit code 0 when all references resolve, 1 when at least one stale reference +is found (so CI can fail the build). + +Usage: + python3 tools/verify_workflow_paths.py [--root REPO_ROOT] [--verbose] + +Stdlib-only on purpose: it must run before any dependencies are installed. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +# Extensions that indicate a token is meant to be a file in this repository. +CHECKED_EXTENSIONS = ( + ".py", ".sh", ".ps1", ".sql", ".yml", ".yaml", ".txt", ".ini", + ".cfg", ".toml", ".json", ".env", ".md", +) + +# Paths that are *created at runtime* by workflow steps (heredocs, artifacts, +# generated reports) and therefore legitimately absent from the repository. +RUNTIME_GENERATED = { + "backend/migration/build/config.local.env", +} + +# Prefixes that are never repository paths. +IGNORED_PREFIXES = ( + "http://", "https://", "git@", "/dev/", "/tmp/", "/usr/", "/etc/", + "/opt/", "/home/", "~/", +) + +# Shell/CLI noise that looks path-like but is not a repo file. +NOISE_TOKENS = { + "usr/bin/env", "bin/bash", "bin/sh", +} + +TOKEN_RE = re.compile(r"""[A-Za-z0-9_./\\~-]+""") + + +def find_repo_root(start: Path) -> Path: + """Walk upwards until a .github directory is found.""" + for candidate in [start, *start.parents]: + if (candidate / ".github").is_dir(): + return candidate + return start + + +def iter_blocks(text: str): + """Yield (kind, value) for every ``run:`` and ``uses:`` entry. + + A tiny purpose-built YAML walk: we only need the scalar attached to + ``run:``/``uses:`` keys, including ``run: |`` multi-line blocks. This + avoids a PyYAML dependency so the script can run pre-install. + """ + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() + m = re.match(r"(?:-\s+)?(run|uses):\s*(.*)$", stripped) + if m: + kind, value = m.group(1), m.group(2).strip() + if value in ("|", ">", "|-", ">-", ""): + # Multi-line block scalar: collect more-indented lines. + indent = len(line) - len(line.lstrip()) + block: list[str] = [] + j = i + 1 + while j < len(lines): + nxt = lines[j] + if nxt.strip() == "": + block.append("") + j += 1 + continue + nxt_indent = len(nxt) - len(nxt.lstrip()) + if nxt_indent <= indent: + break + block.append(nxt.strip()) + j += 1 + yield kind, "\n".join(block) + i = j + continue + yield kind, value + i += 1 + + +def strip_quotes(token: str) -> str: + return token.strip().strip("'\"") + + +def extract_path_candidates(command: str): + """Extract repository-path-like tokens from a shell command string.""" + for raw_line in command.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + # Remove shell variable references and escape characters so they are + # never mistaken for literal paths (e.g. \"$db\" must not yield 'db/'). + line = re.sub(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?", " ", line) + line = line.replace("\\", " ") + for token in TOKEN_RE.findall(line): + token = strip_quotes(token) + if not token or token in NOISE_TOKENS: + continue + if token.startswith(IGNORED_PREFIXES): + continue + # Flags/options (e.g. --cov-report) are not literal paths. + if token.startswith("-"): + continue + has_ext = token.lower().endswith(CHECKED_EXTENSIONS) + is_dir_ref = "/" in token and token.endswith("/") + if not has_ext and not is_dir_ref: + continue # only verify explicit file/directory references + # Skip pure version-ish or numeric tokens (e.g. "3.11"). + if re.fullmatch(r"[\d.]+", token): + continue + token = token.lstrip("./") + if token: + yield token + + +def verify(root: Path, verbose: bool = False) -> int: + workflows_dir = root / ".github" / "workflows" + if not workflows_dir.is_dir(): + print(f"ERROR: no workflows directory at {workflows_dir}", file=sys.stderr) + return 1 + + failures: list[tuple[str, str]] = [] + checked = 0 + + for wf in sorted(workflows_dir.glob("*.y*ml")): + text = wf.read_text(encoding="utf-8") + for kind, value in iter_blocks(text): + if kind == "uses": + ref = strip_quotes(value) + if ref.startswith("./"): # local action + checked += 1 + target = root / ref[2:] + ok = target.exists() + if verbose: + print(f"[{'OK' if ok else 'MISSING'}] {wf.name}: uses {ref}") + if not ok: + failures.append((wf.name, f"uses: {ref}")) + continue + + for candidate in extract_path_candidates(value): + if candidate in RUNTIME_GENERATED: + continue + target = root / candidate + checked += 1 + ok = target.exists() + if verbose: + print(f"[{'OK' if ok else 'MISSING'}] {wf.name}: {candidate}") + if not ok: + failures.append((wf.name, candidate)) + + print(f"verify_workflow_paths: checked {checked} references " + f"across {len(list(workflows_dir.glob('*.y*ml')))} workflow file(s).") + + if failures: + print("\nSTALE WORKFLOW PATH REFERENCES FOUND:", file=sys.stderr) + for wf_name, ref in failures: + print(f" - {wf_name}: '{ref}' does not exist in the repository", + file=sys.stderr) + print("\nFix the workflow (or add the path to RUNTIME_GENERATED in " + "tools/verify_workflow_paths.py if it is created at runtime).", + file=sys.stderr) + return 1 + + print("All workflow path references resolve. ✔") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=None, + help="Repository root (default: auto-detected)") + parser.add_argument("--verbose", action="store_true", + help="Print every checked reference") + args = parser.parse_args() + + root = Path(args.root).resolve() if args.root else find_repo_root( + Path(__file__).resolve().parent) + return verify(root, verbose=args.verbose) + + +if __name__ == "__main__": + sys.exit(main())