From 343dcb1fd7b12094ab48e80d6429728b46a5945c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 06:15:44 +0000 Subject: [PATCH 1/4] test: group test suite into unit, system, SIT, performance, accessibility and UAT Reorganise the pytest suite into one folder per test category and add test cases for each: - tests/unit (+ frontend src/__tests__/unit): moved existing DB-free tests; new coverage for csv_parse, config/auth, the connection pool, app error handlers and loader guard paths - tests/system: moved API/e2e tests; new full-lifecycle API tests (typing, duplicates, overwrite, limits, audit log, T&E mode) - tests/system_integration: moved integration/parity/env-contract tests; new API<->frontend contract tests checked against the zod enums in csv.functions.ts, plus API<->PostgreSQL bootstrap/schema tests - tests/performance: parser throughput, 20k-row upload, concurrent uploads, p95 read latency, constant query count (PERF_BUDGET_SCALE scales budgets) - tests/accessibility: WCAG 2.1 AA via Playwright + axe-core against the Vite dev server with a mocked API, plus keyboard, focus-trap and reflow checks - tests/uat: moved issue #4/#5 acceptance tests; new Given/When/Then user stories using the shipped sample CSVs conftest.py tags every test with its folder's marker; new Makefile targets test-unit/system/sit/performance/accessibility/uat. docs/TEST_CASES.md is generated by scripts/test_catalogue.py and CI checks it is current. Fix the accessibility defects the new tests found: accessible names on the Select triggers, the audit-page download menu button and the upload progress bar; darker text for inline code chips and the ok/error status pills. CI: run the PostgreSQL test step against te_mgmt_dev (where te_dev is deployed; T&E-mode tests failed on main against the postgres DB), add an accessibility job (vitest + Playwright/axe), and sync bun.lock, which was missing jsdom and vitest so --frozen-lockfile failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CS4GeHuWThWvU74AkBL6E --- .github/workflows/quality-gate.yml | 67 ++- .gitignore | 4 +- Makefile | 33 ++ README.md | 39 +- csv-table-hub-main/bun.lock | 337 ++++++------ .../{ => unit}/audit-filters.test.ts | 0 .../__tests__/{ => unit}/csv-preview.test.ts | 0 .../src/__tests__/{ => unit}/export.test.ts | 0 .../__tests__/{ => unit}/job-outcome.test.ts | 0 .../{ => unit}/mapping-templates.test.ts | 0 .../{ => unit}/uploader-state-machine.test.ts | 0 .../src/routes/_authenticated/index.tsx | 16 +- csv-table-hub-main/src/routes/audit.tsx | 8 +- csv-table-hub-main/src/routes/auth.tsx | 2 +- docs/API_INTEGRATION.md | 8 +- docs/ARCHITECTURE.md | 20 +- docs/ENV_VARIABLES.md | 2 +- docs/TEST_CASES.md | 486 ++++++++++++++++++ docs/TEST_CONDITIONS.md | 11 +- pytest.ini | 5 + requirements-dev.txt | 5 + scripts/fix_inserted_rows_count.py | 6 +- scripts/health_check.py | 14 +- scripts/lint.sh | 21 +- scripts/provision_full_test_env.sh | 2 +- scripts/run_qa.ps1 | 4 +- scripts/select_tests.py | 8 +- scripts/test_catalogue.py | 163 ++++++ tests/README.md | 52 ++ .../__init__.py | 0 tests/accessibility/conftest.py | 216 ++++++++ tests/accessibility/test_accessibility.py | 202 ++++++++ tests/conftest.py | 134 +++++ tests/performance/__init__.py | 0 tests/performance/test_performance.py | 207 ++++++++ tests/system/__init__.py | 0 tests/{ => system}/test_api_coverage.py | 0 .../test_csv_loader_arbitrary_shapes.py | 2 +- tests/{ => system}/test_e2e_pipeline.py | 2 +- tests/system/test_system_api.py | 225 ++++++++ tests/system_integration/__init__.py | 0 .../environments/__init__.py | 0 .../environments/_contract.py | 0 .../environments/test_env_dev.py | 2 +- .../environments/test_env_prod.py | 2 +- .../environments/test_env_staging.py | 2 +- .../environments/test_env_test.py | 2 +- .../test_api_integration.py | 0 tests/{ => system_integration}/test_parity.py | 2 +- .../test_sit_frontend_contract.py | 222 ++++++++ .../test_te_loader_integration.py | 2 +- tests/uat/__init__.py | 0 .../test_issue_04_multi_file_upload.py | 2 +- .../{ => uat}/test_issue_05_import_summary.py | 2 +- tests/uat/test_uat_user_stories.py | 224 ++++++++ tests/unit/__init__.py | 0 .../snapshots/basic_expected_skip.csv | 0 .../snapshots/basic_expected_valid.csv | 0 tests/{ => unit}/snapshots/basic_input.csv | 0 tests/{ => unit}/test_api_unit.py | 2 +- tests/{ => unit}/test_ci_quality_gate.py | 2 +- tests/unit/test_config_auth.py | 107 ++++ tests/unit/test_csv_parse.py | 267 ++++++++++ tests/{ => unit}/test_csv_utilise.py | 2 +- tests/{ => unit}/test_csv_validator.py | 2 +- tests/unit/test_db_pool.py | 228 ++++++++ tests/{ => unit}/test_evals_runner.py | 2 +- tests/{ => unit}/test_regression.py | 2 +- tests/{ => unit}/test_security.py | 2 +- tests/{ => unit}/test_service_loader.py | 0 tests/{ => unit}/test_snapshot.py | 6 +- 71 files changed, 3136 insertions(+), 249 deletions(-) rename csv-table-hub-main/src/__tests__/{ => unit}/audit-filters.test.ts (100%) rename csv-table-hub-main/src/__tests__/{ => unit}/csv-preview.test.ts (100%) rename csv-table-hub-main/src/__tests__/{ => unit}/export.test.ts (100%) rename csv-table-hub-main/src/__tests__/{ => unit}/job-outcome.test.ts (100%) rename csv-table-hub-main/src/__tests__/{ => unit}/mapping-templates.test.ts (100%) rename csv-table-hub-main/src/__tests__/{ => unit}/uploader-state-machine.test.ts (100%) create mode 100644 docs/TEST_CASES.md create mode 100644 scripts/test_catalogue.py create mode 100644 tests/README.md rename tests/{environments => accessibility}/__init__.py (100%) create mode 100644 tests/accessibility/conftest.py create mode 100644 tests/accessibility/test_accessibility.py create mode 100644 tests/performance/__init__.py create mode 100644 tests/performance/test_performance.py create mode 100644 tests/system/__init__.py rename tests/{ => system}/test_api_coverage.py (100%) rename tests/{ => system}/test_csv_loader_arbitrary_shapes.py (98%) rename tests/{ => system}/test_e2e_pipeline.py (99%) create mode 100644 tests/system/test_system_api.py create mode 100644 tests/system_integration/__init__.py create mode 100644 tests/system_integration/environments/__init__.py rename tests/{ => system_integration}/environments/_contract.py (100%) rename tests/{ => system_integration}/environments/test_env_dev.py (79%) rename tests/{ => system_integration}/environments/test_env_prod.py (80%) rename tests/{ => system_integration}/environments/test_env_staging.py (80%) rename tests/{ => system_integration}/environments/test_env_test.py (80%) rename tests/{ => system_integration}/test_api_integration.py (100%) rename tests/{ => system_integration}/test_parity.py (99%) create mode 100644 tests/system_integration/test_sit_frontend_contract.py rename tests/{ => system_integration}/test_te_loader_integration.py (99%) create mode 100644 tests/uat/__init__.py rename tests/{ => uat}/test_issue_04_multi_file_upload.py (99%) rename tests/{ => uat}/test_issue_05_import_summary.py (99%) create mode 100644 tests/uat/test_uat_user_stories.py create mode 100644 tests/unit/__init__.py rename tests/{ => unit}/snapshots/basic_expected_skip.csv (100%) rename tests/{ => unit}/snapshots/basic_expected_valid.csv (100%) rename tests/{ => unit}/snapshots/basic_input.csv (100%) rename tests/{ => unit}/test_api_unit.py (99%) rename tests/{ => unit}/test_ci_quality_gate.py (99%) create mode 100644 tests/unit/test_config_auth.py create mode 100644 tests/unit/test_csv_parse.py rename tests/{ => unit}/test_csv_utilise.py (98%) rename tests/{ => unit}/test_csv_validator.py (98%) create mode 100644 tests/unit/test_db_pool.py rename tests/{ => unit}/test_evals_runner.py (98%) rename tests/{ => unit}/test_regression.py (99%) rename tests/{ => unit}/test_security.py (99%) rename tests/{ => unit}/test_service_loader.py (100%) rename tests/{ => unit}/test_snapshot.py (94%) diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 3c48374..aa3d090 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -41,6 +41,11 @@ jobs: - name: Component health check run: make health + # docs/TEST_CASES.md is generated from the test sources; fail if a test + # was added or changed without regenerating it (make test-catalogue). + - name: Test catalogue is up to date + run: python3 scripts/test_catalogue.py --check + - name: Evals — Tier P (offline validator scenarios) run: python3 evals/runner.py --tiers p --verbose @@ -150,8 +155,14 @@ jobs: # Prints a final block accounting for every test: PASSED / FAILED / # ERROR / SKIPPED (with reasons) / NOT RUN (deselected). --strict fails # the build on any skip. + # The T&E schema (te_dev) is deployed into te_mgmt_dev, so the API under + # test must connect there; with the job-level PGDATABASE=postgres every + # T&E-mode test failed with "relation te_dev.organisations does not exist". + # Accessibility tests need Node + a browser and run in their own job below. - name: Full test suite — final result with skip accounting - run: python3 scripts/test_report.py --strict + env: + PGDATABASE: te_mgmt_dev + run: python3 scripts/test_report.py --markers "not accessibility" --strict - name: Upload eval reports if: always() @@ -161,3 +172,57 @@ jobs: path: evals/reports/ if-no-files-found: ignore retention-days: 30 + + # WCAG 2.1 AA checks on the React frontend: Playwright (Chromium) + axe-core. + # The backend is mocked inside the tests, so no database is needed. + accessibility: + name: accessibility (playwright + axe) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: | + requirements-dev.txt + api/requirements.txt + + - name: Install dev dependencies + run: pip install -r requirements-dev.txt -r api/requirements.txt + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install frontend dependencies + working-directory: csv-table-hub-main + run: bun install --frozen-lockfile + + - name: Install Playwright Chromium + run: python -m playwright install --with-deps chromium + + - name: Frontend unit tests (vitest) + working-directory: csv-table-hub-main + run: npx vitest run + + - name: Accessibility tests — final result with skip accounting + env: + TMPDIR: ${{ runner.temp }} + run: python3 scripts/test_report.py --markers accessibility --strict + + - name: Upload Vite dev-server log + if: always() + uses: actions/upload-artifact@v7 + with: + name: a11y-vite-log + path: ${{ runner.temp }}/a11y-vite.log + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 6168fba..b329a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,9 +46,9 @@ _norton_/ extend to Oracle dbs/ # Runtime test artifacts (regenerate with scripts/test_report.py) -# tests/snapshots/ holds committed fixtures AND runtime-generated output. +# tests/unit/snapshots/ holds committed fixtures AND runtime-generated output. # Ignore only the generated file; fixtures must be committed. -tests/snapshots/all_valid_expected_valid.csv +tests/unit/snapshots/all_valid_expected_valid.csv tfplan *.tfplan terraform-provider-*.log diff --git a/Makefile b/Makefile index 2efe6c9..9822629 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ .PHONY: test-free test-gate test-evals test-e2e test-all \ + test-unit test-system test-sit test-performance test-accessibility test-uat \ + test-catalogue \ lint lint-diff health \ eval-list eval-compare eval-summary select-tests \ csv-load csv-list csv-demo @@ -32,6 +34,37 @@ test-all: pytest tests/ --cov=build/csv --cov=evals --cov-report=term-missing python3 evals/runner.py --tiers p,i,s --verbose +# ── Test groups (see tests/README.md) ─────────────────────────────────────────── + +# Unit: no database, no network. Python + frontend (vitest). +test-unit: + pytest -m unit tests/ -v + cd csv-table-hub-main && npx vitest run + +# System: whole backend through its public API. Requires PostgreSQL. +test-system: + pytest tests/system -v + +# System integration: API <-> frontend contract, API <-> DB, cross-env parity. +test-sit: + pytest tests/system_integration -v + +# Performance: throughput / latency budgets. PERF_BUDGET_SCALE=3 on slow machines. +test-performance: + pytest tests/performance -v + +# Accessibility: WCAG 2.1 AA with Playwright + axe. Needs csv-table-hub-main/node_modules. +test-accessibility: + pytest tests/accessibility -v + +# User acceptance: Given/When/Then user stories. Requires PostgreSQL. +test-uat: + pytest tests/uat -v + +# Regenerate docs/TEST_CASES.md from the test sources. +test-catalogue: + python3 scripts/test_catalogue.py + # ── Code quality ────────────────────────────────────────────────────────────── # Full flake8 + bandit scan over all Python source. diff --git a/README.md b/README.md index 9953933..855de35 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![FastAPI](https://img.shields.io/badge/FastAPI-latest-009688?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/) [![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)](https://react.dev/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/Tests-76%20vitest%20%7C%20142%20SQL%20assertions-brightgreen)](#test-suite) +[![Tests](https://img.shields.io/badge/Tests-95%20vitest%20%7C%20142%20SQL%20assertions%20%7C%206%20test%20groups-brightgreen)](#test-suite) --- @@ -61,7 +61,7 @@ PostgreDataMigrationAppWithCSVLoader/ │ │ ├── export.ts ← CSV / XLSX / PDF export │ │ ├── mapping-templates.ts ← saved column mapping templates │ │ └── csv-preview.ts ← CSV parsing utilities -│ ├── src/__tests__/ ← 76 vitest tests +│ ├── src/__tests__/unit/ ← 95 vitest unit tests │ ├── .env.local ← VITE_API_BASE, VITE_API_KEY (not committed) │ └── package.json │ @@ -83,12 +83,14 @@ PostgreDataMigrationAppWithCSVLoader/ │ ├── start-frontend.ps1 ← Terminal 2: Vite on :8080 │ └── provision_full_test_env.sh ← one-command DB setup │ -├── tests/ -│ ├── test_api_unit.py ← 12 unit tests (no DB required) -│ ├── test_api_integration.py ← 19 integration tests -│ ├── test_te_loader_integration.py ← 11 T&E mode integration tests -│ ├── environments/ ← 4 per-environment contract tests -│ └── snapshots/ ← committed CSV test fixtures +├── tests/ ← grouped by test category (tests/README.md) +│ ├── unit/ ← no DB: parser, config, auth, pool, validator +│ ├── system/ ← whole backend through its public API +│ ├── system_integration/ ← API↔UI contract, API↔DB, env parity +│ ├── performance/ ← throughput / latency budgets +│ ├── accessibility/ ← WCAG 2.1 AA (Playwright + axe-core) +│ ├── uat/ ← Given/When/Then user stories +│ └── suites/, framework/ ← 142-assertion SQL suite (psql) │ ├── env.dev.example ← dev environment template ├── docs/ENV_VARIABLES.md ← all environment variables documented @@ -184,19 +186,26 @@ Interactive docs: `http://localhost:8000/docs` ### Python tests +Tests are grouped into unit, system, system integration, performance, +accessibility and user acceptance tests. See [`tests/README.md`](tests/README.md) +for what each group covers, and [`docs/TEST_CASES.md`](docs/TEST_CASES.md) for +the full list of test cases. + ```powershell # Unit tests — no database needed (~5 seconds) python -m pytest tests\ -m "unit or regression or security or snapshot" -q -# Integration tests — requires running API + provisioned database -python -m pytest tests\test_api_integration.py -v -python -m pytest tests\test_te_loader_integration.py -v +# Need a provisioned dev database ($env:PGDATABASE = "te_mgmt_dev") +python -m pytest tests\system -v +python -m pytest tests\system_integration -v +python -m pytest tests\performance -v +python -m pytest tests\uat -v -# Per-environment contract tests -python -m pytest tests\environments\ -v +# Needs the frontend installed (cd csv-table-hub-main; bun install) +python -m pytest tests\accessibility -v ``` -### Frontend tests (76 vitest) +### Frontend tests (95 vitest) ```powershell cd csv-table-hub-main @@ -283,7 +292,7 @@ organisations ──< personnel | Connection Limit | 10 | 15 | 25 | 50 | | Seed Data | ✅ | ✅ | ❌ | ❌ | -Staging and Prod are **never seeded**. The `tests/environments/` contract tests +Staging and Prod are **never seeded**. The `tests/system_integration/environments/` contract tests verify this automatically. --- diff --git a/csv-table-hub-main/bun.lock b/csv-table-hub-main/bun.lock index 36c7efe..7a24dfe 100644 --- a/csv-table-hub-main/bun.lock +++ b/csv-table-hub-main/bun.lock @@ -61,25 +61,38 @@ "devDependencies": { "@eslint/js": "^9.32.0", "@lovable.dev/vite-tanstack-config": "2.7.7", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", + "@vitest/ui": "^4.1.10", "eslint": "^9.32.0", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", + "jsdom": "^30.0.1", "nitro": "3.0.260603-beta", "prettier": "^3.7.3", "typescript": "^5.8.3", "typescript-eslint": "^8.56.1", "vite": "^8.0.16", + "vitest": "^4.1.10", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.1.4", "", { "dependencies": { "@csstools/css-calc": "^3.4.3", "@csstools/css-color-parser": "^4.2.6", "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" } }, "sha512-KdNhWlAh2sbYB8Of0Yw84/a+LO/vsBOHJcIg8BpyXo2RGrFWVJvEMRviMON+ysQu0KpV7wxrWrq9jaX0P21GzA=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@9.2.4", "", { "dependencies": { "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.3" } }, "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -120,65 +133,27 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], - - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@bramus/specificity": ["@bramus/specificity@2.4.3", "", { "dependencies": { "css-tree": "^3.2.1" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.6", "", { "dependencies": { "@csstools/color-helpers": "^6.1.2", "@csstools/css-calc": "^3.4.3" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.2", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.15", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.2", "", {}, "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -198,6 +173,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -288,6 +265,8 @@ "@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -428,55 +407,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -560,8 +491,18 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.7", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -570,6 +511,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], @@ -588,6 +531,8 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -620,12 +565,30 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/ui": ["@vitest/ui@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], @@ -634,12 +597,18 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -648,6 +617,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001790", "", {}, "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -674,6 +645,10 @@ "crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -698,6 +673,8 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "data-urls": ["data-urls@8.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.2" } }, "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -706,16 +683,22 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], @@ -728,9 +711,11 @@ "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + "env-runner": ["env-runner@0.1.9", "", { "dependencies": { "crossws": "^0.4.5", "exsolve": "^1.0.8", "httpxy": "^0.5.3", "srvx": "^0.11.15" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": "^0.2.0", "miniflare": "^4.20260515.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -758,10 +743,14 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -778,6 +767,8 @@ "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -792,8 +783,6 @@ "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], @@ -810,6 +799,8 @@ "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="], + "httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -818,6 +809,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], @@ -826,6 +819,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "isbot": ["isbot@5.1.39", "", {}, "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -836,6 +831,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@30.1.2", "", { "dependencies": { "@asamuzakjp/css-color": "^7.1.3", "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.15", "@exodus/bytes": "^1.16.0", "css-tree": "^3.2.1", "data-urls": "^8.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.3", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", "undici": "^8.11.2", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -884,14 +881,22 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -906,6 +911,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "ocache": ["ocache@0.1.4", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-e7geNdWjxSnvsSgvLuPvgKgu7ubM10ZmTPOgpr7mz2BXYtvjMKTiLhjFi/gWU8chkuP6hNkZBsa9LzOusyaqkQ=="], "ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="], @@ -922,6 +929,8 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -940,6 +949,8 @@ "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -974,16 +985,18 @@ "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "rolldown": ["rolldown@1.1.0", "", { "dependencies": { "@oxc-project/types": "=0.134.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.0", "@rolldown/binding-darwin-arm64": "1.1.0", "@rolldown/binding-darwin-x64": "1.1.0", "@rolldown/binding-freebsd-x64": "1.1.0", "@rolldown/binding-linux-arm-gnueabihf": "1.1.0", "@rolldown/binding-linux-arm64-gnu": "1.1.0", "@rolldown/binding-linux-arm64-musl": "1.1.0", "@rolldown/binding-linux-ppc64-gnu": "1.1.0", "@rolldown/binding-linux-s390x-gnu": "1.1.0", "@rolldown/binding-linux-x64-gnu": "1.1.0", "@rolldown/binding-linux-x64-musl": "1.1.0", "@rolldown/binding-openharmony-arm64": "1.1.0", "@rolldown/binding-wasm32-wasi": "1.1.0", "@rolldown/binding-win32-arm64-msvc": "1.1.0", "@rolldown/binding-win32-x64-msvc": "1.1.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-zpMvlJhs5PkXRTtKc0CaLBVI9AR/VDiJFpM+kx//hgToEca7FgMlGjaRIisXBcb19T76LswgmKECSQ96hjWr5A=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + "rolldown": ["rolldown@1.1.0", "", { "dependencies": { "@oxc-project/types": "=0.134.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.0", "@rolldown/binding-darwin-arm64": "1.1.0", "@rolldown/binding-darwin-x64": "1.1.0", "@rolldown/binding-freebsd-x64": "1.1.0", "@rolldown/binding-linux-arm-gnueabihf": "1.1.0", "@rolldown/binding-linux-arm64-gnu": "1.1.0", "@rolldown/binding-linux-arm64-musl": "1.1.0", "@rolldown/binding-linux-ppc64-gnu": "1.1.0", "@rolldown/binding-linux-s390x-gnu": "1.1.0", "@rolldown/binding-linux-x64-gnu": "1.1.0", "@rolldown/binding-linux-x64-musl": "1.1.0", "@rolldown/binding-openharmony-arm64": "1.1.0", "@rolldown/binding-wasm32-wasi": "1.1.0", "@rolldown/binding-win32-arm64-msvc": "1.1.0", "@rolldown/binding-win32-x64-msvc": "1.1.0" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-zpMvlJhs5PkXRTtKc0CaLBVI9AR/VDiJFpM+kx//hgToEca7FgMlGjaRIisXBcb19T76LswgmKECSQ96hjWr5A=="], "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -998,6 +1011,10 @@ "shell-quote": ["shell-quote@1.9.0", "", {}, "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -1006,6 +1023,12 @@ "srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1020,16 +1043,30 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], + + "tldts": ["tldts@7.4.18", "", { "dependencies": { "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg=="], + + "tldts-core": ["tldts-core@7.4.18", "", {}, "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@7.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -1040,6 +1077,8 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -1068,22 +1107,42 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@6.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@17.2.0", "", { "dependencies": { "@exodus/bytes": "^1.16.0", "tr46": "^7.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "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=="], + "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -1142,6 +1201,10 @@ "@tanstack/start-plugin-core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1160,68 +1223,18 @@ "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "vite/rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], diff --git a/csv-table-hub-main/src/__tests__/audit-filters.test.ts b/csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts similarity index 100% rename from csv-table-hub-main/src/__tests__/audit-filters.test.ts rename to csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts diff --git a/csv-table-hub-main/src/__tests__/csv-preview.test.ts b/csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts similarity index 100% rename from csv-table-hub-main/src/__tests__/csv-preview.test.ts rename to csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts diff --git a/csv-table-hub-main/src/__tests__/export.test.ts b/csv-table-hub-main/src/__tests__/unit/export.test.ts similarity index 100% rename from csv-table-hub-main/src/__tests__/export.test.ts rename to csv-table-hub-main/src/__tests__/unit/export.test.ts diff --git a/csv-table-hub-main/src/__tests__/job-outcome.test.ts b/csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts similarity index 100% rename from csv-table-hub-main/src/__tests__/job-outcome.test.ts rename to csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts diff --git a/csv-table-hub-main/src/__tests__/mapping-templates.test.ts b/csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts similarity index 100% rename from csv-table-hub-main/src/__tests__/mapping-templates.test.ts rename to csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts diff --git a/csv-table-hub-main/src/__tests__/uploader-state-machine.test.ts b/csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts similarity index 100% rename from csv-table-hub-main/src/__tests__/uploader-state-machine.test.ts rename to csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts diff --git a/csv-table-hub-main/src/routes/_authenticated/index.tsx b/csv-table-hub-main/src/routes/_authenticated/index.tsx index 98727d1..69bc301 100644 --- a/csv-table-hub-main/src/routes/_authenticated/index.tsx +++ b/csv-table-hub-main/src/routes/_authenticated/index.tsx @@ -334,7 +334,7 @@ function StatusPill({status}:{status:JobStatus}){ processing:{cls:"bg-muted text-muted-foreground",icon:}, done:{cls:"bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",icon:}, duplicate:{cls:"bg-amber-500/10 text-amber-700 dark:text-amber-400",icon:}, - error:{cls:"bg-destructive/10 text-destructive",icon:}, + error:{cls:"bg-destructive/10 text-red-700 dark:text-red-400",icon:}, interrupted:{cls:"bg-slate-500/10 text-slate-600 dark:text-slate-400",icon:}, cancelled:{cls:"bg-slate-500/10 text-slate-600 dark:text-slate-400",icon:}, }; @@ -387,8 +387,8 @@ function JobRow({job,onRetry,onOverwrite,onCancel}:{job:Job;onRetry:(id:string)= {(isError||isDuplicate||isInterrupted)&&} -
div]:bg-destructive":isDuplicate?"[&>div]:bg-amber-500":isInterrupted?"[&>div]:bg-slate-500":isDone?"[&>div]:bg-emerald-500":""}`}/>
- {isDone&&

{job.overwritten?"Overwrote previous upload · ":""}Imported {job.insertedRows} of {job.totalRows??job.insertedRows} rows{job.duplicateRowsSkipped?` · ${job.duplicateRowsSkipped} duplicate rows skipped`:""}{job.failedRows?` · ${job.failedRows} row${job.failedRows===1?"":"s"} failed validation`:""}{job.tableName?<> · stored in {job.tableName}:null}

} +
div]:bg-destructive":isDuplicate?"[&>div]:bg-amber-500":isInterrupted?"[&>div]:bg-slate-500":isDone?"[&>div]:bg-emerald-500":""}`}/>
+ {isDone&&

{job.overwritten?"Overwrote previous upload · ":""}Imported {job.insertedRows} of {job.totalRows??job.insertedRows} rows{job.duplicateRowsSkipped?` · ${job.duplicateRowsSkipped} duplicate rows skipped`:""}{job.failedRows?` · ${job.failedRows} row${job.failedRows===1?"":"s"} failed validation`:""}{job.tableName?<> · stored in {job.tableName}:null}

} {isDuplicate&&

{job.duplicateReason==="name"?<>A file named {job.name} is already imported with different contents. Click Overwrite to replace it.:<>Already imported{job.existingFileName?<> as "{job.existingFileName}":<> previously}. Click Overwrite to re-import.}

} {job.status==="cancelled"&&job.cancellationReason&&

Cancelled: {job.cancellationReason}

} {isError&&} @@ -532,7 +532,7 @@ function PreviewDialog({pending,index,onPrev,onNext,onUpdateMapping,onUpdateMode {isTe&&(
updateMapping(colIdx,{type:v as ColumnType})}> - + {(["int8","numeric","date","timestamptz","boolean","text"] as ColumnType[]).map(t=>{COLUMN_TYPE_LABEL[t]})} @@ -647,7 +647,7 @@ function DiffDialog({job,onConfirm,onCancel}:{job:Job|null;onConfirm:()=>void;on Overwrite confirmation - This will replace the existing data in {job.tableName}. Here are the first rows currently stored. + This will replace the existing data in {job.tableName}. Here are the first rows currently stored.
{isLoading?
Loading existing rows…
:rows.length===0?

No rows in this table.

:( @@ -699,7 +699,7 @@ function FilesList({files}:{files:CsvFileSummary[]}){
Rows per page
diff --git a/csv-table-hub-main/src/routes/audit.tsx b/csv-table-hub-main/src/routes/audit.tsx index 4ed1760..5022ee6 100644 --- a/csv-table-hub-main/src/routes/audit.tsx +++ b/csv-table-hub-main/src/routes/audit.tsx @@ -32,7 +32,7 @@ function statusCls(s: JobStatus, ow?: boolean) { if (s==="done"&&ow) return "bg-blue-500/10 text-blue-700 dark:text-blue-400"; if (s==="done") return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"; if (s==="duplicate") return "bg-amber-500/10 text-amber-700 dark:text-amber-400"; - if (s==="error") return "bg-destructive/10 text-destructive"; + if (s==="error") return "bg-destructive/10 text-red-700 dark:text-red-400"; return "bg-slate-500/10 text-slate-600 dark:text-slate-400"; } function fmtBytes(b: number) { if(b<1024) return `${b} B`; if(b<1048576) return `${(b/1024).toFixed(1)} KB`; return `${(b/1048576).toFixed(2)} MB`; } @@ -110,7 +110,7 @@ function AuditPage() { setSearch(e.target.value)} className="pl-8 h-9 text-sm"/>
@@ -163,7 +163,7 @@ function AuditPage() { {job.cancellationReason &&

{job.cancellationReason}

} - {job.totalRows!=null ?
{job.totalRows} total{job.insertedRows!=null&&{job.insertedRows} ok}{(job.failedRows??0)>0&&{job.failedRows} failed}
: —} + {job.totalRows!=null ?
{job.totalRows} total{job.insertedRows!=null&&{job.insertedRows} ok}{(job.failedRows??0)>0&&{job.failedRows} failed}
: —}
{new Date(job.createdAt).toISOString().replace("T"," ").slice(0,19)} UTC {mappingSummary(job.headerMapping)||—} @@ -173,7 +173,7 @@ function AuditPage() { <> - + {const r=buildErrorRows(job.name,job.rowErrors??[],job.errorMessage,job.headerMapping);downloadErrorReport(job.name,r,"csv");toast.success("Downloaded");}}>As CSV {const r=buildErrorRows(job.name,job.rowErrors??[],job.errorMessage,job.headerMapping);downloadErrorReport(job.name,r,"xlsx");toast.success("Downloaded");}}>As XLSX diff --git a/csv-table-hub-main/src/routes/auth.tsx b/csv-table-hub-main/src/routes/auth.tsx index 64f253c..4bdbcc5 100644 --- a/csv-table-hub-main/src/routes/auth.tsx +++ b/csv-table-hub-main/src/routes/auth.tsx @@ -27,7 +27,7 @@ function AuthPage() {

- Go back to the home screen and upload CSV files directly. Configure VITE_API_BASE to point to your backend. + Go back to the home screen and upload CSV files directly. Configure VITE_API_BASE to point to your backend.

diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 014c2fd..26b8d8b 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -238,8 +238,8 @@ Per the repository's no-skip policy, the integration group **fails** with remediation text when the database is unreachable rather than skipping. ```bash -python -m pytest tests/test_api_unit.py -m unit # no database -python -m pytest tests/test_api_unit.py tests/test_api_integration.py # both files +python -m pytest tests/unit/test_api_unit.py -m unit # no database +python -m pytest tests/unit/test_api_unit.py tests/system_integration/test_api_integration.py # both files python scripts/test_report.py --strict # whole suite ``` @@ -255,8 +255,8 @@ with remediation text rather than skipping, per the no-skip policy. ## Also required -CI installs only `requirements-dev.txt`. Because `tests/test_api_unit.py` and -`tests/test_api_integration.py` import FastAPI at module level, both +CI installs only `requirements-dev.txt`. Because `tests/unit/test_api_unit.py` and +`tests/system_integration/test_api_integration.py` import FastAPI at module level, both workflows need: ```yaml diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9e0e48..294fffb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,15 +50,15 @@ The three layers can break independently, so we keep them physically separate. T | `tests/run_tests.sh` | Bash wrapper that sources `config.local.env` | | `tests/run_python_tests.ps1` | Windows runner — invoked by the GitHub Actions workflow | | `tests/conftest.py` | pytest env-var isolation between tests | -| `tests/test_csv_validator.py` | unittest for `build/csv/validator.py` | -| `tests/test_csv_utilise.py` | unit tests for `build/csv_utilise.sh` argument parsing | -| `tests/test_csv_loader_arbitrary_shapes.py` | integration: arbitrary CSV shapes through loader → PG (skips without PG) | -| `tests/test_e2e_pipeline.py` | e2e: CSV → validate → load → verify (DB half skips without PG) | -| `tests/test_parity.py` | cross-environment row-count / schema parity (skips without PG) | -| `tests/test_regression.py` | pinned tests for previously found bug classes | -| `tests/test_security.py` | static credential/SQL-pattern scans | -| `tests/test_snapshot.py` | golden-file output comparisons (`tests/snapshots/`) | -| `tests/test_evals_runner.py` | unittest for `evals/runner.py` itself | +| `tests/unit/test_csv_validator.py` | unittest for `build/csv/validator.py` | +| `tests/unit/test_csv_utilise.py` | unit tests for `build/csv_utilise.sh` argument parsing | +| `tests/system/test_csv_loader_arbitrary_shapes.py` | integration: arbitrary CSV shapes through loader → PG (skips without PG) | +| `tests/system/test_e2e_pipeline.py` | e2e: CSV → validate → load → verify (DB half skips without PG) | +| `tests/system_integration/test_parity.py` | cross-environment row-count / schema parity (skips without PG) | +| `tests/unit/test_regression.py` | pinned tests for previously found bug classes | +| `tests/unit/test_security.py` | static credential/SQL-pattern scans | +| `tests/unit/test_snapshot.py` | golden-file output comparisons (`tests/unit/snapshots/`) | +| `tests/unit/test_evals_runner.py` | unittest for `evals/runner.py` itself | ### `evals/` — data-driven scenarios @@ -110,7 +110,7 @@ These are left at the repo root and excluded from the layer model. They can move ## Path conventions inside the layers - Code inside `evals/runner.py` computes `PROJECT_ROOT = Path(__file__).resolve().parent.parent` and reaches into `build/csv/validator.py`, `build/environments/env_dev.sql`, `tests/run_all_tests.sql` from there. -- Code inside `tests/test_csv_validator.py` does `Path(__file__).resolve().parents[1] / "build" / "csv" / "validator.py"`. +- Code inside `tests/unit/test_csv_validator.py` does `Path(__file__).resolve().parents[2] / "build" / "csv" / "validator.py"`. - Shell scripts inside `build/` cd into their own directory and use relative paths (`./adapters/...`, `./schema/...`). - Nothing in `build/` references `tests/` or `evals/`. diff --git a/docs/ENV_VARIABLES.md b/docs/ENV_VARIABLES.md index 905ecc8..45d86e7 100644 --- a/docs/ENV_VARIABLES.md +++ b/docs/ENV_VARIABLES.md @@ -61,7 +61,7 @@ the environment SQL templates. Copy `build/config.env.example` to > **Staging and prod must never be seeded.** The `SEED_STAGING` and `SEED_PROD` > variables default to `false`. Changing them to `true` will insert test data -> into production. The `tests/test_parity.py` contract tests verify this. +> into production. The `tests/system_integration/test_parity.py` contract tests verify this. ### Additional build-script variables diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md new file mode 100644 index 0000000..22d998a --- /dev/null +++ b/docs/TEST_CASES.md @@ -0,0 +1,486 @@ +# Test cases + +Every automated test case in the repository, grouped by test category. +Generated by `scripts/test_catalogue.py`; do not edit by hand. Run +`make test-catalogue` after adding or changing tests. How to run each group +is in [`tests/README.md`](../tests/README.md). + +Each row is one test function. **Cases** is the number of executions +after parametrisation. The objective is the test's docstring, which states +the behaviour asserted, and therefore the expected result. + +## Summary + +| Category | Folder | Tests | Cases | +|----------|--------|-------|-------| +| [Unit tests](#unit-tests) | `tests/unit/` | 236 | 279 | +| [System tests](#system-tests) | `tests/system/` | 68 | 70 | +| [System integration tests](#system-integration-tests) | `tests/system_integration/` | 61 | 200 | +| [Performance tests](#performance-tests) | `tests/performance/` | 13 | 16 | +| [Accessibility tests](#accessibility-tests) | `tests/accessibility/` | 14 | 17 | +| [User acceptance tests](#user-acceptance-tests) | `tests/uat/` | 31 | 33 | + +## Unit tests + +Single functions and modules in isolation. No database, no network. + +| ID | File | Test | Objective / expected result | Cases | +|----|------|------|-----------------------------|-------| +| UT-001 | `tests/unit/test_api_unit.py` | `UnitCsvParse.test_sanitize_columns_no_collision_on_stem_conflict` | regression: ["foo","foo","foo\_2"] must produce three distinct names. The old algorithm bumped a per-stem counter without checking the resulting name against already-assigned names, so the second "foo" became "foo\_2" and collided with the caller-supplied "foo\_2" — the dynamic loader then rejected the whole upload as "Duplicate sanitized column". | 1 | +| UT-002 | `tests/unit/test_api_unit.py` | `UnitCsvParse.test_sanitize_columns_dedupes_true_duplicates` | Guard-rail for the BUG-036 fix: real duplicate stems still dedupe. | 1 | +| UT-003 | `tests/unit/test_api_unit.py` | `UnitCsvParse.test_sanitize_columns_reserved_stems_get_suffix` | Guard-rail: reserved names (\_id, \_row\_hash, \_created\_at) must be remapped so they don't collide with the internal columns dynamic\_loader adds to every table. | 1 | +| UT-004 | `tests/unit/test_api_unit.py` | `RequestValidation.test_preview_rejects_empty_filename` | Preview rejects empty filename | 1 | +| UT-005 | `tests/unit/test_api_unit.py` | `RequestValidation.test_preview_rejects_empty_content` | Preview rejects empty content | 1 | +| UT-006 | `tests/unit/test_api_unit.py` | `RequestValidation.test_preview_rejects_overlong_filename` | Preview rejects overlong filename | 1 | +| UT-007 | `tests/unit/test_api_unit.py` | `RequestValidation.test_upload_rejects_unknown_mode` | Upload rejects unknown mode | 1 | +| UT-008 | `tests/unit/test_api_unit.py` | `RequestValidation.test_upload_te_mode_requires_target_table` | Upload te mode requires target table | 1 | +| UT-009 | `tests/unit/test_api_unit.py` | `RequestValidation.test_oversized_upload_is_rejected` | Oversized upload is rejected | 1 | +| UT-010 | `tests/unit/test_api_unit.py` | `TableNameGuards.test_non_upload_tables_are_rejected` | Non upload tables are rejected | 1 | +| UT-011 | `tests/unit/test_api_unit.py` | `TableNameGuards.test_overlong_names_are_rejected` | Overlong names are rejected | 1 | +| UT-012 | `tests/unit/test_api_unit.py` | `TableNameGuards.test_identifiers_are_never_string_formatted` | Every SQL identifier must go through psycopg2.sql.Identifier. | 1 | +| UT-013 | `tests/unit/test_ci_quality_gate.py` | `TestQualityGateStructure.test_has_free_tier_job` | Has free tier job | 1 | +| UT-014 | `tests/unit/test_ci_quality_gate.py` | `TestQualityGateStructure.test_has_integration_postgres_job` | Has integration postgres job | 1 | +| UT-015 | `tests/unit/test_ci_quality_gate.py` | `TestQualityGateStructure.test_triggers_on_pull_request` | Triggers on pull request | 1 | +| UT-016 | `tests/unit/test_ci_quality_gate.py` | `TestQualityGateStructure.test_triggers_on_push_to_main` | Triggers on push to main | 1 | +| UT-017 | `tests/unit/test_ci_quality_gate.py` | `TestAllJobsHaveConsistentStructure.test_all_jobs_have_checkout` | All jobs have checkout | 1 | +| UT-018 | `tests/unit/test_ci_quality_gate.py` | `TestAllJobsHaveConsistentStructure.test_all_jobs_have_python_setup` | All jobs have python setup | 1 | +| UT-019 | `tests/unit/test_ci_quality_gate.py` | `TestAllJobsHaveConsistentStructure.test_all_jobs_have_artifact_upload` | All jobs have artifact upload | 1 | +| UT-020 | `tests/unit/test_ci_quality_gate.py` | `TestAllJobsHaveConsistentStructure.test_all_jobs_install_dev_deps` | All jobs install dev deps | 1 | +| UT-021 | `tests/unit/test_ci_quality_gate.py` | `TestGapAnalysisG2Accepted.test_g2_row_exists` | G2 row exists | 1 | +| UT-022 | `tests/unit/test_ci_quality_gate.py` | `TestGapAnalysisG2Accepted.test_g2_is_accepted_or_deferred` | G2 is accepted or deferred | 1 | +| UT-023 | `tests/unit/test_ci_quality_gate.py` | `TestGapAnalysisG2Accepted.test_g2_section_exists` | G2 section exists | 1 | +| UT-024 | `tests/unit/test_ci_quality_gate.py` | `TestGapAnalysisG2Accepted.test_g2_is_not_marked_closed` | G2 is not marked closed | 1 | +| UT-025 | `tests/unit/test_config_auth.py` | `TestRequireApiKey.test_no_key_configured_allows_request` | With API\_KEY unset (local dev) every request is allowed. | 1 | +| UT-026 | `tests/unit/test_config_auth.py` | `TestRequireApiKey.test_correct_key_is_accepted` | A matching X-API-Key header passes. | 1 | +| UT-027 | `tests/unit/test_config_auth.py` | `TestRequireApiKey.test_wrong_or_missing_key_is_401` | A missing or mismatched key raises 401. | 4 | +| UT-028 | `tests/unit/test_config_auth.py` | `TestSettings.test_defaults` | Unset env vars fall back to the documented local-dev defaults. | 1 | +| UT-029 | `tests/unit/test_config_auth.py` | `TestSettings.test_destructive_operations_disabled_by_default` | BUG-040: DELETE is off unless explicitly enabled. | 1 | +| UT-030 | `tests/unit/test_config_auth.py` | `TestSettings.test_destructive_truthy_values` | 1/true/yes (any case) enable destructive operations. | 4 | +| UT-031 | `tests/unit/test_config_auth.py` | `TestSettings.test_destructive_other_values_stay_disabled` | Anything else keeps destructive operations disabled. | 5 | +| UT-032 | `tests/unit/test_config_auth.py` | `TestSettings.test_cors_origins_split_on_comma` | CORS\_ORIGINS is a comma-separated list. | 1 | +| UT-033 | `tests/unit/test_config_auth.py` | `TestSettings.test_numeric_limits_read_from_env` | Row and error caps are configurable via env vars. | 1 | +| UT-034 | `tests/unit/test_config_auth.py` | `TestTeTables.test_dependency_order` | Parents precede children so T&E loads can respect foreign keys. | 1 | +| UT-035 | `tests/unit/test_config_auth.py` | `TestTeTables.test_names_are_safe_identifiers` | Every whitelisted table name is a plain lowercase identifier. | 1 | +| UT-036 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_simple_rows` | Comma-separated rows split into lists of strings. | 1 | +| UT-037 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_no_trailing_newline` | The final row is kept when the file has no trailing newline. | 1 | +| UT-038 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_empty_input_returns_no_rows` | An empty string parses to an empty list. | 1 | +| UT-039 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_utf8_bom_is_stripped` | A leading UTF-8 BOM does not leak into the first header. | 1 | +| UT-040 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_crlf_line_endings` | Windows CRLF line endings parse the same as LF. | 1 | +| UT-041 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_quoted_comma_stays_in_field` | A comma inside double quotes is data, not a delimiter. | 1 | +| UT-042 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_escaped_double_quote` | Two double quotes inside a quoted field decode to one. | 1 | +| UT-043 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_embedded_newline_in_quoted_field` | A newline inside quotes stays inside the field. | 1 | +| UT-044 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_trailing_blank_rows_are_dropped` | Blank rows at the end of the file are not data rows. | 1 | +| UT-045 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_empty_cells_preserved` | Consecutive delimiters produce empty-string cells. | 1 | +| UT-046 | `tests/unit/test_csv_parse.py` | `TestParseCsv.test_unicode_content` | Non-ASCII text round-trips unchanged. | 1 | +| UT-047 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_lowercases_and_replaces_invalid_chars` | Headers become lowercase snake\_case identifiers. | 1 | +| UT-048 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_leading_digit_gets_prefix` | Identifiers may not start with a digit. | 1 | +| UT-049 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_blank_header_gets_positional_name` | An empty header is replaced by column\_<n>. | 1 | +| UT-050 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_symbol_only_header_gets_positional_name` | A header with no usable characters falls back to column\_<n>. | 1 | +| UT-051 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_reserved_system_column_is_renamed` | \_id collides with the system primary key and must be renamed. | 1 | +| UT-052 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_long_header_is_truncated` | Names are capped at 55 chars so a numeric suffix still fits in 63. | 1 | +| UT-053 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_duplicates_get_unique_suffixes` | Repeated headers are made unique. | 1 | +| UT-054 | `tests/unit/test_csv_parse.py` | `TestSanitizeColumns.test_all_outputs_are_valid_identifiers` | Every sanitised name passes the identifier whitelist. | 1 | +| UT-055 | `tests/unit/test_csv_parse.py` | `TestValidIdentifier.test_accepts_safe_identifiers` | Lowercase letters, digits and underscores up to 63 chars are valid. | 4 | +| UT-056 | `tests/unit/test_csv_parse.py` | `TestValidIdentifier.test_rejects_unsafe_identifiers` | Uppercase, punctuation, leading digits and over-length names are rejected. | 7 | +| UT-057 | `tests/unit/test_csv_parse.py` | `TestInferType.test_picks_narrowest_type` | The narrowest type that fits every non-empty value is chosen. | 8 | +| UT-058 | `tests/unit/test_csv_parse.py` | `TestInferType.test_blank_values_are_ignored` | Empty cells do not widen an otherwise-integer column to text. | 1 | +| UT-059 | `tests/unit/test_csv_parse.py` | `TestInferType.test_impossible_date_is_not_a_date` | 2024-02-30 matches the pattern but is not a real date. | 1 | +| UT-060 | `tests/unit/test_csv_parse.py` | `TestInferType.test_result_is_always_an_allowed_type` | infer\_type never returns a type outside ALLOWED\_TYPES. | 1 | +| UT-061 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_empty_cell_is_null_for_every_type` | A blank cell casts to NULL without error. | 1 | +| UT-062 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_int8_valid` | Whole numbers cast to Python int. | 1 | +| UT-063 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_int8_invalid` | A decimal is rejected for an int8 column with a readable reason. | 1 | +| UT-064 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_numeric_keeps_string_precision` | Numerics are passed as strings so no float rounding occurs. | 1 | +| UT-065 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_numeric_invalid` | Non-numeric text is rejected for a numeric column. | 1 | +| UT-066 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_boolean_valid` | Common boolean spellings are accepted case-insensitively. | 4 | +| UT-067 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_boolean_invalid` | Unrecognised words are rejected for a boolean column. | 1 | +| UT-068 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_date_valid_and_invalid` | Only real YYYY-MM-DD dates are accepted. | 1 | +| UT-069 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_timestamp_without_zone_defaults_to_utc` | A naive timestamp is interpreted as UTC. | 1 | +| UT-070 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_timestamp_z_suffix` | A trailing Z is accepted as UTC. | 1 | +| UT-071 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_timestamp_invalid` | Garbage is rejected for a timestamptz column. | 1 | +| UT-072 | `tests/unit/test_csv_parse.py` | `TestCastValue.test_text_is_passed_through_raw` | Text values keep their original whitespace. | 1 | +| UT-073 | `tests/unit/test_csv_parse.py` | `TestPgTypeMapping.test_known_types_map` | Postgres data\_type names map onto the six client-validated types. | 7 | +| UT-074 | `tests/unit/test_csv_parse.py` | `TestPgTypeMapping.test_unknown_types_return_none` | Types we cannot validate client-side fall back to server casting. | 6 | +| UT-075 | `tests/unit/test_csv_parse.py` | `TestBuildPreview.test_empty_file` | An empty file reports invalid\_structure/empty. | 1 | +| UT-076 | `tests/unit/test_csv_parse.py` | `TestBuildPreview.test_header_only` | A header with no data rows reports header\_only. | 1 | +| UT-077 | `tests/unit/test_csv_parse.py` | `TestBuildPreview.test_blank_header_row` | A blank first row reports no\_columns. | 1 | +| UT-078 | `tests/unit/test_csv_parse.py` | `TestBuildPreview.test_ok_payload_shape` | A valid file returns headers, columns, types, sample rows and a row count. | 1 | +| UT-079 | `tests/unit/test_csv_parse.py` | `TestBuildPreview.test_short_rows_are_padded_and_long_rows_trimmed` | Sample rows always have exactly one cell per column. | 1 | +| UT-080 | `tests/unit/test_csv_parse.py` | `TestBuildPreview.test_sample_rows_are_capped` | Only the first sample\_rows rows are returned. | 1 | +| UT-081 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_no_args_shows_usage_and_exits_nonzero` | No args → exit 1 with usage banner on stdout. | 1 | +| UT-082 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_unknown_command_exits_nonzero` | Unknown subcommand → exit 1 with 'Unknown command' on stderr. | 1 | +| UT-083 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_describe_requires_table_arg` | \`describe\` with no positional table → exit 1 with a helpful message. | 1 | +| UT-084 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_peek_requires_table_arg` | \`peek\` with no positional table → exit 1 with a helpful message. | 1 | +| UT-085 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_export_requires_two_positional_args` | \`export\` needs both <table> and <out.csv> → exit 1 otherwise. | 1 | +| UT-086 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_drop_requires_table_arg` | \`drop\` with no positional table → exit 1. | 1 | +| UT-087 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_help_flag_exits_zero` | \`--help\` short-circuits to a 0 exit with the usage banner. | 1 | +| UT-088 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_invalid_table_name_rejected` | Identifier validation rejects names with spaces or punctuation. | 1 | +| UT-089 | `tests/unit/test_csv_utilise.py` | `CsvUtiliseArgumentParsing.test_unimplemented_engine_returns_clear_error` | Engines other than postgresql exit 2 with a 'not implemented' message. | 1 | +| UT-090 | `tests/unit/test_csv_validator.py` | `CsvValidatorTests.test_fails_when_required_env_vars_missing` | When CSV\_FILE/VALID\_CSV/SKIP\_FILE are absent, the validator should exit 1 with 'Missing required environment variables' on stderr. | 1 | +| UT-091 | `tests/unit/test_csv_validator.py` | `CsvValidatorTests.test_fails_when_csv_file_missing` | When CSV\_FILE points to a non-existent path, the validator should exit 1 with 'CSV file not found' on stderr. | 1 | +| UT-092 | `tests/unit/test_csv_validator.py` | `CsvValidatorTests.test_splits_valid_and_skipped_rows` | Given a 4-row CSV (2 valid, 1 empty row, 1 column-mismatch), valid.csv gets the 2 good rows and skip.csv gets 2 annotated rows with a \_skip\_reason column. | 1 | +| UT-093 | `tests/unit/test_csv_validator.py` | `CsvValidatorTests.test_returns_error_when_no_valid_rows` | When all data rows are invalid, the validator should exit 1 with 'No valid rows found' on stderr. | 1 | +| UT-094 | `tests/unit/test_db_pool.py` | `TestConn.test_raises_when_pool_not_initialised` | Using Conn before init\_pool() fails loudly, not with AttributeError. | 1 | +| UT-095 | `tests/unit/test_db_pool.py` | `TestConn.test_returns_connection_on_success` | The borrowed connection is always returned to the pool. | 1 | +| UT-096 | `tests/unit/test_db_pool.py` | `TestConn.test_rolls_back_and_returns_connection_on_error` | An exception inside the block rolls back and still returns the slot. | 1 | +| UT-097 | `tests/unit/test_db_pool.py` | `TestBorrowWithTimeout.test_hung_getconn_raises_pool_error` | BUG-030: a blocking getconn() surfaces as PoolError after the timeout. | 1 | +| UT-098 | `tests/unit/test_db_pool.py` | `TestBorrowWithTimeout.test_late_connection_is_returned_to_pool` | A getconn() that completes after the timeout gives its slot back. | 1 | +| UT-099 | `tests/unit/test_db_pool.py` | `TestInitPool.test_retries_until_postgres_is_reachable` | BUG-026: transient OperationalError is retried with backoff. | 1 | +| UT-100 | `tests/unit/test_db_pool.py` | `TestInitPool.test_backoff_is_capped_at_ten_seconds` | Exponential backoff never sleeps longer than 10s per attempt. | 1 | +| UT-101 | `tests/unit/test_db_pool.py` | `TestInitPool.test_gives_up_after_max_attempts` | The last OperationalError is re-raised once attempts run out. | 1 | +| UT-102 | `tests/unit/test_db_pool.py` | `TestBootstrap.test_falls_back_when_alembic_ini_missing` | Without alembic.ini the hand-written CREATE IF NOT EXISTS path runs. | 1 | +| UT-103 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_pool_exhaustion_maps_to_503` | A PoolError anywhere in a handler becomes HTTP 503, not 500. | 1 | +| UT-104 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_health_degrades_without_leaking_details` | When the DB is unreachable /api/health returns 200 'degraded' with no host/user/driver text in the body. | 1 | +| UT-105 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_preview_parse_crash_returns_structured_error` | BUG-025: a parser exception never surfaces as a raw 500. | 1 | +| UT-106 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_preview_te_match_is_best_effort` | If the T&E lookup fails the preview still succeeds with a null match. | 1 | +| UT-107 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_methods_are_restricted` | Only GET/POST/DELETE are advertised to browsers. | 1 | +| UT-108 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_structure_errors` | Empty, header-only and blank-header files are rejected with a message. | 3 | +| UT-109 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_row_cap` | BUG-027: files above MAX\_ROWS are rejected up-front. | 1 | +| UT-110 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_logs_have_frontend_shape` | Every processing-log entry carries ts/step/level/message. | 1 | +| UT-111 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_rejects_unknown_target_table` | Only the 12 whitelisted T&E tables are accepted. | 1 | +| UT-112 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_structure_errors` | T&E mode needs a header and at least one data row. | 2 | +| UT-113 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_row_cap` | BUG-027: the row cap also applies in T&E mode. | 1 | +| UT-114 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_load_expected_returns_none_for_missing_scenario` | \_load\_expected with an unknown scenario name should return None without raising. | 1 | +| UT-115 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_discover_scenarios_filters_only_requested_name` | discover\_scenarios with a name argument should return only the directory whose name matches exactly. | 1 | +| UT-116 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_reports_unknown_runner_action_as_failure` | An expected JSON with an unrecognised runner\_action should produce a failed result containing 'Unknown runner\_action' in errors. | 1 | +| UT-117 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_generated_invalid_utf8_skips_row_cleanly` | The 23\_invalid\_utf8\_bytes eval scenario: an invalid-UTF-8 data row is skipped with a reason while valid rows still pass (exit\_code=0), per the loader's load-what-is-valid contract. No raw Traceback may appear. | 1 | +| UT-118 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_i_fails_when_postgresql_is_unavailable` | When \_can\_connect\_pg returns False, run\_tier\_i\_scenario must FAIL (not skip): an unavailable prerequisite is a failure, so a green run always means the scenario really executed. | 1 | +| UT-119 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_count_dev_rows_records_none_for_failed_count_query` | When the count query subprocess exits non-zero, \_count\_dev\_rows should record None for every entry in DEV\_SEED\_TABLES. | 1 | +| UT-120 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_have_psql_uses_path_lookup` | When shutil.which returns None, \_have\_psql should return False. | 1 | +| UT-121 | `tests/unit/test_regression.py` | `RegressionBomHandling.test_bom_csv_is_accepted` | Bom csv is accepted | 1 | +| UT-122 | `tests/unit/test_regression.py` | `RegressionCrlfLineEndings.test_crlf_csv_accepted` | Crlf csv accepted | 1 | +| UT-123 | `tests/unit/test_regression.py` | `RegressionHeaderOnlyCsv.test_header_only_exits_cleanly` | Header only exits cleanly | 1 | +| UT-124 | `tests/unit/test_regression.py` | `RegressionSingleColumnCsv.test_single_column_valid_rows_pass` | Single column valid rows pass | 1 | +| UT-125 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_missing_env_vars_no_traceback` | Missing env vars no traceback | 1 | +| UT-126 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_nonexistent_file_no_traceback` | Nonexistent file no traceback | 1 | +| UT-127 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_invalid_utf8_no_traceback` | Invalid utf8 no traceback | 1 | +| UT-128 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_validator_no_hardcoded_credentials` | Validator no hardcoded credentials | 1 | +| UT-129 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_runner_no_hardcoded_credentials` | Runner no hardcoded credentials | 1 | +| UT-130 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_gap_report_no_hardcoded_credentials` | Gap report no hardcoded credentials | 1 | +| UT-131 | `tests/unit/test_security.py` | `TestNoFStringSql.test_validator_no_fstring_sql` | Validator no fstring sql | 1 | +| UT-132 | `tests/unit/test_security.py` | `TestNoFStringSql.test_runner_no_fstring_sql` | Runner no fstring sql | 1 | +| UT-133 | `tests/unit/test_security.py` | `TestConfigEnvExample.test_pg_password_is_empty` | Pg password is empty | 1 | +| UT-134 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_adapters` | No echo password in adapters | 1 | +| UT-135 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_loaders` | No echo password in loaders | 1 | +| UT-136 | `tests/unit/test_security.py` | `TestSqlFilesNoInlineCredentials.test_env_sql_files_no_hardcoded_passwords` | Env sql files no hardcoded passwords | 1 | +| UT-137 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_dynamic_upload_rejects_mismatched_type_list_length` | Dynamic upload rejects mismatched type list length | 1 | +| UT-138 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_te_cleanup_prior_registry_entries_drops_dynamic_table` | Te cleanup prior registry entries drops dynamic table | 1 | +| UT-139 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_valid_csv_matches_snapshot` | Valid csv matches snapshot | 1 | +| UT-140 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_skip_csv_matches_snapshot` | Skip csv matches snapshot | 1 | +| UT-141 | `tests/unit/test_snapshot.py` | `SnapshotAllValidInput.test_all_valid_no_skips` | All valid no skips | 1 | +| UT-142 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns all jobs when search is empty | 1 | +| UT-143 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | filters by exact substring | 1 | +| UT-144 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | is case-insensitive | 1 | +| UT-145 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty array when nothing matches | 1 | +| UT-146 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | matches partial filenames | 1 | +| UT-147 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'all' returns every job | 1 | +| UT-148 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'done' returns only done jobs (including overwritten) | 1 | +| UT-149 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'overwritten' returns only done+overwritten jobs | 1 | +| UT-150 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'error' returns only failed jobs | 1 | +| UT-151 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'duplicate' returns only duplicate jobs | 1 | +| UT-152 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'cancelled' returns only cancelled jobs | 1 | +| UT-153 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | non-overwritten done job does NOT appear under 'overwritten' | 1 | +| UT-154 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom excludes jobs before the date | 1 | +| UT-155 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateTo includes jobs on the specified day; jobs strictly after it are excluded | 1 | +| UT-156 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom + dateTo together form a closed range | 1 | +| UT-157 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | empty date strings do not filter by date | 1 | +| UT-158 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + status combined narrow correctly | 1 | +| UT-159 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + date + status combined to a single result | 1 | +| UT-160 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty when search and status conflict | 1 | +| UT-161 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | lowercases and trims headers | 1 | +| UT-162 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | replaces non-alphanumeric chars with underscores | 1 | +| UT-163 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips leading and trailing underscores | 1 | +| UT-164 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | prefixes numeric-starting names with col\_ | 1 | +| UT-165 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | generates a name for empty/blank headers | 1 | +| UT-166 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | deduplicates by appending \_2, \_3, … | 1 | +| UT-167 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reserved system column names get a \_col suffix instead of colliding | 1 | +| UT-168 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | truncates names longer than 55 characters | 1 | +| UT-169 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles a mix of edge cases in one call | 1 | +| UT-170 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns headers and one sample row | 1 | +| UT-171 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips the BOM character | 1 | +| UT-172 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles quoted fields with embedded commas | 1 | +| UT-173 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reports zero data rows for a header-only file | 1 | +| UT-174 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns empty arrays for a completely empty file | 1 | +| UT-175 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers int8 for whole-number columns | 1 | +| UT-176 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers numeric for decimal columns | 1 | +| UT-177 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers boolean for true/false columns | 1 | +| UT-178 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers date for YYYY-MM-DD columns | 1 | +| UT-179 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | falls back to text when column has mixed types | 1 | +| UT-180 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers text for a column with all empty cells | 1 | +| UT-181 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | ignores empty cells when inferring type | 1 | +| UT-182 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | has a human label for every inferred type | 1 | +| UT-183 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for undefined input | 1 | +| UT-184 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for an empty mapping | 1 | +| UT-185 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when every column keeps its original name | 1 | +| UT-186 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when renamed is empty string | 1 | +| UT-187 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | lists renamed columns as 'original→renamed' | 1 | +| UT-188 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | omits columns that are unchanged | 1 | +| UT-189 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns one row per RowError | 1 | +| UT-190 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | maps rowNumber, column, value, reason correctly | 1 | +| UT-191 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes the file name in every row | 1 | +| UT-192 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes a mapping summary when mapping is provided | 1 | +| UT-193 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | when rowErrors is empty but errorMessage is set, emits one structural-error row | 1 | +| UT-194 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty array when both rowErrors and errorMessage are absent | 1 | +| UT-195 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .csv filename | 1 | +| UT-196 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the header row | 1 | +| UT-197 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the error row values | 1 | +| UT-198 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | escapes commas and quotes in values | 1 | +| UT-199 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .xlsx filename | 1 | +| UT-200 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX content is a valid workbook with an Errors sheet | 1 | +| UT-201 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: triggers download with audit-report filename | 1 | +| UT-202 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX: workbook has an Audit sheet | 1 | +| UT-203 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: includes header and data row | 1 | +| UT-204 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok -> done, success toast, notify, and invalidateRoute | 1 | +| UT-205 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok with overwritten and failedRows composes the full message | 1 | +| UT-206 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status duplicate\_file -> duplicate patch, warning toast, no invalidateRoute | 1 | +| UT-207 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status invalid\_structure -> error patch carrying invalidReason | 1 | +| UT-208 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status error -> error patch carrying rowErrors | 1 | +| UT-209 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | passes the header mapping through to the patch on every branch | 1 | +| UT-210 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | uses the error's message and stack, and never sets \`notify\` | 1 | +| UT-211 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | falls back to 'Unknown error' when the thrown value has no message | 1 | +| UT-212 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | defaults the cancellation reason when none is given | 1 | +| UT-213 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | carries an explicit reason through to both the patch and the toast | 1 | +| UT-214 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | shows the diff dialog when the job already has a tableName | 1 | +| UT-215 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly with overwrite when there is no tableName yet | 1 | +| UT-216 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly when the job is undefined (id no longer in the list) | 1 | +| UT-217 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns an empty array when no templates are stored | 1 | +| UT-218 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns stored templates | 1 | +| UT-219 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns [] when localStorage contains invalid JSON | 1 | +| UT-220 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | persists a new template | 1 | +| UT-221 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | replaces an existing template with the same id | 1 | +| UT-222 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | prepends the newest template so it appears first | 1 | +| UT-223 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | removes the template with the matching id | 1 | +| UT-224 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | is a no-op when the id does not exist | 1 | +| UT-225 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when there are no templates | 1 | +| UT-226 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns the template whose column originals match in order | 1 | +| UT-227 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the header set differs | 1 | +| UT-228 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the order differs | 1 | +| UT-229 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | sets lastUsedAt on the targeted template | 1 | +| UT-230 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | does not affect other templates | 1 | +| UT-231 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a successful upload ends in status done and invalidates the router | 1 | +| UT-232 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a duplicate\_file response is classified as a duplicate, not an error | 1 | +| UT-233 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | an invalid\_structure response is classified as an error | 1 | +| UT-234 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a rejected upload promise is classified as an error with the thrown message | 1 | +| UT-235 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | clicking Stop while uploading marks the job cancelled and ignores the late result | 1 | +| UT-236 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | overwrite flow: confirming the diff dialog re-uploads with overwrite=true | 1 | + +## System tests + +The whole backend (API + services + PostgreSQL + shell pipeline) through its public interfaces. + +| ID | File | Test | Objective / expected result | Cases | +|----|------|------|-----------------------------|-------| +| ST-001 | `tests/system/test_api_coverage.py` | `TeTablesConfig.test_te_tables_has_twelve_entries` | Te tables has twelve entries | 1 | +| ST-002 | `tests/system/test_api_coverage.py` | `TeTablesConfig.test_te_tables_are_all_strings` | Te tables are all strings | 1 | +| ST-003 | `tests/system/test_api_coverage.py` | `TeTablesConfig.test_te_tables_has_no_duplicates` | Te tables has no duplicates | 1 | +| ST-004 | `tests/system/test_api_coverage.py` | `CorsHeaders.test_preflight_returns_cors_headers` | Preflight returns cors headers | 1 | +| ST-005 | `tests/system/test_api_coverage.py` | `CorsHeaders.test_cors_allows_configured_origin` | Cors allows configured origin | 1 | +| ST-006 | `tests/system/test_api_coverage.py` | `CorsHeaders.test_cors_rejects_unconfigured_origin` | Cors rejects unconfigured origin | 1 | +| ST-007 | `tests/system/test_api_coverage.py` | `TeTablesEndpoint.test_returns_200` | Returns 200 | 1 | +| ST-008 | `tests/system/test_api_coverage.py` | `TeTablesEndpoint.test_returns_list_of_twelve` | Returns list of twelve | 1 | +| ST-009 | `tests/system/test_api_coverage.py` | `TeTablesEndpoint.test_each_entry_has_required_keys` | Each entry has required keys | 1 | +| ST-010 | `tests/system/test_api_coverage.py` | `TeTablesEndpoint.test_table_names_match_whitelist` | Table names match whitelist | 1 | +| ST-011 | `tests/system/test_api_coverage.py` | `TeTablesEndpoint.test_all_tables_exist_after_deploy` | All tables exist after deploy | 1 | +| ST-012 | `tests/system/test_api_coverage.py` | `TeTablesEndpoint.test_row_count_is_non_negative_integer` | Row count is non negative integer | 1 | +| ST-013 | `tests/system/test_api_coverage.py` | `PreviewTeTableMatch.test_preview_returns_te_match_for_matching_columns` | Preview returns te match for matching columns | 1 | +| ST-014 | `tests/system/test_api_coverage.py` | `PreviewTeTableMatch.test_preview_returns_null_for_non_matching_columns` | Preview returns null for non matching columns | 1 | +| ST-015 | `tests/system/test_api_coverage.py` | `PreviewTeTableMatch.test_te_table_match_key_present_in_ok_response` | Te table match key present in ok response | 1 | +| ST-016 | `tests/system/test_api_coverage.py` | `DeleteEndpoint.test_delete_returns_correct_shape` | Delete returns correct shape | 1 | +| ST-017 | `tests/system/test_api_coverage.py` | `DeleteEndpoint.test_delete_nonexistent_id_returns_404` | Delete nonexistent id returns 404 | 1 | +| ST-018 | `tests/system/test_api_coverage.py` | `DeleteEndpoint.test_delete_dynamic_drops_backing_table` | Delete dynamic drops backing table | 1 | +| ST-019 | `tests/system/test_api_coverage.py` | `DeleteEndpoint.test_delete_te_only_removes_registry` | Delete te only removes registry | 1 | +| ST-020 | `tests/system/test_api_coverage.py` | `DeleteEndpoint.test_deleted_file_no_longer_listed` | Deleted file no longer listed | 1 | +| ST-021 | `tests/system/test_api_coverage.py` | `AuditLogEndpoint.test_delete_appears_in_audit_log` | Delete appears in audit log | 1 | +| ST-022 | `tests/system/test_api_coverage.py` | `OverwriteUpload.test_overwrite_replaces_existing_file` | Overwrite replaces existing file | 1 | +| ST-023 | `tests/system/test_api_coverage.py` | `OverwriteUpload.test_overwrite_false_refuses_duplicate` | Overwrite false refuses duplicate | 1 | +| ST-024 | `tests/system/test_api_coverage.py` | `OverwriteUpload.test_overwrite_creates_new_table` | Overwrite creates new table | 1 | +| ST-025 | `tests/system/test_api_coverage.py` | `OverwriteUpload.test_overwrite_response_has_fields` | Overwrite response has fields | 1 | +| ST-026 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_successful_upload_to_organisations` | Successful upload to organisations | 1 | +| ST-027 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_response_shape_matches_contract` | Response shape matches contract | 1 | +| ST-028 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_column_mismatch_returns_error` | Column mismatch returns error | 1 | +| ST-029 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_row_level_error_reported` | Row level error reported | 1 | +| ST-030 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_te_upload_registers_in_csv_files` | Te upload registers in csv files | 1 | +| ST-031 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_enum_constraint_violation_reported_per_row` | An invalid enum value must produce a per-row error, not a 500. | 1 | +| ST-032 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_null_in_not_null_column_reported_per_row` | An empty required field sent as NULL must fail per-row, not crash. | 1 | +| ST-033 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_unknown_target_table_returns_error` | Uploading to a table not in TE\_TABLES must return status=error. | 1 | +| ST-034 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_te_reupload_same_file_replaces_registry` | Re-uploading the same CSV in T&E mode replaces the registry entry. | 1 | +| ST-035 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_te_client_side_type_validation_reports_column_and_value` | BUG-028: a bad value on a client-validatable column (BOOLEAN here) must be rejected by the pre-insert cast\_value() pass, not by the DB. | 1 | +| ST-036 | `tests/system/test_api_coverage.py` | `TeUploadPipeline.test_te_upload_rejects_over_row_cap` | for T&E mode: a CSV with more than settings.MAX\_ROWS data rows must be rejected up-front with status=error and never reach the DB. | 1 | +| ST-037 | `tests/system/test_api_coverage.py` | `DeleteGateUnit.test_delete_blocked_returns_403` | Delete blocked returns 403 | 1 | +| ST-038 | `tests/system/test_csv_loader_arbitrary_shapes.py` | `CsvLoaderArbitraryShapes.test_shape_tiny` | Shape tiny | 1 | +| ST-039 | `tests/system/test_csv_loader_arbitrary_shapes.py` | `CsvLoaderArbitraryShapes.test_shape_medium` | Shape medium | 1 | +| ST-040 | `tests/system/test_csv_loader_arbitrary_shapes.py` | `CsvLoaderArbitraryShapes.test_shape_skinny` | Shape skinny | 1 | +| ST-041 | `tests/system/test_e2e_pipeline.py` | `E2EPipelineValidateOnly.test_happy_path_two_valid_rows` | Standard 2-column CSV with 2 valid rows produces correct outputs. | 1 | +| ST-042 | `tests/system/test_e2e_pipeline.py` | `E2EPipelineValidateOnly.test_mixed_rows_splits_correctly` | Mixed CSV produces separate valid and skip outputs. | 1 | +| ST-043 | `tests/system/test_e2e_pipeline.py` | `E2EPipelineValidateOnly.test_all_invalid_rows_exit_nonzero` | A CSV where every data row fails validation must exit 1. | 1 | +| ST-044 | `tests/system/test_e2e_pipeline.py` | `E2EPipelineValidateOnly.test_output_directory_is_created_automatically` | Validator must create the output directory if it does not exist. | 1 | +| ST-045 | `tests/system/test_e2e_pipeline.py` | `E2EPipelineWithDatabase.test_tier_p_scenarios_all_pass` | All Tier P eval scenarios must pass when the runner is executed end-to-end. | 1 | +| ST-046 | `tests/system/test_e2e_pipeline.py` | `E2EPipelineWithDatabase.test_tier_i_idempotency` | Tier I (idempotency) scenarios must pass against the running PostgreSQL. | 1 | +| ST-047 | `tests/system/test_system_api.py` | `TestHealth.test_health_ok_with_bootstrapped_schema` | /api/health reports ok and the uploads schema once bootstrapped. | 1 | +| ST-048 | `tests/system/test_system_api.py` | `TestHealth.test_openapi_lists_every_endpoint` | the OpenAPI document exposes every public route. | 1 | +| ST-049 | `tests/system/test_system_api.py` | `TestDynamicLifecycle.test_upload_list_read_delete_audit` | full lifecycle: upload → listed → rows readable → delete → gone from list, rows 404, audit entry recorded. | 1 | +| ST-050 | `tests/system/test_system_api.py` | `TestDynamicLifecycle.test_typed_columns_are_stored_typed` | caller-supplied types create typed columns; values come back typed. | 1 | +| ST-051 | `tests/system/test_system_api.py` | `TestDynamicLifecycle.test_cast_failures_are_reported_and_good_rows_load` | rows that fail type casting are reported; valid rows still load. | 1 | +| ST-052 | `tests/system/test_system_api.py` | `TestDynamicLifecycle.test_type_count_mismatch_is_rejected` | supplying the wrong number of types is a clear error, no table. | 1 | +| ST-053 | `tests/system/test_system_api.py` | `TestDynamicLifecycle.test_unsupported_type_is_rejected` | types outside the whitelist cannot reach CREATE TABLE. | 1 | +| ST-054 | `tests/system/test_system_api.py` | `TestDynamicLifecycle.test_headers_are_sanitised_in_created_table` | messy headers become safe snake\_case column names. | 1 | +| ST-055 | `tests/system/test_system_api.py` | `TestDuplicatesAndOverwrite.test_same_name_different_content_is_name_duplicate` | a new file reusing an existing file name is flagged reason=name. | 1 | +| ST-056 | `tests/system/test_system_api.py` | `TestDuplicatesAndOverwrite.test_same_content_different_name_is_content_duplicate` | identical content under another name is flagged reason=content. | 1 | +| ST-057 | `tests/system/test_system_api.py` | `TestDuplicatesAndOverwrite.test_overwrite_replaces_previous_upload` | overwrite=true drops the old table and registers the new file once. | 1 | +| ST-058 | `tests/system/test_system_api.py` | `TestLimitsAndGuards.test_row_cap_rejects_oversized_file` | API\_MAX\_ROWS rejects a file before any table is created. | 1 | +| ST-059 | `tests/system/test_system_api.py` | `TestLimitsAndGuards.test_row_error_list_is_capped` | rowErrors is capped but failedRows reports the true count. | 1 | +| ST-060 | `tests/system/test_system_api.py` | `TestLimitsAndGuards.test_upload_size_limit_returns_413` | bodies above MAX\_UPLOAD\_BYTES are refused with 413. | 1 | +| ST-061 | `tests/system/test_system_api.py` | `TestLimitsAndGuards.test_delete_disabled_by_default` | DELETE is 403 unless API\_ALLOW\_DESTRUCTIVE is on. | 1 | +| ST-062 | `tests/system/test_system_api.py` | `TestLimitsAndGuards.test_rows_limit_is_clamped` | the rows endpoint clamps limit into 1..200. | 1 | +| ST-063 | `tests/system/test_system_api.py` | `TestLimitsAndGuards.test_rows_endpoint_refuses_non_upload_tables` | only registered csv\_\* tables are readable through the API. | 3 | +| ST-064 | `tests/system/test_system_api.py` | `TestAuditLogEndpoint.test_pagination_parameters_are_clamped` | limit is clamped into 1..200 and offset to >= 0. | 1 | +| ST-065 | `tests/system/test_system_api.py` | `TestAuditLogEndpoint.test_entries_are_newest_first` | audit entries are ordered by performedAt descending. | 1 | +| ST-066 | `tests/system/test_system_api.py` | `TestTeMode.test_te_tables_endpoint_lists_all_twelve` | /api/te/tables reports all 12 T&E tables as existing. | 1 | +| ST-067 | `tests/system/test_system_api.py` | `TestTeMode.test_te_upload_and_registry` | a T&E upload inserts rows and registers a schema-qualified table. | 1 | +| ST-068 | `tests/system/test_system_api.py` | `TestTeMode.test_te_constraint_violation_is_per_row` | a CHECK-constraint failure is reported per row; others insert. | 1 | + +## System integration tests + +Contracts between components: API and frontend, API and PostgreSQL, deploy scripts, environments. + +| ID | File | Test | Objective / expected result | Cases | +|----|------|------|-----------------------------|-------| +| SIT-001 | `tests/system_integration/test_api_integration.py` | `HealthContract.test_health_always_returns_200` | Health always returns 200 | 1 | +| SIT-002 | `tests/system_integration/test_api_integration.py` | `HealthContract.test_health_status_is_ok_or_degraded` | Health status is ok or degraded | 1 | +| SIT-003 | `tests/system_integration/test_api_integration.py` | `HealthContract.test_openapi_schema_is_served` | Openapi schema is served | 1 | +| SIT-004 | `tests/system_integration/test_api_integration.py` | `HealthContract.test_health_is_not_behind_api_key` | regression: /api/health must be reachable without X-API-Key even when API\_KEY is set, so external uptime probes can use it. | 1 | +| SIT-005 | `tests/system_integration/test_api_integration.py` | `HealthContract.test_csv_endpoint_still_requires_api_key_when_set` | counterpart: business endpoints must still be guarded. | 1 | +| SIT-006 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_preview_reports_columns` | Preview reports columns | 1 | +| SIT-007 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_upload_then_list_then_read_rows` | Upload then list then read rows | 1 | +| SIT-008 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_reupload_of_identical_content_is_refused` | Reupload of identical content is refused | 1 | +| SIT-009 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_in_file_duplicate_rows_are_skipped` | In file duplicate rows are skipped | 1 | +| SIT-010 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_row_hash_does_not_collide_on_split_boundaries` | regression: \`["ab","cd"]\` and \`["a","bcd"]\` used to hash to the same value because the pre-hash \`"".join(cells)\` had no separator. With the fixed \`"\\x1f".join(...)\` they must be treated as distinct rows and both land in the table. | 1 | +| SIT-011 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_row_hash_still_dedupes_identical_rows` | Guard-rail for BUG-022 fix: the separator change must not break the intended dedup — two byte-identical rows still count as one insert. | 1 | +| SIT-012 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_unregistered_table_is_404` | Unregistered table is 404 | 1 | +| SIT-013 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_injection_shaped_names_are_not_served` | Names that pass the prefix guard must still 404 when unregistered. | 1 | +| SIT-014 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_core_te_tables_are_intact` | A CSV upload must never disturb the 12 tables the SQL suite asserts. | 1 | +| SIT-015 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_audit_record_contains_required_fields` | BR-15: GET /api/csv/files must return all required audit fields. | 1 | +| SIT-016 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_audit_record_row_count_matches_inserted_rows` | BR-15: persisted row\_count must match what the upload response reported. | 1 | +| SIT-017 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_audit_record_table_name_matches_upload_response` | BR-15: persisted table\_name must match what the upload response reported. | 1 | +| SIT-018 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_audit_record_has_iso_timestamp` | BR-15: created\_at must be a non-empty ISO timestamp. | 1 | +| SIT-019 | `tests/system_integration/test_api_integration.py` | `CsvPipelineWithDatabase.test_failed_upload_leaves_no_audit_record` | BR-15: a structurally invalid file must not create a registry entry. | 1 | +| SIT-020 | `tests/system_integration/test_api_integration.py` | `PoolTimeout.test_exhausted_pool_raises_within_timeout` | psycopg2's ThreadedConnectionPool.getconn() raises PoolError immediately when the pool is exhausted — it does not block. Our timeout wrapper still catches the error correctly and never lets a request hang. The upper bound is what BUG-030 really guards against; the lower bound is not asserted because a fast raise is the desired behaviour, not a regression. | 1 | +| SIT-021 | `tests/system_integration/test_parity.py` | `TestDevSeedCounts.test_dev_seed_row_counts` | Dev seed row counts | 1 | +| SIT-022 | `tests/system_integration/test_parity.py` | `TestAllEnvironmentsHaveRequiredTables.test_dev_has_required_tables` | Dev has required tables | 1 | +| SIT-023 | `tests/system_integration/test_parity.py` | `TestAllEnvironmentsHaveRequiredTables.test_test_has_required_tables` | Test has required tables | 1 | +| SIT-024 | `tests/system_integration/test_parity.py` | `TestAllEnvironmentsHaveRequiredTables.test_staging_has_required_tables` | Staging has required tables | 1 | +| SIT-025 | `tests/system_integration/test_parity.py` | `TestAllEnvironmentsHaveRequiredTables.test_prod_has_required_tables` | Prod has required tables | 1 | +| SIT-026 | `tests/system_integration/test_parity.py` | `TestNoSeedInStagingAndProd.test_staging_has_no_seed_data` | Staging has no seed data | 1 | +| SIT-027 | `tests/system_integration/test_parity.py` | `TestNoSeedInStagingAndProd.test_prod_has_no_seed_data` | Prod has no seed data | 1 | +| SIT-028 | `tests/system_integration/test_parity.py` | `TestIdempotentDeployParity.test_dev_row_counts_stable_after_second_deploy` | Dev row counts stable after second deploy | 1 | +| SIT-029 | `tests/system_integration/test_sit_frontend_contract.py` | `TestStaticContract.test_column_type_enum_matches_backend_whitelist` | the UI's ColumnType enum equals the API's ALLOWED\_TYPES. | 1 | +| SIT-030 | `tests/system_integration/test_sit_frontend_contract.py` | `TestStaticContract.test_te_whitelist_matches_schema_ddl` | every T&E table the API whitelists is created by te\_core\_schema.sql. | 1 | +| SIT-031 | `tests/system_integration/test_sit_frontend_contract.py` | `TestStaticContract.test_frontend_sends_api_key_header_the_api_reads` | the UI sends X-API-Key, which CORS allows and auth reads. | 1 | +| SIT-032 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_ok_upload` | status=ok upload response matches uploadResultSchema. | 1 | +| SIT-033 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_duplicate_upload` | status=duplicate\_file response matches the UI schema. | 1 | +| SIT-034 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_invalid_structure_upload` | status=invalid\_structure reasons are ones the UI understands. | 3 | +| SIT-035 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_error_upload` | status=error response matches the UI schema. | 1 | +| SIT-036 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_upload` | a T&E-mode response (including DB-side row errors) matches the UI schema. | 1 | +| SIT-037 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_files_list` | /api/csv/files entries match csvFileSummarySchema. | 1 | +| SIT-038 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_ok` | /api/csv/preview ok response matches csvPreviewResultSchema. | 1 | +| SIT-039 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_table_rows_values_are_json_primitives` | row values are string/number/boolean/null as previewRowsSchema requires. | 1 | +| SIT-040 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_tables` | /api/te/tables entries match teTableInfoSchema. | 1 | +| SIT-041 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_bootstrap_is_idempotent` | running bootstrap again on a live schema is a harmless no-op. | 1 | +| SIT-042 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_registry_tables_have_expected_columns` | csv\_files and audit\_log carry every column the API reads/writes. | 1 | +| SIT-043 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_dynamic_tables_live_only_in_uploads_schema` | a dynamic upload creates its table in the uploads schema, nowhere else. | 1 | +| SIT-044 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_te_row_counts_track_postgres_statistics` | /api/te/tables row counts agree with COUNT(\*) once statistics are fresh. | 1 | +| SIT-045 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_valid_te_upload_returns_ok` | A well-formed T&E CSV must return status=ok. | 1 | +| SIT-046 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_inserted_rows_count_matches_data_rows` | insertedRows must equal the number of data rows supplied. | 1 | +| SIT-047 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_table_name_in_response_is_schema_qualified` | tableName must be schema-qualified (e.g. te\_dev.organisations). | 1 | +| SIT-048 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_rows_are_queryable_after_upload` | Inserted rows must appear in the T&E table row count. | 1 | +| SIT-049 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_core_te_tables_undisturbed_after_upload` | A T&E upload must not drop or alter the other 11 core tables. | 1 | +| SIT-050 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_reupload_cleans_prior_registry_entry` | Re-uploading the same file name must not duplicate registry entries. | 1 | +| SIT-051 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_invalid_target_table_is_rejected` | A targetTable not in TE\_TABLES must return status=error. | 1 | +| SIT-052 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_empty_file_is_rejected` | Empty CSV is rejected — 422 from Pydantic min\_length or status=error. | 1 | +| SIT-053 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_header_only_file_is_rejected` | A header-only CSV (no data rows) must return status=invalid\_structure. | 1 | +| SIT-054 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_unknown_columns_are_rejected` | Columns not in the target table must return status=error. | 1 | +| SIT-055 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_missing_target_table_param_is_rejected` | mode=te with no targetTable must return 422 from the API layer. | 1 | +| SIT-056 | `tests/system_integration/test_te_loader_integration.py` | `DynamicOverwriteOfTeOriginFile.test_dynamic_overwrite_of_te_origin_entry_succeeds` | A dynamic-mode overwrite of a filename previously registered in TE mode must succeed (not crash, not silently no-op) and the registry must reflect the new dynamic-mode table, not a stale TE reference. | 1 | +| SIT-057 | `tests/suites/test_01_organisations_personnel.sql` | SQL suite | T&E schema constraints and business rules (23 assertions) | 23 | +| SIT-058 | `tests/suites/test_02_programs_phases.sql` | SQL suite | T&E schema constraints and business rules (25 assertions) | 25 | +| SIT-059 | `tests/suites/test_03_requirements_vcrm.sql` | SQL suite | T&E schema constraints and business rules (23 assertions) | 23 | +| SIT-060 | `tests/suites/test_04_execution_defects.sql` | SQL suite | T&E schema constraints and business rules (38 assertions) | 38 | +| SIT-061 | `tests/suites/test_05_schema_and_business_rules.sql` | SQL suite | T&E schema constraints and business rules (33 assertions) | 33 | + +## Performance tests + +Throughput, latency and resource bounds. Budgets scale with PERF_BUDGET_SCALE. + +| ID | File | Test | Objective / expected result | Cases | +|----|------|------|-----------------------------|-------| +| PT-001 | `tests/performance/test_performance.py` | `TestParserThroughput.test_parse_100k_rows` | parse\_csv handles 100,000 rows × 5 columns within 6s. | 1 | +| PT-002 | `tests/performance/test_performance.py` | `TestParserThroughput.test_parse_scales_linearly` | 10× the rows costs well under 30× the time (no quadratic blow-up). | 1 | +| PT-003 | `tests/performance/test_performance.py` | `TestParserThroughput.test_preview_of_large_file` | build\_preview of a 50,000-row file returns within 4s. | 1 | +| PT-004 | `tests/performance/test_performance.py` | `TestParserThroughput.test_very_long_field` | a single 5 MB quoted field parses within 3s. | 1 | +| PT-005 | `tests/performance/test_performance.py` | `TestParserThroughput.test_sanitize_many_duplicate_headers` | sanitising 2,000 identical headers stays under 2s. | 1 | +| PT-006 | `tests/performance/test_performance.py` | `TestParserThroughput.test_cast_200k_values` | casting 200,000 cells across all types completes within 3s. | 1 | +| PT-007 | `tests/performance/test_performance.py` | `TestUploadPerformance.test_dynamic_upload_20k_rows` | a 20,000-row typed upload finishes within 20s with every row inserted. | 1 | +| PT-008 | `tests/performance/test_performance.py` | `TestUploadPerformance.test_all_failing_file_is_bounded` | a 20,000-row file where every row fails returns quickly and the response body stays small (rowErrors capped). | 1 | +| PT-009 | `tests/performance/test_performance.py` | `TestUploadPerformance.test_te_upload_1000_rows` | a 1,000-row T&E upload uses the batch path and finishes within 10s. | 1 | +| PT-010 | `tests/performance/test_performance.py` | `TestUploadPerformance.test_concurrent_uploads` | 8 concurrent uploads (pool size) all succeed within 30s. | 1 | +| PT-011 | `tests/performance/test_performance.py` | `TestReadLatency.test_read_endpoint_p95_latency` | read endpoints answer with p95 latency under 500ms over 20 calls. | 4 | +| PT-012 | `tests/performance/test_performance.py` | `TestReadLatency.test_te_tables_uses_constant_queries` | BUG-042: /api/te/tables issues 2 queries, not one per table. | 1 | +| PT-013 | `tests/performance/test_performance.py` | `TestReadLatency.test_oversized_body_rejected_fast` | a body over MAX\_UPLOAD\_BYTES is refused (413) in under 2s without parsing it. | 1 | + +## Accessibility tests + +WCAG 2.1 A/AA on the React frontend with Playwright + axe-core, plus keyboard and reflow checks. + +| ID | File | Test | Objective / expected result | Cases | +|----|------|------|-----------------------------|-------| +| AT-001 | `tests/accessibility/test_accessibility.py` | `test_page_has_no_wcag_violations` | every page passes axe-core WCAG 2.1 A/AA rules. | 1 | +| AT-002 | `tests/accessibility/test_accessibility.py` | `test_migrated_files_second_page_has_no_violations` | the paginated Migrated files table passes after paging. | 1 | +| AT-003 | `tests/accessibility/test_accessibility.py` | `test_preview_dialog_has_no_violations` | the CSV preview / header-mapping dialog passes axe. | 1 | +| AT-004 | `tests/accessibility/test_accessibility.py` | `test_te_mode_dialog_has_no_violations` | the dialog in T&E-table mode (table picker) passes axe. | 1 | +| AT-005 | `tests/accessibility/test_accessibility.py` | `test_upload_report_has_no_violations` | the upload report shown after an import passes axe. | 1 | +| AT-006 | `tests/accessibility/test_accessibility.py` | `test_table_preview_dialog_has_no_violations` | the imported-table preview dialog passes axe. | 1 | +| AT-007 | `tests/accessibility/test_accessibility.py` | `test_document_language_and_title` | every page declares lang and has a non-empty <title> (WCAG 3.1.1, 2.4.2). | 1 | +| AT-008 | `tests/accessibility/test_accessibility.py` | `test_single_h1_and_no_skipped_heading_levels` | one h1 per page and heading levels never skip (WCAG 1.3.1). | 2 | +| AT-009 | `tests/accessibility/test_accessibility.py` | `test_every_button_and_link_has_an_accessible_name` | icon-only controls still expose a name to screen readers (WCAG 4.1.2). | 2 | +| AT-010 | `tests/accessibility/test_accessibility.py` | `test_migrated_files_table_has_column_headers` | the data table exposes named column headers to assistive tech (WCAG 1.3.1). | 1 | +| AT-011 | `tests/accessibility/test_accessibility.py` | `test_choose_files_is_reachable_by_keyboard` | Tab reaches the Choose files button and focus is visible (WCAG 2.1.1, 2.4.7). | 1 | +| AT-012 | `tests/accessibility/test_accessibility.py` | `test_dialog_traps_focus_and_escape_closes` | focus stays inside the open dialog and Escape dismisses it (WCAG 2.1.2). | 1 | +| AT-013 | `tests/accessibility/test_accessibility.py` | `test_pagination_is_operable_by_keyboard` | the table pager works with Enter on a focused button. | 1 | +| AT-014 | `tests/accessibility/test_accessibility.py` | `test_reflows_at_320_css_pixels` | at 320px wide (400% zoom equivalent) the page does not scroll horizontally (WCAG 1.4.10 Reflow). Data tables may scroll inside their own container, which WCAG permits. | 2 | + +## User acceptance tests + +User stories written as Given/When/Then and run against the API. + +| ID | File | Test | Objective / expected result | Cases | +|----|------|------|-----------------------------|-------| +| UAT-001 | `tests/uat/test_issue_04_multi_file_upload.py` | `MultipleFileUpload.test_many_files_upload_in_one_session` | Uploading several files in sequence must all succeed. | 1 | +| UAT-002 | `tests/uat/test_issue_04_multi_file_upload.py` | `MultipleFileUpload.test_every_uploaded_file_is_listed` | All uploads appear in /api/csv/files — this drives 'Migrated files'. | 1 | +| UAT-003 | `tests/uat/test_issue_04_multi_file_upload.py` | `MultipleFileUpload.test_each_file_is_independently_readable` | Rows from one upload must not leak into another's table. | 1 | +| UAT-004 | `tests/uat/test_issue_04_multi_file_upload.py` | `MultipleFileUpload.test_narrow_and_wide_files_both_load` | One column and many columns must both be accepted. | 1 | +| UAT-005 | `tests/uat/test_issue_04_multi_file_upload.py` | `MultipleFileUpload.test_files_with_different_schemas_coexist` | Unrelated column sets must not collide — no shared fixed schema. | 1 | +| UAT-006 | `tests/uat/test_issue_04_multi_file_upload.py` | `MultipleFileUpload.test_a_larger_file_loads_completely` | Row count is not silently truncated. | 1 | +| UAT-007 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_all_four_counts_are_returned` | All four counts are returned | 1 | +| UAT-008 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_total_rows_counts_data_rows_not_the_header` | Total rows counts data rows not the header | 1 | +| UAT-009 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_created_count_matches_rows_actually_inserted` | Created count matches rows actually inserted | 1 | +| UAT-010 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_duplicate_count_reflects_repeated_rows_in_the_file` | Two identical rows plus one distinct: 1 duplicate skipped. | 1 | +| UAT-011 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_failed_count_reflects_rows_that_could_not_be_cast` | With an int8 column, a non-numeric value must count as failed. | 1 | +| UAT-012 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_failed_rows_are_reported_with_detail` | A count alone is not actionable — the offending row must be named. | 1 | +| UAT-013 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_counts_reconcile_for_a_clean_file` | Counts reconcile for a clean file | 1 | +| UAT-014 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_counts_reconcile_for_a_mixed_file` | One good row, one in-file duplicate, one uncastable value. | 1 | +| UAT-015 | `tests/uat/test_issue_05_import_summary.py` | `ImportSummaryCounts.test_counts_are_per_batch_not_cumulative` | A second, larger upload must report its own totals, not a running sum. | 1 | +| UAT-016 | `tests/uat/test_uat_user_stories.py` | `TestUS01ImportAnyCsv.test_sample_file_becomes_a_table_with_every_row` | Given a sample CSV, when I import it, then every data row is stored. | 3 | +| UAT-017 | `tests/uat/test_uat_user_stories.py` | `TestUS01ImportAnyCsv.test_quoted_commas_are_preserved` | Given a product name containing a comma, when imported, then it is stored as one value, not split across columns. | 1 | +| UAT-018 | `tests/uat/test_uat_user_stories.py` | `TestUS02PreviewBeforeImport.test_preview_shows_headers_types_and_sample` | Given customers.csv, when I preview it, then I see its headers, sensible inferred types and the first rows, and nothing is stored. | 1 | +| UAT-019 | `tests/uat/test_uat_user_stories.py` | `TestUS02PreviewBeforeImport.test_preview_suggests_matching_te_table` | Given a file whose columns fit a T&E table, when I preview it, then that table is suggested. | 1 | +| UAT-020 | `tests/uat/test_uat_user_stories.py` | `TestUS02PreviewBeforeImport.test_preview_explains_an_unusable_file` | Given a header-only file, when I preview it, then I am told why it cannot be imported. | 1 | +| UAT-021 | `tests/uat/test_uat_user_stories.py` | `TestUS03DuplicateProtection.test_reupload_is_flagged_with_the_original_file` | Given I imported a file, when I upload the same content again, then I am told it duplicates the original and how many rows it holds. | 1 | +| UAT-022 | `tests/uat/test_uat_user_stories.py` | `TestUS03DuplicateProtection.test_overwrite_replaces_the_data` | Given a duplicate warning, when I choose Overwrite, then the new data replaces the old and the file appears once in the list. | 1 | +| UAT-023 | `tests/uat/test_uat_user_stories.py` | `TestUS03DuplicateProtection.test_repeated_rows_inside_a_file_load_once` | Given a file with the same row twice, when imported, then it is stored once and the summary says one duplicate was skipped. | 1 | +| UAT-024 | `tests/uat/test_uat_user_stories.py` | `TestUS04BadDataIsExplained.test_each_bad_row_has_row_column_value_and_reason` | Given a file with a bad date on row 2, when imported with a date column, then the error names row 2, the column, the value and why, and the good rows are still stored. | 1 | +| UAT-025 | `tests/uat/test_uat_user_stories.py` | `TestUS04BadDataIsExplained.test_summary_counts_reconcile` | Given a mixed file, when imported, then inserted + failed + duplicates equals the total rows processed. | 1 | +| UAT-026 | `tests/uat/test_uat_user_stories.py` | `TestUS05LoadIntoTeTables.test_load_organisations` | Given an organisations CSV, when I load it in T&E mode, then the rows are in te\_dev.organisations and the load is listed. | 1 | +| UAT-027 | `tests/uat/test_uat_user_stories.py` | `TestUS05LoadIntoTeTables.test_wrong_columns_are_rejected_with_their_names` | Given a CSV with columns the T&E table lacks, when I load it, then nothing is inserted and the unknown columns are named. | 1 | +| UAT-028 | `tests/uat/test_uat_user_stories.py` | `TestUS06ReviewMigratedData.test_migrated_files_list_shows_counts_and_columns` | Given I imported a file, when I open Migrated files, then it is listed first with its table, column names and row count. | 1 | +| UAT-029 | `tests/uat/test_uat_user_stories.py` | `TestUS06ReviewMigratedData.test_first_rows_can_be_previewed` | Given an imported file, when I click Preview, then I see its first rows in file order. | 1 | +| UAT-030 | `tests/uat/test_uat_user_stories.py` | `TestUS07AuditTrail.test_deleting_a_file_is_audited` | Given deletion is enabled, when I delete an imported file, then it disappears from the list and the audit log records who/what/when. | 1 | +| UAT-031 | `tests/uat/test_uat_user_stories.py` | `TestUS07AuditTrail.test_deletion_is_blocked_unless_enabled` | Given a default (safe) deployment, when someone tries to delete a file, then it is refused and the data stays. | 1 | diff --git a/docs/TEST_CONDITIONS.md b/docs/TEST_CONDITIONS.md index d9a9e44..eee6d7a 100644 --- a/docs/TEST_CONDITIONS.md +++ b/docs/TEST_CONDITIONS.md @@ -1,5 +1,10 @@ # TEST_CONDITIONS — comprehensive catalog +> **Note:** the pytest suite is now grouped by test category under +> `tests//`. The generated per-test catalogue is +> [`docs/TEST_CASES.md`](TEST_CASES.md), and `tests/README.md` explains the +> groups. The counts below are a historical snapshot. + Every test condition that runs against the codebase, in one place. Five categories: Python unit tests, SQL test-suite assertions, and Tier P / I / S evals. ## At a glance @@ -18,7 +23,7 @@ Every test condition that runs against the codebase, in one place. Five categori Located in `tests/`, run as `python -m unittest discover -s tests -p "test*.py"`. All currently green. -### `tests/test_csv_validator.py` — 4 tests against `build/csv/validator.py` +### `tests/unit/test_csv_validator.py` — 4 tests against `build/csv/validator.py` | # | Test | Asserts | |---|------|---------| @@ -27,7 +32,7 @@ Located in `tests/`, run as `python -m unittest discover -s tests -p "test*.py"` | 3 | `test_splits_valid_and_skipped_rows` | Exit 0; valid+skip rows split correctly; reasons captured | | 4 | `test_returns_error_when_no_valid_rows` | Exit 1; stderr contains `No valid rows found` | -### `tests/test_evals_runner.py` — 7 tests against `evals/runner.py` +### `tests/unit/test_evals_runner.py` — 7 tests against `evals/runner.py` | # | Test | Asserts | |---|------|---------| @@ -208,7 +213,7 @@ psql -d migration_test --set schema_name=te_dev -f tests\run_all_tests.sql | Kind of thing you want to assert | Add it here | |---------------------------------|------------| -| Single Python function behaves correctly | `tests/test_csv_validator.py` or `tests/test_evals_runner.py` | +| Single Python function behaves correctly | `tests/unit/test_csv_validator.py` or `tests/unit/test_evals_runner.py` | | A SQL business rule holds across seed data | `tests/suites/test_0X_.sql` using `assert_*` | | End-to-end behaviour against the validator binary | `evals/datasets/tier_p//` + `evals/expected/tier_p/.json` | | End-to-end PostgreSQL operational scenario | `evals/datasets/tier_i//` + runner branch in `evals/runner.py` | diff --git a/pytest.ini b/pytest.ini index 49bc7cc..444caa2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,6 +7,11 @@ markers = security: static security and credential pattern scans snapshot: golden-file output comparison tests parity: cross-environment row-count consistency tests + system: whole-system behaviour through the public API; requires PostgreSQL + sit: system integration — contracts between API, database, frontend and scripts + performance: throughput, latency and resource-bound tests (PERF_BUDGET_SCALE scales budgets) + accessibility: WCAG 2.1 AA checks on the frontend with Playwright + axe-core + uat: user acceptance tests written as Given/When/Then user stories addopts = -ra -q testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt index fdf614f..084e041 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,3 +8,8 @@ PyYAML>=6.0 # it falls back to httpx with a deprecation warning, and raises if neither # is installed. Nothing else in this file or api/requirements.txt pulls it in. httpx2>=2.9.0 + +# Accessibility tests (tests/accessibility): Playwright drives Chromium against +# the Vite dev server; axe-playwright-python bundles axe-core for WCAG scans. +playwright>=1.56.0,<2.0.0 +axe-playwright-python>=0.1.8,<0.2.0 diff --git a/scripts/fix_inserted_rows_count.py b/scripts/fix_inserted_rows_count.py index 1b3363c..f618d38 100644 --- a/scripts/fix_inserted_rows_count.py +++ b/scripts/fix_inserted_rows_count.py @@ -24,7 +24,7 @@ Then verify:: - python -m pytest tests/test_issue_04_multi_file_upload.py + python -m pytest tests/uat/test_issue_04_multi_file_upload.py """ from __future__ import annotations @@ -100,8 +100,8 @@ def main() -> int: TARGET.write_text(updated, encoding="utf-8") print(" rewrote api/services/dynamic_loader.py") print("\nNow verify:") - print(" python -m pytest tests/test_issue_04_multi_file_upload.py") - print(" python -m pytest tests/test_issue_05_import_summary.py") + print(" python -m pytest tests/uat/test_issue_04_multi_file_upload.py") + print(" python -m pytest tests/uat/test_issue_05_import_summary.py") return 0 diff --git a/scripts/health_check.py b/scripts/health_check.py index e68d0d4..42e4053 100644 --- a/scripts/health_check.py +++ b/scripts/health_check.py @@ -173,11 +173,15 @@ def check_test_infra() -> None: for label, path in files.items(): _check_file(label, path) - test_files = list((ROOT / "tests").glob("test_*.py")) - if test_files: - _pass(f"python test files ({len(test_files)} found)") - else: - _fail("python test files", "no test_*.py found in tests/") + # Tests are grouped by category (see tests/README.md); every group must + # exist and hold at least one test module. + for group in ("unit", "system", "system_integration", "performance", + "accessibility", "uat"): + test_files = list((ROOT / "tests" / group).rglob("test_*.py")) + if test_files: + _pass(f"tests/{group} ({len(test_files)} test files)") + else: + _fail(f"tests/{group}", "no test_*.py found") # ── Config template ─────────────────────────────────────────────────────────── diff --git a/scripts/lint.sh b/scripts/lint.sh index f8c1acf..7ddec6a 100644 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -13,12 +13,21 @@ PY_SRC=( ) PY_TESTS=( - tests/test_csv_validator.py - tests/test_evals_runner.py - tests/test_api_unit.py - tests/test_api_integration.py - tests/test_issue_04_multi_file_upload.py - tests/test_issue_05_import_summary.py + tests/unit/test_csv_validator.py + tests/unit/test_evals_runner.py + tests/unit/test_api_unit.py + tests/unit/test_csv_parse.py + tests/unit/test_config_auth.py + tests/unit/test_db_pool.py + tests/system/test_system_api.py + tests/system_integration/test_api_integration.py + tests/system_integration/test_sit_frontend_contract.py + tests/performance/test_performance.py + tests/accessibility/conftest.py + tests/accessibility/test_accessibility.py + tests/uat/test_issue_04_multi_file_upload.py + tests/uat/test_issue_05_import_summary.py + tests/uat/test_uat_user_stories.py ) # Collect scripts/*.py dynamically diff --git a/scripts/provision_full_test_env.sh b/scripts/provision_full_test_env.sh index 2a0f279..53a9fc5 100644 --- a/scripts/provision_full_test_env.sh +++ b/scripts/provision_full_test_env.sh @@ -20,7 +20,7 @@ # Note on passwords: the generated env_.sql keep the literal # '__INJECT_AT_DEPLOY__' placeholder from the template. It is a valid role # password for a local test instance and keeps -# tests/test_security.py::test_env_sql_files_no_hardcoded_passwords green. +# tests/unit/test_security.py::test_env_sql_files_no_hardcoded_passwords green. # Never use these files against a real environment. # ============================================================================= diff --git a/scripts/run_qa.ps1 b/scripts/run_qa.ps1 index 01c18c4..c1cf60d 100644 --- a/scripts/run_qa.ps1 +++ b/scripts/run_qa.ps1 @@ -42,8 +42,8 @@ switch ($Target) { "build/csv/validator.py", "evals/runner.py", "evals/gap_report.py", - "tests/test_csv_validator.py", - "tests/test_evals_runner.py" + "tests/unit/test_csv_validator.py", + "tests/unit/test_evals_runner.py" ) $scriptPy = Get-ChildItem scripts/*.py -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName } diff --git a/scripts/select_tests.py b/scripts/select_tests.py index 60738a9..6d4b830 100644 --- a/scripts/select_tests.py +++ b/scripts/select_tests.py @@ -32,7 +32,13 @@ ("build/environments/", "integration or e2e or parity", "env SQL changed → integration + parity"), ("build/schema/", "integration or e2e or parity", "schema changed → integration + parity"), ("tests/suites/", "integration", "SQL test suite changed → integration"), - ("tests/test_", "unit", "test file changed → unit"), + ("tests/unit/", "unit", "unit test changed → unit"), + ("tests/system/", "system", "system test changed → system"), + ("tests/system_integration/", "sit", "SIT test changed → sit"), + ("tests/performance/", "performance", "performance test changed → performance"), + ("tests/accessibility/", "accessibility", "a11y test changed → accessibility"), + ("tests/uat/", "uat", "UAT test changed → uat"), + ("csv-table-hub-main/src/", "accessibility", "frontend changed → accessibility"), ("scripts/", "security", "scripts changed → security scan"), ("build/adapters/", "security", "adapter changed → security scan"), ("build/csv/loader_", "e2e", "loader changed → e2e"), diff --git a/scripts/test_catalogue.py b/scripts/test_catalogue.py new file mode 100644 index 0000000..34d14f7 --- /dev/null +++ b/scripts/test_catalogue.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Generate docs/TEST_CASES.md — every test case grouped by test category. + +Sources: + * pytest tests under tests// (collected statically with ast — no + imports, no database). Objective = first line of the test's docstring, + falling back to the humanised test name; parametrised tests list their + case count. + * vitest tests under csv-table-hub-main/src/__tests__// (it/test titles). + * the SQL assertion suite (counted per suite file). + +Usage: + python3 scripts/test_catalogue.py # rewrite docs/TEST_CASES.md + python3 scripts/test_catalogue.py --check # exit 1 if the file is stale +""" +from __future__ import annotations + +import argparse +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "docs" / "TEST_CASES.md" + +GROUPS = [ + ("unit", "Unit tests", "UT", + "Single functions and modules in isolation. No database, no network."), + ("system", "System tests", "ST", + "The whole backend (API + services + PostgreSQL + shell pipeline) through its public interfaces."), + ("system_integration", "System integration tests", "SIT", + "Contracts between components: API and frontend, API and PostgreSQL, deploy scripts, environments."), + ("performance", "Performance tests", "PT", + "Throughput, latency and resource bounds. Budgets scale with PERF_BUDGET_SCALE."), + ("accessibility", "Accessibility tests", "AT", + "WCAG 2.1 A/AA on the React frontend with Playwright + axe-core, plus keyboard and reflow checks."), + ("uat", "User acceptance tests", "UAT", + "User stories written as Given/When/Then and run against the API."), +] + +FRONTEND_TESTS = ROOT / "csv-table-hub-main" / "src" / "__tests__" +SQL_SUITES = ROOT / "tests" / "suites" + + +def _md(text: str) -> str: + """Escape docstring text for a Markdown table cell.""" + for ch in ("\\", "|", "*", "_", "`"): + text = text.replace(ch, "\\" + ch) + return text.replace("<", "<").replace(">", ">") + + +def _humanise(name: str) -> str: + return name.removeprefix("test_").replace("_", " ").strip().capitalize() + + +def _objective(node: ast.AST) -> str: + doc = ast.get_docstring(node) or "" + first = " ".join(doc.strip().split("\n\n")[0].split()) + # Drop a leading case ID such as "SYS-03" — the catalogue assigns its own. + first = re.sub(r"^[A-Z0-9]+-\d+\s+", "", first) + return _md(first or _humanise(getattr(node, "name", ""))) + + +def _param_count(fn: ast.FunctionDef) -> int: + total = 1 + for dec in fn.decorator_list: + if (isinstance(dec, ast.Call) and getattr(dec.func, "attr", "") == "parametrize" + and len(dec.args) >= 2): + values = dec.args[1] + if isinstance(values, (ast.List, ast.Tuple)): + total *= max(len(values.elts), 1) + elif isinstance(values, ast.Call): # list(...) etc. — count unknown + total *= 1 + return total + + +def _python_cases(path: Path): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name.startswith("test"): + yield f"{node.name}.{item.name}", _objective(item), _param_count(item) + elif isinstance(node, ast.FunctionDef) and node.name.startswith("test"): + yield node.name, _objective(node), _param_count(node) + + +_IT_RE = re.compile(r"""\b(?:it|test)(?:\.each\([^)]*\))?\(\s*(["'`])(.+?)\1""") + + +def _vitest_cases(path: Path): + for m in _IT_RE.finditer(path.read_text(encoding="utf-8")): + yield _md(m.group(2)) + + +def build() -> str: + lines = [ + "# Test cases", + "", + "Every automated test case in the repository, grouped by test category.", + "Generated by `scripts/test_catalogue.py`; do not edit by hand. Run", + "`make test-catalogue` after adding or changing tests. How to run each group", + "is in [`tests/README.md`](../tests/README.md).", + "", + "Each row is one test function. **Cases** is the number of executions", + "after parametrisation. The objective is the test's docstring, which states", + "the behaviour asserted, and therefore the expected result.", + "", + ] + summary, sections = [], [] + for folder, title, prefix, blurb in GROUPS: + rows, n, cases = [], 0, 0 + for path in sorted((ROOT / "tests" / folder).rglob("test_*.py")): + rel = path.relative_to(ROOT).as_posix() + for name, objective, count in _python_cases(path): + n += 1 + cases += count + rows.append(f"| {prefix}-{n:03d} | `{rel}` | `{name}` | {objective} | {count} |") + frontend_dir = FRONTEND_TESTS / folder + for path in sorted(frontend_dir.rglob("*.test.ts*")) if frontend_dir.exists() else []: + rel = path.relative_to(ROOT).as_posix() + for title_ in _vitest_cases(path): + n += 1 + cases += 1 + rows.append(f"| {prefix}-{n:03d} | `{rel}` | vitest | {title_} | 1 |") + if folder == "system_integration" and SQL_SUITES.exists(): + for path in sorted(SQL_SUITES.glob("test_*.sql")): + count = len(re.findall(r"\bassert_\w+\s*\(", path.read_text(encoding="utf-8"))) + n += 1 + cases += count + rel = path.relative_to(ROOT).as_posix() + rows.append(f"| {prefix}-{n:03d} | `{rel}` | SQL suite | " + f"T&E schema constraints and business rules ({count} assertions) | {count} |") + summary.append(f"| [{title}](#{title.lower().replace(' ', '-')}) | `tests/{folder}/` | {n} | {cases} |") + sections += [f"## {title}", "", blurb, "", + "| ID | File | Test | Objective / expected result | Cases |", + "|----|------|------|-----------------------------|-------|", *rows, ""] + lines += ["## Summary", "", "| Category | Folder | Tests | Cases |", + "|----------|--------|-------|-------|", *summary, ""] + return "\n".join(lines + sections).rstrip() + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--check", action="store_true", help="fail if docs/TEST_CASES.md is stale") + args = ap.parse_args() + content = build() + if args.check: + current = OUT.read_text(encoding="utf-8") if OUT.exists() else "" + if current != content: + print("docs/TEST_CASES.md is stale — run: python3 scripts/test_catalogue.py") + return 1 + print("docs/TEST_CASES.md is up to date") + return 0 + OUT.write_text(content, encoding="utf-8") + print(f"wrote {OUT.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..a3d99a5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,52 @@ +# Test suite + +Tests are grouped into six categories. Each group lives in its own folder, and +`tests/conftest.py` automatically gives every test the marker for its folder. So +`pytest tests/uat` and `pytest -m uat` select the same tests. + +| Group | Folder | Marker | Needs | What it proves | +|-------|--------|--------|-------|----------------| +| Unit | `tests/unit/` (+ `csv-table-hub-main/src/__tests__/unit/`) | `unit` | nothing | Individual functions and modules behave correctly in isolation (parser, casting, config, auth, pool, loaders' guard paths, validator, eval runner, static security scans, CI-workflow structure). Frontend logic is covered by vitest. | +| System | `tests/system/` | `system` | PostgreSQL | The whole backend works end-to-end through its public HTTP API and shell pipeline: upload → list → read → delete → audit, typing, duplicates, overwrite, limits, T&E mode. | +| System integration (SIT) | `tests/system_integration/` | `sit` | PostgreSQL (+ all 4 envs for parity) | The seams between components: API ↔ frontend contract (checked against the zod schemas in `csv.functions.ts`), API ↔ PostgreSQL bootstrap/schema, API ↔ deploy scripts, cross-environment parity. | +| Performance | `tests/performance/` | `performance` | PostgreSQL for the API cases | Throughput, latency and resource bounds: 100k-row parse, 20k-row upload, concurrent uploads, p95 read latency, constant query count, bounded error payloads. | +| Accessibility | `tests/accessibility/` | `accessibility` | Node + `csv-table-hub-main/node_modules`, Playwright Chromium | WCAG 2.1 A/AA via axe-core in real Chromium, plus keyboard operability, focus trapping, headings, accessible names and 320px reflow. The backend is mocked, so no database is needed. | +| User acceptance (UAT) | `tests/uat/` | `uat` | PostgreSQL | User stories written as Given/When/Then, run against the API with the shipped sample CSVs. | + +The SQL assertion suite (`tests/framework/`, `tests/suites/`, `tests/run_all_tests.sql`, +`tests/run_tests.sh`) stays where it is. It is driven by `psql`, not pytest, and the +Tier S eval and deploy scripts reference those paths. It belongs to the system +integration group: it verifies the deployed T&E schema and its business rules. + +The full catalogue of test cases, with IDs and objectives, is in +[`docs/TEST_CASES.md`](../docs/TEST_CASES.md). Regenerate it with +`python3 scripts/test_catalogue.py` after adding tests. + +## Running + +```bash +# Database-free (CI free tier) +make test-unit + +# Need a provisioned dev database: +# bash scripts/provision_full_test_env.sh && export PGDATABASE=te_mgmt_dev +make test-system +make test-sit +make test-performance # PERF_BUDGET_SCALE=3 on slow machines +make test-uat + +# Needs the frontend installed: (cd csv-table-hub-main && bun install) +make test-accessibility # A11Y_BASE_URL=http://host:port to test a running app + +# Frontend unit tests (vitest) +cd csv-table-hub-main && npx vitest run +``` + +## Rules + +- **No skips.** A missing prerequisite (database, Node, Chromium) makes a test fail + with remediation text. `scripts/test_report.py --strict` enforces this in CI. +- **Leave no data behind.** Database tests use unique content per run and the + `registry_cleanup` / `te_org_cleanup` fixtures to remove what they created. +- **One group per test.** Put a new test in the folder that matches what it + proves. Do not add a second category marker by hand. diff --git a/tests/environments/__init__.py b/tests/accessibility/__init__.py similarity index 100% rename from tests/environments/__init__.py rename to tests/accessibility/__init__.py diff --git a/tests/accessibility/conftest.py b/tests/accessibility/conftest.py new file mode 100644 index 0000000..f09e95f --- /dev/null +++ b/tests/accessibility/conftest.py @@ -0,0 +1,216 @@ +"""Fixtures for the accessibility suite: a Vite dev server for the React +frontend, a headless Chromium via Playwright, and a mocked backend. + +The backend is mocked with Playwright request interception, so these tests +need neither PostgreSQL nor the FastAPI process — only Node (with +csv-table-hub-main/node_modules installed) and a Playwright Chromium. + +Prerequisites (missing ones FAIL with remediation, per the no-skip policy): + (cd csv-table-hub-main && bun install) # or npm install + pip install -r requirements-dev.txt # playwright + axe-playwright-python + python -m playwright install chromium # skip if PLAYWRIGHT_BROWSERS_PATH is set up + +Environment knobs: + A11Y_BASE_URL test an already-running frontend instead of starting Vite + A11Y_PORT port for the Vite dev server this fixture starts (default 8089) +""" +from __future__ import annotations + +import json +import os +import shutil +import signal +import socket +import subprocess # nosec B404 — starts the local Vite dev server +import time +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +FRONTEND = ROOT / "csv-table-hub-main" + +TE_TABLES = [ + "organisations", "personnel", "test_programs", "temp_documents", "test_phases", + "requirements", "test_cases", "vcrm_entries", "test_events", "test_results", + "defect_reports", "evidence_artifacts", +] + +MOCK_FILES = [ + { + "id": str(i), + "file_name": f"customers_{i:02d}.csv", + "table_name": f"csv_{i:016x}", + "mode": "dynamic", + "row_count": 10 * i, + "column_names": ["id", "name", "email", "signup_date"], + "created_at": f"2026-09-{(i % 28) + 1:02d}T10:00:00+00:00", + } + for i in range(1, 61) # 60 files: two pages at the default page size of 50 +] + +MOCK_ROWS = {"rows": [ + {"_id": 1, "id": 1, "name": "Alice Nguyen", "email": "alice@example.com", + "signup_date": "2024-01-15", "_row_hash": "a" * 64, "_created_at": "2026-09-01T10:00:00+00:00"}, +]} + +MOCK_PREVIEW = { + "status": "ok", "headers": ["name", "org_type", "country"], + "columns": ["name", "org_type", "country"], "inferredTypes": ["text", "text", "text"], + "sampleRows": [["ACME", "prime", "AU"]], "totalRowsApprox": 1, "teTableMatch": "organisations", +} + +MOCK_UPLOAD = { + "status": "ok", "fileId": "99", "tableName": "csv_00000000000000ff", "totalRows": 3, + "insertedRows": 2, "duplicateRowsSkipped": 0, "failedRows": 1, + "columns": ["name", "org_type", "country"], "types": ["text", "text", "text"], + "rowErrors": [{"rowNumber": 3, "column": "country", "value": "AUS", "reason": "too long"}], + "logs": [{"ts": 1, "step": "done", "level": "info", "message": "Import complete"}], + "overwritten": False, "replacedFileName": None, +} + +# A finished job history as the upload page persists it (useLocalStorageState). +MOCK_JOBS = [ + {"id": "j1", "batchId": "b1", "name": "customers.csv", "size": 2048, "status": "done", + "progress": 100, "createdAt": 1_790_000_000_000, "insertedRows": 5, "totalRows": 5, + "failedRows": 0, "duplicateRowsSkipped": 0, "tableName": "csv_0000000000000001", + "mode": "dynamic"}, + {"id": "j2", "batchId": "b1", "name": "orders.csv", "size": 4096, "status": "error", + "progress": 100, "createdAt": 1_790_000_100_000, "errorMessage": "Unsupported column type", + "rowErrors": [{"rowNumber": 2, "column": "qty", "value": "two", "reason": "not a number"}], + "mode": "dynamic"}, + {"id": "j3", "batchId": "b2", "name": "inventory.csv", "size": 1024, "status": "duplicate", + "progress": 100, "createdAt": 1_790_000_200_000, "existingFileName": "inventory.csv", + "duplicateReason": "content", "tableName": "csv_0000000000000003", "mode": "dynamic"}, +] + + +def _port_open(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + return s.connect_ex(("127.0.0.1", port)) == 0 + + +@pytest.fixture(scope="session") +def frontend_url(): + """URL of a running frontend; starts `vite dev` unless A11Y_BASE_URL is set.""" + external = os.environ.get("A11Y_BASE_URL") + if external: + yield external.rstrip("/") + return + + if not (FRONTEND / "node_modules").is_dir(): + pytest.fail( + "csv-table-hub-main/node_modules is missing — install the frontend first:\n" + " cd csv-table-hub-main && bun install (or npm install)", + pytrace=False, + ) + npx = shutil.which("npx") + if not npx: + pytest.fail("Node.js (npx) is not on PATH — install Node 22+.", pytrace=False) + + port = int(os.environ.get("A11Y_PORT", "8089")) + if _port_open(port): + pytest.fail(f"Port {port} is already in use — set A11Y_PORT or A11Y_BASE_URL.", + pytrace=False) + + env = {**os.environ, "VITE_API_BASE": "", "VITE_API_KEY": "", "BROWSER": "none"} + log = open(Path(os.environ.get("TMPDIR", "/tmp")) / "a11y-vite.log", "w") # noqa: SIM115 + # The TanStack router plugin regenerates routeTree.gen.ts on start; keep + # the committed copy so a local test run leaves the working tree clean. + route_tree = FRONTEND / "src" / "routeTree.gen.ts" + route_tree_before = route_tree.read_bytes() if route_tree.exists() else None + # New session => own process group, so the vite child that npx spawns is + # stopped too (terminating npx alone orphans it and leaks the port). + proc = subprocess.Popen( # nosec B603 — fixed argv, no shell + [npx, "vite", "dev", "--host", "127.0.0.1", "--port", str(port), "--strictPort"], + cwd=FRONTEND, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, + ) + url = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + 120 + try: + while True: + if proc.poll() is not None: + pytest.fail(f"vite dev exited early (code {proc.returncode}); see {log.name}", + pytrace=False) + try: + with urllib.request.urlopen(url + "/audit", timeout=5) as r: # nosec B310 — localhost + if r.status == 200: + break + except OSError: + pass + if time.monotonic() > deadline: + pytest.fail(f"vite dev did not answer on {url} within 120s; see {log.name}", + pytrace=False) + time.sleep(1) + yield url + finally: + try: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait(timeout=15) + except (ProcessLookupError, subprocess.TimeoutExpired): + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + log.close() + if route_tree_before is not None and route_tree.read_bytes() != route_tree_before: + route_tree.write_bytes(route_tree_before) + + +@pytest.fixture(scope="session") +def browser(): + try: + from playwright.sync_api import sync_playwright + except ImportError: + pytest.fail("playwright is not installed — pip install -r requirements-dev.txt", + pytrace=False) + with sync_playwright() as pw: + try: + b = pw.chromium.launch() + except Exception as exc: # noqa: BLE001 + pytest.fail( + f"Could not launch Chromium: {str(exc).splitlines()[0]}\n" + " python -m playwright install --with-deps chromium", + pytrace=False, + ) + yield b + b.close() + + +def _json(route, payload, status=200): + route.fulfill(status=status, content_type="application/json", body=json.dumps(payload)) + + +def _mock_api(route): + """Answer every /api/* call the frontend makes with realistic fixtures.""" + req = route.request + path = req.url.split("/api/", 1)[1].split("?", 1)[0] + if path == "csv/files": + return _json(route, MOCK_FILES) + if path == "te/tables": + return _json(route, [{"table": t, "exists": True, "rowCount": 3} for t in TE_TABLES]) + if path.startswith("csv/tables/"): + return _json(route, MOCK_ROWS) + if path == "csv/preview": + return _json(route, MOCK_PREVIEW) + if path == "csv/upload": + return _json(route, MOCK_UPLOAD) + if path == "health": + return _json(route, {"status": "ok"}) + return _json(route, {"detail": "not mocked"}, 404) + + +@pytest.fixture +def page(browser, frontend_url): + """A fresh page with the API mocked and an import history seeded.""" + context = browser.new_context(base_url=frontend_url, viewport={"width": 1280, "height": 900}) + context.add_init_script( + f"window.localStorage.setItem('csv-migrator:jobs:v1', {json.dumps(json.dumps(MOCK_JOBS))});" + ) + context.route("**/api/**", _mock_api) + pg = context.new_page() + pg.set_default_timeout(30_000) + yield pg + context.close() diff --git a/tests/accessibility/test_accessibility.py b/tests/accessibility/test_accessibility.py new file mode 100644 index 0000000..e5631f3 --- /dev/null +++ b/tests/accessibility/test_accessibility.py @@ -0,0 +1,202 @@ +"""Accessibility tests — WCAG 2.1 A/AA checks on the React frontend using +Playwright (real Chromium) and axe-core, plus keyboard and semantics checks +axe cannot make on its own. + +The backend is mocked (see conftest.py), so pages render with realistic data: +12 migrated files (two pages of the table), the 12 T&E tables, and a seeded +import history. +""" +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.accessibility + +WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] +# name -> (path, text that is visible once the page has rendered) +PAGES = { + "home": ("/", "Migrated files"), + "import history": ("/audit", "Import history"), + "auth": ("/auth", "Authentication disabled"), + "not found": ("/no-such-page", "Page not found"), +} +CSV = b"name,org_type,country\nACME,prime,AU\nBETA,academic,AU\n" + + +def _axe(page, context=None): + from axe_playwright_python.sync_playwright import Axe + + options = {"runOnly": {"type": "tag", "values": WCAG_TAGS}} + return Axe().run(page, context=context, options=options) + + +def _assert_no_violations(results, where: str): + violations = results.response["violations"] + if violations: + lines = [f"{len(violations)} WCAG violation(s) on {where}:"] + for v in violations: + lines.append(f" [{v['impact']}] {v['id']}: {v['help']} ({v['helpUrl']})") + for node in v["nodes"][:3]: + lines.append(f" {node['html'][:160]}") + pytest.fail("\n".join(lines), pytrace=False) + + +def _open(page, name): + path, ready_text = PAGES[name] + page.goto(path, wait_until="networkidle") + page.get_by_text(ready_text).first.wait_for() + + +def _open_preview_dialog(page): + _open(page, "home") + page.locator('input[type="file"]').set_input_files( + {"name": "orgs.csv", "mimeType": "text/csv", "buffer": CSV}) + dialog = page.get_by_role("dialog") + dialog.wait_for() + return dialog + + +# ── Automated WCAG scans ────────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", list(PAGES)) +def test_page_has_no_wcag_violations(page, name): + """A11Y-01 every page passes axe-core WCAG 2.1 A/AA rules.""" + _open(page, name) + _assert_no_violations(_axe(page), f"the {name} page") + + +def test_migrated_files_second_page_has_no_violations(page): + """A11Y-02 the paginated Migrated files table passes after paging.""" + _open(page, "home") + page.get_by_role("button", name="Next page").click() + _assert_no_violations(_axe(page), "Migrated files page 2") + + +def test_preview_dialog_has_no_violations(page): + """A11Y-03 the CSV preview / header-mapping dialog passes axe.""" + _open_preview_dialog(page) + _assert_no_violations(_axe(page, context='[role="dialog"]'), "the preview dialog") + + +def test_te_mode_dialog_has_no_violations(page): + """A11Y-04 the dialog in T&E-table mode (table picker) passes axe.""" + dialog = _open_preview_dialog(page) + dialog.get_by_role("button", name="T&E table").click() + _assert_no_violations(_axe(page, context='[role="dialog"]'), "the T&E-mode dialog") + + +def test_upload_report_has_no_violations(page): + """A11Y-05 the upload report shown after an import passes axe.""" + dialog = _open_preview_dialog(page) + dialog.get_by_role("button", name="Confirm & import").click() + page.get_by_text("Imported", exact=True).first.wait_for() + _assert_no_violations(_axe(page), "the upload report") + + +def test_table_preview_dialog_has_no_violations(page): + """A11Y-06 the imported-table preview dialog passes axe.""" + _open(page, "home") + page.get_by_role("button", name="Preview").first.click() + page.get_by_role("dialog").get_by_text("Alice Nguyen").wait_for() + _assert_no_violations(_axe(page, context='[role="dialog"]'), "the table preview dialog") + + +# ── Semantics ───────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", list(PAGES)) +def test_document_language_and_title(page, name): + """A11Y-07 every page declares lang and has a non-empty (WCAG 3.1.1, 2.4.2).""" + _open(page, name) + assert page.locator("html").get_attribute("lang") == "en" + assert page.title().strip() + + +@pytest.mark.parametrize("name", ["home", "import history"]) +def test_single_h1_and_no_skipped_heading_levels(page, name): + """A11Y-08 one h1 per page and heading levels never skip (WCAG 1.3.1).""" + _open(page, name) + levels = page.eval_on_selector_all( + "h1,h2,h3,h4,h5,h6", "els => els.map(e => Number(e.tagName[1]))") + assert levels.count(1) == 1, levels + assert all(b - a <= 1 for a, b in zip(levels, levels[1:])), levels + + +@pytest.mark.parametrize("name", ["home", "import history"]) +def test_every_button_and_link_has_an_accessible_name(page, name): + """A11Y-09 icon-only controls still expose a name to screen readers (WCAG 4.1.2).""" + _open(page, name) + unnamed = page.evaluate("""() => [...document.querySelectorAll('button, a[href], [role=button]')] + .filter(el => el.offsetParent !== null) + .filter(el => !(el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') + || el.getAttribute('title') || el.textContent.trim())) + .map(el => el.outerHTML.slice(0, 120))""") + assert unnamed == [], unnamed + + +def test_migrated_files_table_has_column_headers(page): + """A11Y-10 the data table exposes named column headers to assistive tech (WCAG 1.3.1). + + Read from Chromium's own accessibility tree (CDP) — that is what screen + readers consume; Playwright's role heuristics report these <th> as cells. + """ + _open(page, "home") + cdp = page.context.new_cdp_session(page) + nodes = cdp.send("Accessibility.getFullAXTree")["nodes"] + headers = [n.get("name", {}).get("value", "") for n in nodes + if n.get("role", {}).get("value") == "columnheader"] + assert {"File", "Table", "Cols", "Rows", "Uploaded"} <= set(headers), headers + + +# ── Keyboard ───────────────────────────────────────────────────────────────── + +def test_choose_files_is_reachable_by_keyboard(page): + """A11Y-11 Tab reaches the Choose files button and focus is visible (WCAG 2.1.1, 2.4.7).""" + _open(page, "home") + for _ in range(15): + page.keyboard.press("Tab") + if page.evaluate("document.activeElement.textContent.trim()") == "Choose files": + break + else: + pytest.fail("Choose files was not reached within 15 Tab presses") + outline = page.evaluate("""() => { const s = getComputedStyle(document.activeElement); + return s.outlineStyle !== 'none' || s.boxShadow !== 'none'; }""") + assert outline, "focused button has no visible focus indicator" + + +def test_dialog_traps_focus_and_escape_closes(page): + """A11Y-12 focus stays inside the open dialog and Escape dismisses it (WCAG 2.1.2).""" + dialog = _open_preview_dialog(page) + for _ in range(25): + page.keyboard.press("Tab") + inside = page.evaluate("!!document.activeElement.closest('[role=dialog]')") + assert inside, "focus escaped the modal dialog" + page.keyboard.press("Escape") + dialog.wait_for(state="hidden") + + +def test_pagination_is_operable_by_keyboard(page): + """A11Y-13 the table pager works with Enter on a focused button.""" + _open(page, "home") + page.get_by_role("button", name="Next page").focus() + page.keyboard.press("Enter") + page.locator("table").get_by_text("customers_51.csv").wait_for() + + +# ── Reflow / zoom ───────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", ["home", "import history"]) +def test_reflows_at_320_css_pixels(browser, frontend_url, name): + """A11Y-14 at 320px wide (400% zoom equivalent) the page does not scroll + horizontally (WCAG 1.4.10 Reflow). Data tables may scroll inside their own + container, which WCAG permits.""" + from tests.accessibility.conftest import _mock_api + + ctx = browser.new_context(base_url=frontend_url, viewport={"width": 320, "height": 640}) + ctx.route("**/api/**", _mock_api) + pg = ctx.new_page() + try: + _open(pg, name) + overflow = pg.evaluate("document.documentElement.scrollWidth - window.innerWidth") + assert overflow <= 1, f"page is {overflow}px wider than the 320px viewport" + finally: + ctx.close() diff --git a/tests/conftest.py b/tests/conftest.py index 6406033..f9fafcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,3 +43,137 @@ def _restore_env(): os.environ.pop(k, None) else: os.environ[k] = v + + +# ── Test-group markers ──────────────────────────────────────────────────────── +# Tests are grouped by folder (see tests/README.md). Every test inherits the +# marker of the folder it lives in, so `pytest -m uat` and `pytest tests/uat` +# select the same set. +_GROUP_MARKERS = { + "unit": "unit", + "system": "system", + "system_integration": "sit", + "performance": "performance", + "accessibility": "accessibility", + "uat": "uat", +} +_TESTS_ROOT = Path(__file__).resolve().parent + + +def pytest_collection_modifyitems(config, items): + for item in items: + try: + rel = Path(str(item.fspath)).resolve().relative_to(_TESTS_ROOT) + except ValueError: + continue + marker = _GROUP_MARKERS.get(rel.parts[0]) if len(rel.parts) > 1 else None + if marker: + item.add_marker(getattr(pytest.mark, marker)) + + +# ── Live-database fixtures (system / SIT / performance / UAT) ───────────────── +_DB_HELP = ( + "Start PostgreSQL, set PGHOST/PGUSER/PGPASSWORD, point PGDATABASE at the " + "provisioned dev database and deploy it:\n" + " bash scripts/provision_full_test_env.sh\n" + " export PGDATABASE=te_mgmt_dev" +) + + +@pytest.fixture(scope="module") +def live_api(): + """A TestClient with the FastAPI lifespan running (pool + bootstrap). + + Per the repository's no-skip policy an unreachable database FAILS with + remediation text rather than skipping. + """ + from fastapi.testclient import TestClient + + ctx = TestClient(app) + try: + client = ctx.__enter__() + except Exception as exc: # noqa: BLE001 + pytest.fail( + "Could not start the API — database connection failed.\n" + f" {type(exc).__name__}: {str(exc).strip().splitlines()[0]}\n{_DB_HELP}", + pytrace=False, + ) + if client.get("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/api/health").json().get("status") != "ok": + ctx.__exit__(None, None, None) + pytest.fail(f"API reports the database is unreachable.\n{_DB_HELP}", pytrace=False) + yield client + ctx.__exit__(None, None, None) + + +@pytest.fixture +def unique_tag(request): + """Short tag unique to this test invocation — keeps content hashes from + colliding with rows left behind by earlier runs.""" + import uuid + + return f"{request.node.name[:40]}_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def registry_cleanup(live_api): + """Collects uploaded file names and removes their registry rows (and, for + dynamic uploads, their csv_<hash> tables) after the test, directly via SQL + so cleanup works even while API_ALLOW_DESTRUCTIVE is off.""" + names: list[str] = [] + yield names + if not names: + return + from psycopg2 import sql + + from api.config import settings + from api.db import Conn + + schema = settings.UPLOADS_SCHEMA + with Conn() as conn: + with conn.cursor() as cur: + cur.execute( + sql.SQL("SELECT id, table_name, mode FROM {}.csv_files WHERE file_name = ANY(%s)") + .format(sql.Identifier(schema)), + (names,), + ) + for file_id, table_name, mode in cur.fetchall(): + if mode == "dynamic" and table_name.startswith("csv_"): + cur.execute(sql.SQL("DROP TABLE IF EXISTS {}.{}").format( + sql.Identifier(schema), sql.Identifier(table_name))) + cur.execute(sql.SQL("DELETE FROM {}.csv_files WHERE id = %s") + .format(sql.Identifier(schema)), (file_id,)) + conn.commit() + + +@pytest.fixture +def destructive_enabled(): + """Temporarily enable DELETE /api/csv/files/{id} (API_ALLOW_DESTRUCTIVE).""" + from api.config import settings + + original = settings.allow_destructive + settings.allow_destructive = True + yield + settings.allow_destructive = original + + +@pytest.fixture +def te_org_cleanup(live_api): + """Collects organisation names inserted into te_dev.organisations by a + T&E-mode test and deletes them afterwards so seed data stays intact.""" + names: list[str] = [] + yield names + if not names: + return + from psycopg2 import sql + + from api.config import settings + from api.db import Conn + + with Conn() as conn: + with conn.cursor() as cur: + cur.execute( + sql.SQL("DELETE FROM {}.organisations WHERE name = ANY(%s)") + .format(sql.Identifier(settings.TE_SCHEMA)), + (names,), + ) + conn.commit() diff --git a/tests/performance/__init__.py b/tests/performance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/performance/test_performance.py b/tests/performance/test_performance.py new file mode 100644 index 0000000..2d68db3 --- /dev/null +++ b/tests/performance/test_performance.py @@ -0,0 +1,207 @@ +"""Performance tests — throughput, latency and resource bounds. + +Budgets are deliberately generous so shared CI runners pass reliably while a +real regression (e.g. an accidental O(n²) loop or per-row round-trips) still +fails. Scale every budget with PERF_BUDGET_SCALE (default 1.0), e.g. +``PERF_BUDGET_SCALE=3 pytest tests/performance`` on a slow laptop. + +The CPU-only cases need no database; the rest need a provisioned dev database +(PGDATABASE=te_mgmt_dev) and fail, never skip, without one. +""" +from __future__ import annotations + +import os +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from api.config import settings +from api.services.csv_parse import build_preview, cast_value, parse_csv, sanitize_columns + +pytestmark = pytest.mark.performance + +SCALE = float(os.environ.get("PERF_BUDGET_SCALE", "1.0")) + + +def _budget(seconds: float) -> float: + return seconds * SCALE + + +def _timed(fn, *args, **kwargs): + start = time.perf_counter() + result = fn(*args, **kwargs) + return result, time.perf_counter() - start + + +def _csv(rows: int, tag: str = "") -> str: + lines = ["id,name,amount,joined,active"] + lines += [f"{i},name_{i}{tag},{i}.25,2024-01-{(i % 28) + 1:02d},{'true' if i % 2 else 'false'}" + for i in range(rows)] + return "\n".join(lines) + "\n" + + +# ── CPU-bound (no database) ─────────────────────────────────────────────────── + +class TestParserThroughput: + def test_parse_100k_rows(self): + """PERF-01 parse_csv handles 100,000 rows × 5 columns within 6s.""" + text = _csv(100_000) + rows, elapsed = _timed(parse_csv, text) + assert len(rows) == 100_001 + assert elapsed < _budget(6.0), f"parse_csv took {elapsed:.2f}s" + + def test_parse_scales_linearly(self): + """PERF-02 10× the rows costs well under 30× the time (no quadratic blow-up).""" + _, small = _timed(parse_csv, _csv(5_000)) + _, large = _timed(parse_csv, _csv(50_000)) + assert large < max(small, 0.01) * 30, f"5k={small:.3f}s 50k={large:.3f}s" + + def test_preview_of_large_file(self): + """PERF-03 build_preview of a 50,000-row file returns within 4s.""" + out, elapsed = _timed(build_preview, _csv(50_000)) + assert out["status"] == "ok" and out["totalRowsApprox"] == 50_000 + assert elapsed < _budget(4.0), f"build_preview took {elapsed:.2f}s" + + def test_very_long_field(self): + """PERF-04 a single 5 MB quoted field parses within 3s.""" + text = 'a\n"' + "x" * 5_000_000 + '"\n' + rows, elapsed = _timed(parse_csv, text) + assert len(rows[1][0]) == 5_000_000 + assert elapsed < _budget(3.0), f"long field took {elapsed:.2f}s" + + def test_sanitize_many_duplicate_headers(self): + """PERF-05 sanitising 2,000 identical headers stays under 2s.""" + out, elapsed = _timed(sanitize_columns, ["col"] * 2_000) + assert len(set(out)) == 2_000 + assert elapsed < _budget(2.0), f"sanitize_columns took {elapsed:.2f}s" + + def test_cast_200k_values(self): + """PERF-06 casting 200,000 cells across all types completes within 3s.""" + samples = [("123", "int8"), ("1.5", "numeric"), ("yes", "boolean"), + ("2024-01-01", "date"), ("2024-01-01T00:00:00Z", "timestamptz"), ("x", "text")] + start = time.perf_counter() + for i in range(200_000): + raw, t = samples[i % len(samples)] + cast_value(raw, t) + elapsed = time.perf_counter() - start + assert elapsed < _budget(3.0), f"cast_value loop took {elapsed:.2f}s" + + +# ── End-to-end through the API (PostgreSQL) ─────────────────────────────────── + +def _post(client, cleanup, name, content, **extra): + cleanup.append(name) + start = time.perf_counter() + r = client.post("/api/csv/upload", json={"fileName": name, "content": content, **extra}) + return r, time.perf_counter() - start + + +class TestUploadPerformance: + def test_dynamic_upload_20k_rows(self, live_api, registry_cleanup, unique_tag): + """PERF-07 a 20,000-row typed upload finishes within 20s with every row inserted.""" + r, elapsed = _post(live_api, registry_cleanup, f"perf_{unique_tag}.csv", + _csv(20_000, unique_tag), + types=["int8", "text", "numeric", "date", "boolean"]) + body = r.json() + assert body["status"] == "ok", body.get("message") + assert body["insertedRows"] == 20_000 + assert elapsed < _budget(20.0), f"20k-row upload took {elapsed:.2f}s" + + def test_all_failing_file_is_bounded(self, live_api, registry_cleanup, unique_tag): + """PERF-08 a 20,000-row file where every row fails returns quickly and the + response body stays small (rowErrors capped).""" + content = "n\n" + "\n".join(f"bad_{i}_{unique_tag}" for i in range(20_000)) + "\n" + r, elapsed = _post(live_api, registry_cleanup, f"perf_{unique_tag}.csv", content, + types=["int8"]) + body = r.json() + assert body["failedRows"] == 20_000 + assert len(body["rowErrors"]) == settings.MAX_ROW_ERRORS_REPORTED + assert len(r.content) < 200_000, f"response was {len(r.content)} bytes" + assert elapsed < _budget(10.0), f"all-failing upload took {elapsed:.2f}s" + + def test_te_upload_1000_rows(self, live_api, registry_cleanup, te_org_cleanup, unique_tag): + """PERF-09 a 1,000-row T&E upload uses the batch path and finishes within 10s.""" + names = [f"PERF_{unique_tag}_{i}" for i in range(1_000)] + te_org_cleanup.extend(names) + content = "name,org_type,country\n" + "\n".join(f"{n},academic,AU" for n in names) + "\n" + r, elapsed = _post(live_api, registry_cleanup, f"perf_{unique_tag}.csv", content, + mode="te", targetTable="organisations") + assert r.json()["insertedRows"] == 1_000 + assert elapsed < _budget(10.0), f"T&E upload took {elapsed:.2f}s" + + def test_concurrent_uploads(self, live_api, registry_cleanup, unique_tag): + """PERF-10 8 concurrent uploads (pool size) all succeed within 30s.""" + jobs = [(f"perf_{unique_tag}_{i}.csv", _csv(2_000, f"{unique_tag}_{i}")) for i in range(8)] + registry_cleanup.extend(name for name, _ in jobs) + + def send(job): + name, content = job + return live_api.post("/api/csv/upload", json={"fileName": name, "content": content}).json() + + start = time.perf_counter() + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(send, jobs)) + elapsed = time.perf_counter() - start + assert [r["status"] for r in results] == ["ok"] * 8 + assert all(r["insertedRows"] == 2_000 for r in results) + assert elapsed < _budget(30.0), f"8 concurrent uploads took {elapsed:.2f}s" + + +class TestReadLatency: + @pytest.mark.parametrize("path", ["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/api/health", "/api/csv/files", "/api/te/tables", + "/api/audit/log"]) + def test_read_endpoint_p95_latency(self, live_api, path): + """PERF-11 read endpoints answer with p95 latency under 500ms over 20 calls.""" + timings = [] + for _ in range(20): + start = time.perf_counter() + assert live_api.get(path).status_code == 200 + timings.append(time.perf_counter() - start) + p95 = sorted(timings)[int(len(timings) * 0.95) - 1] + assert p95 < _budget(0.5), f"{path} p95={p95 * 1000:.0f}ms" + + def test_te_tables_uses_constant_queries(self, live_api, monkeypatch): + """PERF-12 BUG-042: /api/te/tables issues 2 queries, not one per table.""" + from api.routers import te_routes + + executed = [] + + class CountingCursor: + """Wraps a real cursor and records every execute().""" + + def __init__(self, cur): + self._cur = cur + + def __enter__(self): + self._cur.__enter__() + return self + + def __exit__(self, *exc): + return self._cur.__exit__(*exc) + + def execute(self, query, *args, **kwargs): + executed.append(query) + return self._cur.execute(query, *args, **kwargs) + + def __getattr__(self, item): + return getattr(self._cur, item) + + class CountingConn(te_routes.Conn): + def __enter__(self): + conn = super().__enter__() + return type("ConnProxy", (), {"cursor": lambda _self: CountingCursor(conn.cursor())})() + + monkeypatch.setattr(te_routes, "Conn", CountingConn) + assert live_api.get("/api/te/tables").status_code == 200 + assert len(executed) == 2, f"expected 2 queries, got {len(executed)}" + + def test_oversized_body_rejected_fast(self, live_api, monkeypatch): + """PERF-13 a body over MAX_UPLOAD_BYTES is refused (413) in under 2s + without parsing it.""" + monkeypatch.setattr(settings, "MAX_UPLOAD_BYTES", 1_000_000) + start = time.perf_counter() + r = live_api.post("/api/csv/upload", json={"fileName": "big.csv", + "content": "a\n" + "x" * 2_000_000}) + assert r.status_code == 413 + assert time.perf_counter() - start < _budget(2.0) diff --git a/tests/system/__init__.py b/tests/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api_coverage.py b/tests/system/test_api_coverage.py similarity index 100% rename from tests/test_api_coverage.py rename to tests/system/test_api_coverage.py diff --git a/tests/test_csv_loader_arbitrary_shapes.py b/tests/system/test_csv_loader_arbitrary_shapes.py similarity index 98% rename from tests/test_csv_loader_arbitrary_shapes.py rename to tests/system/test_csv_loader_arbitrary_shapes.py index d079a36..e3e6c0b 100644 --- a/tests/test_csv_loader_arbitrary_shapes.py +++ b/tests/system/test_csv_loader_arbitrary_shapes.py @@ -21,7 +21,7 @@ pytestmark = pytest.mark.integration -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] LOADER = ROOT / "build" / "csv_loader.sh" UTILISE = ROOT / "build" / "csv_utilise.sh" BUILD_DIR = ROOT / "build" diff --git a/tests/test_e2e_pipeline.py b/tests/system/test_e2e_pipeline.py similarity index 99% rename from tests/test_e2e_pipeline.py rename to tests/system/test_e2e_pipeline.py index 3134bec..bee6c29 100644 --- a/tests/test_e2e_pipeline.py +++ b/tests/system/test_e2e_pipeline.py @@ -19,7 +19,7 @@ pytestmark = pytest.mark.e2e -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] VALIDATOR = ROOT / "build" / "csv" / "validator.py" RUNNER = ROOT / "evals" / "runner.py" diff --git a/tests/system/test_system_api.py b/tests/system/test_system_api.py new file mode 100644 index 0000000..4bdccdb --- /dev/null +++ b/tests/system/test_system_api.py @@ -0,0 +1,225 @@ +"""System tests — the whole backend (FastAPI + services + PostgreSQL) exercised +through its public HTTP API only, the way the frontend uses it. + +Requires a provisioned dev database (PGDATABASE=te_mgmt_dev). Fails, never +skips, when PostgreSQL is unreachable. +""" +from __future__ import annotations + +import pytest + +from api.config import settings + +pytestmark = pytest.mark.system + + +def _upload(client, cleanup, name, content, **extra): + cleanup.append(name) + r = client.post("/api/csv/upload", json={"fileName": name, "content": content, **extra}) + assert r.status_code == 200, r.text[:300] + return r.json() + + +def _file_by_name(client, name): + return next((f for f in client.get("/api/csv/files").json() if f["file_name"] == name), None) + + +class TestHealth: + def test_health_ok_with_bootstrapped_schema(self, live_api): + """SYS-01 /api/health reports ok and the uploads schema once bootstrapped.""" + body = live_api.get("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/api/health").json() + assert body["status"] == "ok" + assert body["uploads_schema"] == settings.UPLOADS_SCHEMA + assert body["postgres"].startswith("PostgreSQL") + + def test_openapi_lists_every_endpoint(self, live_api): + """SYS-02 the OpenAPI document exposes every public route.""" + paths = set(live_api.get("/openapi.json").json()["paths"]) + assert { + "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/api/health", "/api/csv/preview", "/api/csv/upload", "/api/csv/files", + "/api/csv/tables/{table_name}/rows", "/api/csv/files/{file_id}", + "/api/te/tables", "/api/audit/log", + } <= paths + + +class TestDynamicLifecycle: + def test_upload_list_read_delete_audit(self, live_api, registry_cleanup, unique_tag, + destructive_enabled): + """SYS-03 full lifecycle: upload → listed → rows readable → delete → + gone from list, rows 404, audit entry recorded.""" + name = f"sys_{unique_tag}.csv" + up = _upload(live_api, registry_cleanup, name, f"k,v\n1,{unique_tag}\n2,b\n") + assert up["status"] == "ok" and up["insertedRows"] == 2 + + listed = _file_by_name(live_api, name) + assert listed and listed["table_name"] == up["tableName"] and listed["row_count"] == 2 + + rows = live_api.get(f"/api/csv/tables/{up['tableName']}/rows").json()["rows"] + assert [r["v"] for r in rows] == [unique_tag, "b"] + + d = live_api.delete(f"/api/csv/files/{up['fileId']}") + assert d.status_code == 200 and d.json() == {"status": "ok", "deleted": int(up["fileId"])} + assert _file_by_name(live_api, name) is None + assert live_api.get(f"/api/csv/tables/{up['tableName']}/rows").status_code == 404 + + audit = live_api.get("/api/audit/log?limit=200").json()["entries"] + entry = next(e for e in audit if e["fileId"] == int(up["fileId"])) + assert entry["action"] == "delete" and entry["fileName"] == name + + def test_typed_columns_are_stored_typed(self, live_api, registry_cleanup, unique_tag): + """SYS-04 caller-supplied types create typed columns; values come back typed.""" + name = f"sys_{unique_tag}.csv" + up = _upload(live_api, registry_cleanup, name, + f"n,amount,day,flag,label\n7,1.50,2024-03-01,yes,{unique_tag}\n", + types=["int8", "numeric", "date", "boolean", "text"]) + assert up["status"] == "ok" and up["types"] == ["int8", "numeric", "date", "boolean", "text"] + row = live_api.get(f"/api/csv/tables/{up['tableName']}/rows").json()["rows"][0] + assert row["n"] == 7 and row["flag"] is True + assert row["amount"] == "1.50" and row["day"] == "2024-03-01" + + def test_cast_failures_are_reported_and_good_rows_load(self, live_api, registry_cleanup, + unique_tag): + """SYS-05 rows that fail type casting are reported; valid rows still load.""" + name = f"sys_{unique_tag}.csv" + up = _upload(live_api, registry_cleanup, name, + f"n,label\n1,{unique_tag}\nabc,x\n3,y\n", types=["int8", "text"]) + assert up["insertedRows"] == 2 and up["failedRows"] == 1 + err = up["rowErrors"][0] + assert err == {"rowNumber": 2, "column": "n", "value": "abc", + "reason": '"abc" is not a whole number'} + + def test_type_count_mismatch_is_rejected(self, live_api, registry_cleanup, unique_tag): + """SYS-06 supplying the wrong number of types is a clear error, no table.""" + up = _upload(live_api, registry_cleanup, f"sys_{unique_tag}.csv", + f"a,b\n1,{unique_tag}\n", types=["int8"]) + assert up["status"] == "error" and "1 column types" in up["message"] + + def test_unsupported_type_is_rejected(self, live_api, registry_cleanup, unique_tag): + """SYS-07 types outside the whitelist cannot reach CREATE TABLE.""" + up = _upload(live_api, registry_cleanup, f"sys_{unique_tag}.csv", + f"a\n{unique_tag}\n", types=["text; DROP TABLE x"]) + assert up["status"] == "error" and "Unsupported" in up["message"] + + def test_headers_are_sanitised_in_created_table(self, live_api, registry_cleanup, unique_tag): + """SYS-08 messy headers become safe snake_case column names.""" + up = _upload(live_api, registry_cleanup, f"sys_{unique_tag}.csv", + f"First Name,E-Mail,2024 Total\n{unique_tag},a@b.c,9\n") + assert up["columns"] == ["first_name", "e_mail", "col_2024_total"] + row = live_api.get(f"/api/csv/tables/{up['tableName']}/rows").json()["rows"][0] + assert {"first_name", "e_mail", "col_2024_total", "_id", "_row_hash"} <= set(row) + + +class TestDuplicatesAndOverwrite: + def test_same_name_different_content_is_name_duplicate(self, live_api, registry_cleanup, + unique_tag): + """SYS-09 a new file reusing an existing file name is flagged reason=name.""" + name = f"sys_{unique_tag}.csv" + _upload(live_api, registry_cleanup, name, f"a\n{unique_tag}\n") + dup = _upload(live_api, registry_cleanup, name, f"a\n{unique_tag}_v2\n") + assert dup["status"] == "duplicate_file" and dup["reason"] == "name" + assert dup["existingRowCount"] == 1 + + def test_same_content_different_name_is_content_duplicate(self, live_api, registry_cleanup, + unique_tag): + """SYS-10 identical content under another name is flagged reason=content.""" + content = f"a\n{unique_tag}\n" + _upload(live_api, registry_cleanup, f"sys_{unique_tag}_1.csv", content) + dup = _upload(live_api, registry_cleanup, f"sys_{unique_tag}_2.csv", content) + assert dup["status"] == "duplicate_file" and dup["reason"] == "content" + assert dup["existingFileName"] == f"sys_{unique_tag}_1.csv" + + def test_overwrite_replaces_previous_upload(self, live_api, registry_cleanup, unique_tag): + """SYS-11 overwrite=true drops the old table and registers the new file once.""" + name = f"sys_{unique_tag}.csv" + first = _upload(live_api, registry_cleanup, name, f"a\n{unique_tag}\n") + second = _upload(live_api, registry_cleanup, name, f"a\n{unique_tag}\nz\n", overwrite=True) + assert second["status"] == "ok" and second["overwritten"] is True + assert second["replacedFileName"] == name + assert [f["file_name"] for f in live_api.get("/api/csv/files").json()].count(name) == 1 + assert live_api.get(f"/api/csv/tables/{first['tableName']}/rows").status_code == 404 + + +class TestLimitsAndGuards: + def test_row_cap_rejects_oversized_file(self, live_api, registry_cleanup, unique_tag, + monkeypatch): + """SYS-12 API_MAX_ROWS rejects a file before any table is created.""" + monkeypatch.setattr(settings, "MAX_ROWS", 2) + name = f"sys_{unique_tag}.csv" + up = _upload(live_api, registry_cleanup, name, f"a\n{unique_tag}\n2\n3\n") + assert up["status"] == "error" and "at most 2" in up["message"] + assert _file_by_name(live_api, name) is None + + def test_row_error_list_is_capped(self, live_api, registry_cleanup, unique_tag, monkeypatch): + """SYS-13 rowErrors is capped but failedRows reports the true count.""" + monkeypatch.setattr(settings, "MAX_ROW_ERRORS_REPORTED", 5) + body = "n\n" + "\n".join(f"bad{i}_{unique_tag}" for i in range(20)) + "\n" + up = _upload(live_api, registry_cleanup, f"sys_{unique_tag}.csv", body, types=["int8"]) + assert up["failedRows"] == 20 and len(up["rowErrors"]) == 5 + + def test_upload_size_limit_returns_413(self, live_api, monkeypatch): + """SYS-14 bodies above MAX_UPLOAD_BYTES are refused with 413.""" + monkeypatch.setattr(settings, "MAX_UPLOAD_BYTES", 10) + r = live_api.post("/api/csv/upload", json={"fileName": "big.csv", "content": "a\n" + "x" * 20}) + assert r.status_code == 413 + + def test_delete_disabled_by_default(self, live_api, monkeypatch): + """SYS-15 DELETE is 403 unless API_ALLOW_DESTRUCTIVE is on.""" + monkeypatch.setattr(settings, "allow_destructive", False) + assert live_api.delete("/api/csv/files/1").status_code == 403 + + def test_rows_limit_is_clamped(self, live_api, registry_cleanup, unique_tag): + """SYS-16 the rows endpoint clamps limit into 1..200.""" + body = "n\n" + "\n".join(f"{i}_{unique_tag}" for i in range(5)) + "\n" + up = _upload(live_api, registry_cleanup, f"sys_{unique_tag}.csv", body) + url = f"/api/csv/tables/{up['tableName']}/rows" + assert len(live_api.get(url + "?limit=0").json()["rows"]) == 1 + assert len(live_api.get(url + "?limit=999").json()["rows"]) == 5 + + @pytest.mark.parametrize("name", ["organisations", "pg_class", "csv_" + "x" * 70]) + def test_rows_endpoint_refuses_non_upload_tables(self, live_api, name): + """SYS-17 only registered csv_* tables are readable through the API.""" + assert live_api.get(f"/api/csv/tables/{name}/rows").status_code in (404, 422) + + +class TestAuditLogEndpoint: + def test_pagination_parameters_are_clamped(self, live_api): + """SYS-18 limit is clamped into 1..200 and offset to >= 0.""" + assert live_api.get("/api/audit/log?limit=0").json()["limit"] == 1 + assert live_api.get("/api/audit/log?limit=5000").json()["limit"] == 200 + assert live_api.get("/api/audit/log?offset=-5").json()["offset"] == 0 + + def test_entries_are_newest_first(self, live_api): + """SYS-19 audit entries are ordered by performedAt descending.""" + entries = live_api.get("/api/audit/log?limit=50").json()["entries"] + stamps = [e["performedAt"] for e in entries] + assert stamps == sorted(stamps, reverse=True) + + +class TestTeMode: + def test_te_tables_endpoint_lists_all_twelve(self, live_api): + """SYS-20 /api/te/tables reports all 12 T&E tables as existing.""" + tables = live_api.get("/api/te/tables").json() + assert len(tables) == 12 and all(t["exists"] for t in tables) + + def test_te_upload_and_registry(self, live_api, registry_cleanup, te_org_cleanup, unique_tag): + """SYS-21 a T&E upload inserts rows and registers a schema-qualified table.""" + org = f"SYS_{unique_tag}" + te_org_cleanup.append(org) + name = f"sys_{unique_tag}.csv" + up = _upload(live_api, registry_cleanup, name, f"name,org_type,country\n{org},academic,AU\n", + mode="te", targetTable="organisations") + assert up["status"] == "ok" and up["insertedRows"] == 1 + assert up["tableName"] == f"{settings.TE_SCHEMA}.organisations" + listed = _file_by_name(live_api, name) + assert listed["mode"] == "te" and listed["row_count"] == 1 + + def test_te_constraint_violation_is_per_row(self, live_api, registry_cleanup, te_org_cleanup, + unique_tag): + """SYS-22 a CHECK-constraint failure is reported per row; others insert.""" + good, bad = f"SYS_{unique_tag}_ok", f"SYS_{unique_tag}_bad" + te_org_cleanup.extend([good, bad]) + up = _upload(live_api, registry_cleanup, f"sys_{unique_tag}.csv", + f"name,org_type,country\n{good},prime,AU\n{bad},not_a_type,AU\n", + mode="te", targetTable="organisations") + assert up["insertedRows"] == 1 and up["failedRows"] == 1 + assert "check" in up["rowErrors"][0]["reason"].lower() diff --git a/tests/system_integration/__init__.py b/tests/system_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/system_integration/environments/__init__.py b/tests/system_integration/environments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/environments/_contract.py b/tests/system_integration/environments/_contract.py similarity index 100% rename from tests/environments/_contract.py rename to tests/system_integration/environments/_contract.py diff --git a/tests/environments/test_env_dev.py b/tests/system_integration/environments/test_env_dev.py similarity index 79% rename from tests/environments/test_env_dev.py rename to tests/system_integration/environments/test_env_dev.py index ad541da..946cbe5 100644 --- a/tests/environments/test_env_dev.py +++ b/tests/system_integration/environments/test_env_dev.py @@ -1,7 +1,7 @@ """Contract tests for the dev environment.""" import unittest import pytest -from tests.environments._contract import EnvironmentContract +from tests.system_integration.environments._contract import EnvironmentContract pytestmark = [pytest.mark.integration, pytest.mark.parity] diff --git a/tests/environments/test_env_prod.py b/tests/system_integration/environments/test_env_prod.py similarity index 80% rename from tests/environments/test_env_prod.py rename to tests/system_integration/environments/test_env_prod.py index 71f25e9..d285737 100644 --- a/tests/environments/test_env_prod.py +++ b/tests/system_integration/environments/test_env_prod.py @@ -1,7 +1,7 @@ """Contract tests for the prod environment.""" import unittest import pytest -from tests.environments._contract import EnvironmentContract +from tests.system_integration.environments._contract import EnvironmentContract pytestmark = [pytest.mark.integration, pytest.mark.parity] diff --git a/tests/environments/test_env_staging.py b/tests/system_integration/environments/test_env_staging.py similarity index 80% rename from tests/environments/test_env_staging.py rename to tests/system_integration/environments/test_env_staging.py index aac766a..e8a0bbb 100644 --- a/tests/environments/test_env_staging.py +++ b/tests/system_integration/environments/test_env_staging.py @@ -1,7 +1,7 @@ """Contract tests for the staging environment.""" import unittest import pytest -from tests.environments._contract import EnvironmentContract +from tests.system_integration.environments._contract import EnvironmentContract pytestmark = [pytest.mark.integration, pytest.mark.parity] diff --git a/tests/environments/test_env_test.py b/tests/system_integration/environments/test_env_test.py similarity index 80% rename from tests/environments/test_env_test.py rename to tests/system_integration/environments/test_env_test.py index 38efb30..3a7110a 100644 --- a/tests/environments/test_env_test.py +++ b/tests/system_integration/environments/test_env_test.py @@ -1,7 +1,7 @@ """Contract tests for the test environment.""" import unittest import pytest -from tests.environments._contract import EnvironmentContract +from tests.system_integration.environments._contract import EnvironmentContract pytestmark = [pytest.mark.integration, pytest.mark.parity] diff --git a/tests/test_api_integration.py b/tests/system_integration/test_api_integration.py similarity index 100% rename from tests/test_api_integration.py rename to tests/system_integration/test_api_integration.py diff --git a/tests/test_parity.py b/tests/system_integration/test_parity.py similarity index 99% rename from tests/test_parity.py rename to tests/system_integration/test_parity.py index dfe5943..55d7349 100644 --- a/tests/test_parity.py +++ b/tests/system_integration/test_parity.py @@ -17,7 +17,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.parity] -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # Expected minimum seed row counts for the dev environment (from seed data). # Adjust these values if the seed SQL is intentionally changed. diff --git a/tests/system_integration/test_sit_frontend_contract.py b/tests/system_integration/test_sit_frontend_contract.py new file mode 100644 index 0000000..6d097c7 --- /dev/null +++ b/tests/system_integration/test_sit_frontend_contract.py @@ -0,0 +1,222 @@ +"""System integration tests — the seams between components. + +* API ↔ frontend: every response the live API produces must satisfy the zod + schemas the React app validates it with (csv-table-hub-main/src/lib/ + csv.functions.ts). The enums are read from the TypeScript source, so a + change on either side that breaks the other fails here. +* API ↔ PostgreSQL: bootstrap is idempotent and the registry / T&E schema the + API depends on are present with the expected columns. +* API ↔ deploy scripts: the T&E whitelist in api/config.py matches the tables + that build/te_core_schema.sql creates. + +Requires a provisioned dev database (PGDATABASE=te_mgmt_dev). +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from api import db +from api.config import TE_TABLES, settings +from api.services.csv_parse import ALLOWED_TYPES + +pytestmark = pytest.mark.sit + +ROOT = Path(__file__).resolve().parents[2] +TS_CONTRACT = ROOT / "csv-table-hub-main" / "src" / "lib" / "csv.functions.ts" +TE_SCHEMA_SQL = ROOT / "build" / "te_core_schema.sql" + + +def _ts_enum(after: str) -> set[str]: + """Values of the first z.enum([...]) that follows ``after`` in the TS file.""" + src = TS_CONTRACT.read_text(encoding="utf-8") + start = src.index(after) + m = re.search(r"z\.enum\(\[(.*?)\]\)", src[start:], re.S) + assert m, f"no z.enum after {after!r} in {TS_CONTRACT}" + return set(re.findall(r'"([^"]+)"', m.group(1))) + + +@pytest.fixture(scope="module") +def contract(): + return { + "column_types": _ts_enum("const columnTypeSchema"), + "log_steps": _ts_enum("step: z.enum"), + "log_levels": _ts_enum("level: z.enum"), + "invalid_reasons": _ts_enum('status: z.literal("invalid_structure")'), + "duplicate_reasons": _ts_enum('status: z.literal("duplicate_file")'), + } + + +def _assert_logs(logs, contract): + for entry in logs: + assert isinstance(entry["ts"], int) and isinstance(entry["message"], str) + assert entry["step"] in contract["log_steps"], f"step {entry['step']!r} unknown to the UI" + assert entry["level"] in contract["log_levels"] + if "count" in entry: + assert isinstance(entry["count"], int) + + +def _assert_row_errors(errors): + for e in errors: + assert isinstance(e["rowNumber"], int) and isinstance(e["reason"], str) + for optional in ("column", "value"): + if optional in e: + assert isinstance(e[optional], str) + + +def _upload(client, cleanup, name, content, **extra): + cleanup.append(name) + return client.post("/api/csv/upload", json={"fileName": name, "content": content, **extra}).json() + + +class TestStaticContract: + def test_column_type_enum_matches_backend_whitelist(self, contract): + """SIT-01 the UI's ColumnType enum equals the API's ALLOWED_TYPES.""" + assert contract["column_types"] == set(ALLOWED_TYPES) + + def test_te_whitelist_matches_schema_ddl(self): + """SIT-02 every T&E table the API whitelists is created by te_core_schema.sql.""" + ddl = TE_SCHEMA_SQL.read_text(encoding="utf-8").lower() + # Table names are psql variables: CREATE TABLE ... :"schema_name".:"tbl_<name>" + created = set(re.findall(r'create table if not exists :"schema_name"\.:"tbl_([a-z_]+)"', ddl)) + assert set(TE_TABLES) <= created, set(TE_TABLES) - created + + def test_frontend_sends_api_key_header_the_api_reads(self): + """SIT-03 the UI sends X-API-Key, which CORS allows and auth reads.""" + from api.main import app + + assert '"X-API-Key"' in TS_CONTRACT.read_text(encoding="utf-8") + cors = next(m for m in app.user_middleware if m.cls.__name__ == "CORSMiddleware") + assert "X-API-Key" in cors.kwargs["allow_headers"] + + +class TestLiveResponsesSatisfyUiSchemas: + def test_ok_upload(self, live_api, registry_cleanup, unique_tag, contract): + """SIT-04 status=ok upload response matches uploadResultSchema.""" + r = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", + f"a,b\n1,{unique_tag}\nx,y\n", types=["int8", "text"]) + assert r["status"] == "ok" + assert isinstance(r["fileId"], str) and isinstance(r["tableName"], str) + for key in ("totalRows", "insertedRows", "duplicateRowsSkipped", "failedRows"): + assert isinstance(r[key], int) + assert set(r["types"]) <= contract["column_types"] + assert r["replacedFileName"] is None or isinstance(r["replacedFileName"], str) + _assert_row_errors(r["rowErrors"]) + _assert_logs(r["logs"], contract) + + def test_duplicate_upload(self, live_api, registry_cleanup, unique_tag, contract): + """SIT-05 status=duplicate_file response matches the UI schema.""" + content = f"a\n{unique_tag}\n" + _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", content) + r = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", content) + assert r["status"] == "duplicate_file" + assert r["reason"] in contract["duplicate_reasons"] + assert isinstance(r["existingFileName"], str) and isinstance(r["existingRowCount"], int) + _assert_logs(r["logs"], contract) + + @pytest.mark.parametrize("content", ["\n", "a,b\n", ",\n1,2\n"]) + def test_invalid_structure_upload(self, live_api, registry_cleanup, unique_tag, contract, + content): + """SIT-06 status=invalid_structure reasons are ones the UI understands.""" + r = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", content) + assert r["status"] == "invalid_structure" + assert r["reason"] in contract["invalid_reasons"] and isinstance(r["message"], str) + _assert_logs(r["logs"], contract) + + def test_error_upload(self, live_api, registry_cleanup, unique_tag, contract): + """SIT-07 status=error response matches the UI schema.""" + r = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", f"a\n{unique_tag}\n", + types=["int8", "int8"]) + assert r["status"] == "error" and isinstance(r["message"], str) + _assert_logs(r["logs"], contract) + + def test_te_upload(self, live_api, registry_cleanup, te_org_cleanup, unique_tag, contract): + """SIT-08 a T&E-mode response (including DB-side row errors) matches the UI schema.""" + org = f"SIT_{unique_tag}" + te_org_cleanup.append(org) + r = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", + f"name,org_type,country\n{org},prime,AU\n{org}_x,bogus,AU\n", + mode="te", targetTable="organisations") + te_org_cleanup.append(f"{org}_x") + assert r["status"] == "ok" and r["types"] == [] + _assert_row_errors(r["rowErrors"]) + _assert_logs(r["logs"], contract) + + def test_files_list(self, live_api, registry_cleanup, unique_tag): + """SIT-09 /api/csv/files entries match csvFileSummarySchema.""" + _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", f"a\n{unique_tag}\n") + files = live_api.get("/api/csv/files").json() + assert files + for f in files: + assert isinstance(f["id"], str) and isinstance(f["file_name"], str) + assert isinstance(f["table_name"], str) and isinstance(f["row_count"], int) + assert all(isinstance(c, str) for c in f["column_names"]) + assert isinstance(f["created_at"], str) + + def test_preview_ok(self, live_api, contract): + """SIT-10 /api/csv/preview ok response matches csvPreviewResultSchema.""" + r = live_api.post("/api/csv/preview", json={"fileName": "p.csv", + "content": "name,org_type,country\nA,prime,AU\n"}).json() + assert r["status"] == "ok" + assert set(r["inferredTypes"]) <= contract["column_types"] + assert all(isinstance(c, str) for row in r["sampleRows"] for c in row) + assert r["teTableMatch"] == "organisations" + + def test_table_rows_values_are_json_primitives(self, live_api, registry_cleanup, unique_tag): + """SIT-11 row values are string/number/boolean/null as previewRowsSchema requires.""" + up = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", + f"d,t,b\n2024-01-01,{unique_tag},true\n", types=["date", "text", "boolean"]) + rows = live_api.get(f"/api/csv/tables/{up['tableName']}/rows").json()["rows"] + for row in rows: + assert all(v is None or isinstance(v, (str, int, float, bool)) for v in row.values()) + + def test_te_tables(self, live_api): + """SIT-12 /api/te/tables entries match teTableInfoSchema.""" + for t in live_api.get("/api/te/tables").json(): + assert isinstance(t["table"], str) and isinstance(t["exists"], bool) + assert isinstance(t["rowCount"], int) and t["rowCount"] >= 0 + + +class TestApiDatabaseIntegration: + def test_bootstrap_is_idempotent(self, live_api): + """SIT-13 running bootstrap again on a live schema is a harmless no-op.""" + before = live_api.get("/api/csv/files").json() + db.bootstrap() + assert live_api.get("/api/csv/files").json() == before + + def test_registry_tables_have_expected_columns(self, live_api): + """SIT-14 csv_files and audit_log carry every column the API reads/writes.""" + with db.Conn() as conn, conn.cursor() as cur: + cur.execute( + "SELECT table_name, column_name FROM information_schema.columns " + "WHERE table_schema = %s", (settings.UPLOADS_SCHEMA,)) + cols: dict[str, set] = {} + for table, col in cur.fetchall(): + cols.setdefault(table, set()).add(col) + assert {"id", "file_name", "file_hash", "table_name", "mode", "row_count", + "column_names", "created_at"} <= cols["csv_files"] + assert {"id", "action", "file_id", "file_name", "table_name", "mode", + "performed_at"} <= cols["audit_log"] + + def test_dynamic_tables_live_only_in_uploads_schema(self, live_api, registry_cleanup, + unique_tag): + """SIT-15 a dynamic upload creates its table in the uploads schema, nowhere else.""" + up = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", f"a\n{unique_tag}\n") + with db.Conn() as conn, conn.cursor() as cur: + cur.execute("SELECT table_schema FROM information_schema.tables WHERE table_name = %s", + (up["tableName"],)) + assert [r[0] for r in cur.fetchall()] == [settings.UPLOADS_SCHEMA] + + def test_te_row_counts_track_postgres_statistics(self, live_api): + """SIT-16 /api/te/tables row counts agree with COUNT(*) once statistics are fresh.""" + with db.Conn() as conn: + conn.autocommit = True + with conn.cursor() as cur: + cur.execute(f'ANALYZE "{settings.TE_SCHEMA}".organisations') # nosec B608 — fixed identifier + cur.execute(f'SELECT COUNT(*) FROM "{settings.TE_SCHEMA}".organisations') # nosec B608 + actual = cur.fetchone()[0] + conn.autocommit = False + reported = next(t for t in live_api.get("/api/te/tables").json() if t["table"] == "organisations") + assert reported["rowCount"] == actual diff --git a/tests/test_te_loader_integration.py b/tests/system_integration/test_te_loader_integration.py similarity index 99% rename from tests/test_te_loader_integration.py rename to tests/system_integration/test_te_loader_integration.py index 55431b8..7425d83 100644 --- a/tests/test_te_loader_integration.py +++ b/tests/system_integration/test_te_loader_integration.py @@ -13,7 +13,7 @@ Run:: - pytest tests/test_te_loader_integration.py + pytest tests/system_integration/test_te_loader_integration.py Requires a provisioned dev environment: bash scripts/provision_full_test_env.sh diff --git a/tests/uat/__init__.py b/tests/uat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_issue_04_multi_file_upload.py b/tests/uat/test_issue_04_multi_file_upload.py similarity index 99% rename from tests/test_issue_04_multi_file_upload.py rename to tests/uat/test_issue_04_multi_file_upload.py index 4982e9c..19eb2a0 100644 --- a/tests/test_issue_04_multi_file_upload.py +++ b/tests/uat/test_issue_04_multi_file_upload.py @@ -17,7 +17,7 @@ Run:: - python -m pytest tests/test_issue_04_multi_file_upload.py + python -m pytest tests/uat/test_issue_04_multi_file_upload.py Requires a reachable database; per the repository's no-skip policy a missing prerequisite FAILS with remediation text rather than skipping. diff --git a/tests/test_issue_05_import_summary.py b/tests/uat/test_issue_05_import_summary.py similarity index 99% rename from tests/test_issue_05_import_summary.py rename to tests/uat/test_issue_05_import_summary.py index a0ecc5b..dd11692 100644 --- a/tests/test_issue_05_import_summary.py +++ b/tests/uat/test_issue_05_import_summary.py @@ -23,7 +23,7 @@ Run:: - python -m pytest tests/test_issue_05_import_summary.py + python -m pytest tests/uat/test_issue_05_import_summary.py Requires a reachable database; per the repository's no-skip policy a missing prerequisite FAILS with remediation text rather than skipping. diff --git a/tests/uat/test_uat_user_stories.py b/tests/uat/test_uat_user_stories.py new file mode 100644 index 0000000..9ff42e9 --- /dev/null +++ b/tests/uat/test_uat_user_stories.py @@ -0,0 +1,224 @@ +"""User acceptance tests — each test is one acceptance criterion of a user story, +written Given / When / Then from the point of view of the data migrator who +uses the CSV Migrator UI. They drive the same HTTP calls the UI makes, using +the sample files shipped in build/csv/samples/. + +Persona: "Migrator" — moves spreadsheet data into PostgreSQL without SQL. + +Requires a provisioned dev database (PGDATABASE=te_mgmt_dev). +""" +from __future__ import annotations + +import csv +import io +from pathlib import Path + +import pytest + +from api.config import settings + +pytestmark = pytest.mark.uat + +SAMPLES = Path(__file__).resolve().parents[2] / "build" / "csv" / "samples" + + +def _sample(name: str, tag: str) -> str: + """Sample file content with a unique trailing row so each run hashes uniquely.""" + text = (SAMPLES / name).read_text(encoding="utf-8") + header = next(csv.reader(io.StringIO(text))) + return text.rstrip("\n") + "\n" + ",".join(["9999"] + [tag] * (len(header) - 1)) + "\n" + + +def _upload(client, cleanup, name, content, **extra): + cleanup.append(name) + return client.post("/api/csv/upload", json={"fileName": name, "content": content, **extra}).json() + + +class TestUS01ImportAnyCsv: + """US-01 As a Migrator I want to upload a CSV and have a table created for it, + so that I can migrate data without writing DDL.""" + + @pytest.mark.parametrize("sample", ["customers.csv", "orders.csv", "inventory.csv"]) + def test_sample_file_becomes_a_table_with_every_row(self, live_api, registry_cleanup, + unique_tag, sample): + """UAT-01 Given a sample CSV, when I import it, then every data row is stored.""" + content = _sample(sample, unique_tag) + data_rows = len(list(csv.reader(io.StringIO(content)))) - 1 + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}_{sample}", content) + assert result["status"] == "ok" + assert result["totalRows"] == result["insertedRows"] == data_rows + assert result["failedRows"] == 0 + + def test_quoted_commas_are_preserved(self, live_api, registry_cleanup, unique_tag): + """UAT-02 Given a product name containing a comma, when imported, then it is + stored as one value, not split across columns.""" + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", + _sample("orders.csv", unique_tag)) + rows = live_api.get(f"/api/csv/tables/{result['tableName']}/rows").json()["rows"] + assert "Wireless headphones, black" in [r["product"] for r in rows] + + +class TestUS02PreviewBeforeImport: + """US-02 As a Migrator I want to preview a file before importing it, so that I + can confirm the columns and types are right.""" + + def test_preview_shows_headers_types_and_sample(self, live_api, unique_tag): + """UAT-03 Given customers.csv, when I preview it, then I see its headers, + sensible inferred types and the first rows, and nothing is stored.""" + before = len(live_api.get("/api/csv/files").json()) + p = live_api.post("/api/csv/preview", json={ + "fileName": "customers.csv", "content": _sample("customers.csv", unique_tag)}).json() + assert p["headers"] == ["id", "name", "email", "signup_date"] + assert p["inferredTypes"][0] == "int8" + assert p["sampleRows"][0][1] == "Alice Nguyen" + assert len(live_api.get("/api/csv/files").json()) == before + + def test_preview_suggests_matching_te_table(self, live_api): + """UAT-04 Given a file whose columns fit a T&E table, when I preview it, + then that table is suggested.""" + p = live_api.post("/api/csv/preview", json={ + "fileName": "orgs.csv", "content": "name,org_type,country\nACME,prime,AU\n"}).json() + assert p["teTableMatch"] == "organisations" + + def test_preview_explains_an_unusable_file(self, live_api): + """UAT-05 Given a header-only file, when I preview it, then I am told why it + cannot be imported.""" + p = live_api.post("/api/csv/preview", json={"fileName": "h.csv", "content": "a,b\n"}).json() + assert p == {"status": "invalid_structure", "reason": "header_only"} + + +class TestUS03DuplicateProtection: + """US-03 As a Migrator I want accidental re-uploads to be caught, so that I do + not load the same data twice.""" + + def test_reupload_is_flagged_with_the_original_file(self, live_api, registry_cleanup, + unique_tag): + """UAT-06 Given I imported a file, when I upload the same content again, + then I am told it duplicates the original and how many rows it holds.""" + content = _sample("customers.csv", unique_tag) + first = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", content) + again = _upload(live_api, registry_cleanup, f"uat_{unique_tag}_copy.csv", content) + assert again["status"] == "duplicate_file" + assert again["existingFileName"] == f"uat_{unique_tag}.csv" + assert again["existingRowCount"] == first["insertedRows"] + + def test_overwrite_replaces_the_data(self, live_api, registry_cleanup, unique_tag): + """UAT-07 Given a duplicate warning, when I choose Overwrite, then the new + data replaces the old and the file appears once in the list.""" + name = f"uat_{unique_tag}.csv" + _upload(live_api, registry_cleanup, name, _sample("customers.csv", unique_tag)) + result = _upload(live_api, registry_cleanup, name, + _sample("customers.csv", unique_tag + "_v2"), overwrite=True) + assert result["status"] == "ok" and result["overwritten"] is True + names = [f["file_name"] for f in live_api.get("/api/csv/files").json()] + assert names.count(name) == 1 + + def test_repeated_rows_inside_a_file_load_once(self, live_api, registry_cleanup, unique_tag): + """UAT-08 Given a file with the same row twice, when imported, then it is + stored once and the summary says one duplicate was skipped.""" + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", + f"a,b\n{unique_tag},1\n{unique_tag},1\nz,2\n") + assert result["insertedRows"] == 2 and result["duplicateRowsSkipped"] == 1 + + +class TestUS04BadDataIsExplained: + """US-04 As a Migrator I want each rejected row explained, so that I can fix + the source file and re-import.""" + + def test_each_bad_row_has_row_column_value_and_reason(self, live_api, registry_cleanup, + unique_tag): + """UAT-09 Given a file with a bad date on row 2, when imported with a date + column, then the error names row 2, the column, the value and why, and + the good rows are still stored.""" + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", + f"who,joined\n{unique_tag},2024-01-01\nbob,31/12/2024\ncat,2024-02-02\n", + types=["text", "date"]) + assert result["insertedRows"] == 2 and result["failedRows"] == 1 + assert result["rowErrors"] == [{"rowNumber": 2, "column": "joined", "value": "31/12/2024", + "reason": '"31/12/2024" is not a YYYY-MM-DD date'}] + + def test_summary_counts_reconcile(self, live_api, registry_cleanup, unique_tag): + """UAT-10 Given a mixed file, when imported, then inserted + failed + + duplicates equals the total rows processed.""" + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", + f"n,t\n1,{unique_tag}\n1,{unique_tag}\nx,y\n4,z\n", types=["int8", "text"]) + assert result["totalRows"] == (result["insertedRows"] + result["failedRows"] + + result["duplicateRowsSkipped"]) == 4 + + +class TestUS05LoadIntoTeTables: + """US-05 As a T&E data owner I want to load a CSV into an existing T&E table, + so that programme data lands in the governed schema.""" + + def test_load_organisations(self, live_api, registry_cleanup, te_org_cleanup, unique_tag): + """UAT-11 Given an organisations CSV, when I load it in T&E mode, then the + rows are in te_dev.organisations and the load is listed.""" + orgs = [f"UAT_{unique_tag}_{i}" for i in range(3)] + te_org_cleanup.extend(orgs) + name = f"uat_{unique_tag}.csv" + result = _upload(live_api, registry_cleanup, name, + "name,org_type,country\n" + "\n".join(f"{o},test_unit,AU" for o in orgs) + "\n", + mode="te", targetTable="organisations") + assert result["status"] == "ok" and result["insertedRows"] == 3 + listed = next(f for f in live_api.get("/api/csv/files").json() if f["file_name"] == name) + assert listed["table_name"] == f"{settings.TE_SCHEMA}.organisations" + + def test_wrong_columns_are_rejected_with_their_names(self, live_api, registry_cleanup, + unique_tag): + """UAT-12 Given a CSV with columns the T&E table lacks, when I load it, + then nothing is inserted and the unknown columns are named.""" + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", + f"name,favourite_colour\n{unique_tag},blue\n", + mode="te", targetTable="organisations") + assert result["status"] == "error" and "favourite_colour" in result["message"] + + +class TestUS06ReviewMigratedData: + """US-06 As a Migrator I want to see what has been migrated, so that I can + verify and report on the migration.""" + + def test_migrated_files_list_shows_counts_and_columns(self, live_api, registry_cleanup, + unique_tag): + """UAT-13 Given I imported a file, when I open Migrated files, then it is + listed first with its table, column names and row count.""" + name = f"uat_{unique_tag}.csv" + result = _upload(live_api, registry_cleanup, name, _sample("inventory.csv", unique_tag)) + newest = live_api.get("/api/csv/files").json()[0] + assert newest["file_name"] == name + assert newest["column_names"] == ["sku", "description", "stock", "location"] + assert newest["row_count"] == result["insertedRows"] + + def test_first_rows_can_be_previewed(self, live_api, registry_cleanup, unique_tag): + """UAT-14 Given an imported file, when I click Preview, then I see its first + rows in file order.""" + result = _upload(live_api, registry_cleanup, f"uat_{unique_tag}.csv", + _sample("customers.csv", unique_tag)) + rows = live_api.get(f"/api/csv/tables/{result['tableName']}/rows?limit=2").json()["rows"] + assert [r["name"] for r in rows] == ["Alice Nguyen", "Brian O'Connor"] + + +class TestUS07AuditTrail: + """US-07 As an administrator I want deletions recorded, so that removed data + is traceable.""" + + def test_deleting_a_file_is_audited(self, live_api, registry_cleanup, unique_tag, + destructive_enabled): + """UAT-15 Given deletion is enabled, when I delete an imported file, then it + disappears from the list and the audit log records who/what/when.""" + name = f"uat_{unique_tag}.csv" + result = _upload(live_api, registry_cleanup, name, _sample("customers.csv", unique_tag)) + assert live_api.delete(f"/api/csv/files/{result['fileId']}").status_code == 200 + assert name not in [f["file_name"] for f in live_api.get("/api/csv/files").json()] + latest = live_api.get("/api/audit/log?limit=1").json()["entries"][0] + assert (latest["action"], latest["fileName"], latest["tableName"]) == ( + "delete", name, result["tableName"]) + + def test_deletion_is_blocked_unless_enabled(self, live_api, registry_cleanup, unique_tag, + monkeypatch): + """UAT-16 Given a default (safe) deployment, when someone tries to delete a + file, then it is refused and the data stays.""" + monkeypatch.setattr(settings, "allow_destructive", False) + name = f"uat_{unique_tag}.csv" + result = _upload(live_api, registry_cleanup, name, _sample("customers.csv", unique_tag)) + assert live_api.delete(f"/api/csv/files/{result['fileId']}").status_code == 403 + assert name in [f["file_name"] for f in live_api.get("/api/csv/files").json()] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/snapshots/basic_expected_skip.csv b/tests/unit/snapshots/basic_expected_skip.csv similarity index 100% rename from tests/snapshots/basic_expected_skip.csv rename to tests/unit/snapshots/basic_expected_skip.csv diff --git a/tests/snapshots/basic_expected_valid.csv b/tests/unit/snapshots/basic_expected_valid.csv similarity index 100% rename from tests/snapshots/basic_expected_valid.csv rename to tests/unit/snapshots/basic_expected_valid.csv diff --git a/tests/snapshots/basic_input.csv b/tests/unit/snapshots/basic_input.csv similarity index 100% rename from tests/snapshots/basic_input.csv rename to tests/unit/snapshots/basic_input.csv diff --git a/tests/test_api_unit.py b/tests/unit/test_api_unit.py similarity index 99% rename from tests/test_api_unit.py rename to tests/unit/test_api_unit.py index 79083f0..03eeb36 100644 --- a/tests/test_api_unit.py +++ b/tests/unit/test_api_unit.py @@ -140,7 +140,7 @@ def test_identifiers_are_never_string_formatted(self): import ast import pathlib - root = pathlib.Path(__file__).resolve().parents[1] / "api" + root = pathlib.Path(__file__).resolve().parents[2] / "api" offenders = [] for path in root.rglob("*.py"): tree = ast.parse(path.read_text(encoding="utf-8")) diff --git a/tests/test_ci_quality_gate.py b/tests/unit/test_ci_quality_gate.py similarity index 99% rename from tests/test_ci_quality_gate.py rename to tests/unit/test_ci_quality_gate.py index 2991b2e..1a544bc 100644 --- a/tests/test_ci_quality_gate.py +++ b/tests/unit/test_ci_quality_gate.py @@ -12,7 +12,7 @@ pytestmark = [pytest.mark.unit, pytest.mark.regression] -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] WORKFLOW = ROOT / ".github" / "workflows" / "quality-gate.yml" GAP_ANALYSIS = ROOT / "docs/GAP_ANALYSIS.md" diff --git a/tests/unit/test_config_auth.py b/tests/unit/test_config_auth.py new file mode 100644 index 0000000..9752ca1 --- /dev/null +++ b/tests/unit/test_config_auth.py @@ -0,0 +1,107 @@ +"""Unit tests for api/config.py and api/auth.py. No database, no network.""" +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +import api.config as config_module +from api.auth import require_api_key +from api.config import TE_TABLES, settings + +pytestmark = pytest.mark.unit + + +def _reload_settings(monkeypatch, **env): + """Evaluate api/config.py in a throwaway namespace with the given env vars. + + Settings reads os.environ at class-definition time, so a fresh evaluation is + needed. importlib.reload() is deliberately avoided: it would replace the + shared ``settings`` singleton that api.db / the routers already hold. + """ + for key, value in env.items(): + monkeypatch.setenv(key, value) + namespace: dict = {"__name__": "config_under_test"} + source = open(config_module.__file__, encoding="utf-8").read() + exec(compile(source, config_module.__file__, "exec"), namespace) # nosec B102 — trusted repo file + return namespace["Settings"]() + + +class TestRequireApiKey: + @pytest.fixture(autouse=True) + def _restore_key(self): + original = settings.API_KEY + yield + settings.API_KEY = original + + def test_no_key_configured_allows_request(self): + """With API_KEY unset (local dev) every request is allowed.""" + settings.API_KEY = "" + assert require_api_key("") is None + + def test_correct_key_is_accepted(self): + """A matching X-API-Key header passes.""" + settings.API_KEY = "s3cret" + assert require_api_key("s3cret") is None + + @pytest.mark.parametrize("sent", ["", "wrong", "s3cret ", "S3CRET"]) + def test_wrong_or_missing_key_is_401(self, sent): + """A missing or mismatched key raises 401.""" + settings.API_KEY = "s3cret" + with pytest.raises(HTTPException) as exc: + require_api_key(sent) + assert exc.value.status_code == 401 + + +class TestSettings: + def test_defaults(self, monkeypatch): + """Unset env vars fall back to the documented local-dev defaults.""" + for key in ("PGHOST", "PGPORT", "API_ALLOW_DESTRUCTIVE", "API_MAX_ROWS", + "API_MAX_ROW_ERRORS", "MAX_UPLOAD_BYTES", "CSV_UPLOADS_SCHEMA"): + monkeypatch.delenv(key, raising=False) + s = _reload_settings(monkeypatch) + assert s.PG_HOST == "localhost" + assert s.PG_PORT == 5432 + assert s.UPLOADS_SCHEMA == "csv_uploads" + assert s.MAX_ROWS == 100000 + assert s.MAX_ROW_ERRORS_REPORTED == 200 + assert s.MAX_UPLOAD_BYTES == 50 * 1024 * 1024 + + def test_destructive_operations_disabled_by_default(self, monkeypatch): + """BUG-040: DELETE is off unless explicitly enabled.""" + monkeypatch.delenv("API_ALLOW_DESTRUCTIVE", raising=False) + assert _reload_settings(monkeypatch).allow_destructive is False + + @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes"]) + def test_destructive_truthy_values(self, monkeypatch, value): + """1/true/yes (any case) enable destructive operations.""" + assert _reload_settings(monkeypatch, API_ALLOW_DESTRUCTIVE=value).allow_destructive + + @pytest.mark.parametrize("value", ["0", "false", "no", "enabled", ""]) + def test_destructive_other_values_stay_disabled(self, monkeypatch, value): + """Anything else keeps destructive operations disabled.""" + assert not _reload_settings(monkeypatch, API_ALLOW_DESTRUCTIVE=value).allow_destructive + + def test_cors_origins_split_on_comma(self, monkeypatch): + """CORS_ORIGINS is a comma-separated list.""" + s = _reload_settings(monkeypatch, CORS_ORIGINS="http://a.test,http://b.test") + assert s.CORS_ORIGINS == ["http://a.test", "http://b.test"] + + def test_numeric_limits_read_from_env(self, monkeypatch): + """Row and error caps are configurable via env vars.""" + s = _reload_settings(monkeypatch, API_MAX_ROWS="10", API_MAX_ROW_ERRORS="3", + API_POOL_GETCONN_TIMEOUT="1.5") + assert (s.MAX_ROWS, s.MAX_ROW_ERRORS_REPORTED, s.POOL_GETCONN_TIMEOUT) == (10, 3, 1.5) + + +class TestTeTables: + def test_dependency_order(self): + """Parents precede children so T&E loads can respect foreign keys.""" + idx = {t: i for i, t in enumerate(TE_TABLES)} + assert idx["organisations"] < idx["personnel"] + assert idx["test_programs"] < idx["test_phases"] + assert idx["requirements"] < idx["vcrm_entries"] + assert idx["test_events"] < idx["test_results"] + + def test_names_are_safe_identifiers(self): + """Every whitelisted table name is a plain lowercase identifier.""" + assert all(t.isidentifier() and t == t.lower() for t in TE_TABLES) diff --git a/tests/unit/test_csv_parse.py b/tests/unit/test_csv_parse.py new file mode 100644 index 0000000..7e93439 --- /dev/null +++ b/tests/unit/test_csv_parse.py @@ -0,0 +1,267 @@ +"""Unit tests for api/services/csv_parse.py — parser, sanitiser, type inference +and value casting. No database, no network. +""" +from __future__ import annotations + +import pytest + +from api.services.csv_parse import ( + ALLOWED_TYPES, + build_preview, + cast_value, + infer_type, + parse_csv, + pg_type_to_allowed_type, + sanitize_columns, + valid_identifier, +) + +pytestmark = pytest.mark.unit + + +# ── parse_csv ───────────────────────────────────────────────────────────────── + +class TestParseCsv: + def test_simple_rows(self): + """Comma-separated rows split into lists of strings.""" + assert parse_csv("a,b\n1,2\n") == [["a", "b"], ["1", "2"]] + + def test_no_trailing_newline(self): + """The final row is kept when the file has no trailing newline.""" + assert parse_csv("a,b\n1,2") == [["a", "b"], ["1", "2"]] + + def test_empty_input_returns_no_rows(self): + """An empty string parses to an empty list.""" + assert parse_csv("") == [] + + def test_utf8_bom_is_stripped(self): + """A leading UTF-8 BOM does not leak into the first header.""" + assert parse_csv("id,name\n1,x\n")[0] == ["id", "name"] + + def test_crlf_line_endings(self): + """Windows CRLF line endings parse the same as LF.""" + assert parse_csv("a,b\r\n1,2\r\n") == [["a", "b"], ["1", "2"]] + + def test_quoted_comma_stays_in_field(self): + """A comma inside double quotes is data, not a delimiter.""" + assert parse_csv('a,b\n"x,y",2\n')[1] == ["x,y", "2"] + + def test_escaped_double_quote(self): + """Two double quotes inside a quoted field decode to one.""" + assert parse_csv('a\n"say ""hi"""\n')[1] == ['say "hi"'] + + def test_embedded_newline_in_quoted_field(self): + """A newline inside quotes stays inside the field.""" + assert parse_csv('a,b\n"line1\nline2",2\n')[1] == ["line1\nline2", "2"] + + def test_trailing_blank_rows_are_dropped(self): + """Blank rows at the end of the file are not data rows.""" + assert parse_csv("a,b\n1,2\n\n\n") == [["a", "b"], ["1", "2"]] + + def test_empty_cells_preserved(self): + """Consecutive delimiters produce empty-string cells.""" + assert parse_csv("a,b,c\n1,,3\n")[1] == ["1", "", "3"] + + def test_unicode_content(self): + """Non-ASCII text round-trips unchanged.""" + assert parse_csv("name\n東京\n")[1] == ["東京"] + + +# ── sanitize_columns / valid_identifier ────────────────────────────────────── + +class TestSanitizeColumns: + def test_lowercases_and_replaces_invalid_chars(self): + """Headers become lowercase snake_case identifiers.""" + assert sanitize_columns(["First Name", "E-Mail"]) == ["first_name", "e_mail"] + + def test_leading_digit_gets_prefix(self): + """Identifiers may not start with a digit.""" + assert sanitize_columns(["2024 total"]) == ["col_2024_total"] + + def test_blank_header_gets_positional_name(self): + """An empty header is replaced by column_<n>.""" + assert sanitize_columns(["a", "", "c"]) == ["a", "column_2", "c"] + + def test_symbol_only_header_gets_positional_name(self): + """A header with no usable characters falls back to column_<n>.""" + assert sanitize_columns(["%%%"]) == ["column_1"] + + def test_reserved_system_column_is_renamed(self): + """_id collides with the system primary key and must be renamed.""" + out = sanitize_columns(["_id", "_row_hash", "_created_at"]) + assert "_id" not in out and "_row_hash" not in out and "_created_at" not in out + + def test_long_header_is_truncated(self): + """Names are capped at 55 chars so a numeric suffix still fits in 63.""" + assert len(sanitize_columns(["x" * 200])[0]) == 55 + + def test_duplicates_get_unique_suffixes(self): + """Repeated headers are made unique.""" + out = sanitize_columns(["id", "id", "id"]) + assert len(set(out)) == 3 + + def test_all_outputs_are_valid_identifiers(self): + """Every sanitised name passes the identifier whitelist.""" + headers = ["Name", "", "1st", "a b c", "_id", "Ünïcødé", "x" * 80, "a-b"] + assert all(valid_identifier(c) for c in sanitize_columns(headers)) + + +class TestValidIdentifier: + @pytest.mark.parametrize("name", ["a", "abc_1", "_x", "a" * 63]) + def test_accepts_safe_identifiers(self, name): + """Lowercase letters, digits and underscores up to 63 chars are valid.""" + assert valid_identifier(name) + + @pytest.mark.parametrize("name", ["", "1a", "A", "a-b", "a b", "a;drop", "a" * 64]) + def test_rejects_unsafe_identifiers(self, name): + """Uppercase, punctuation, leading digits and over-length names are rejected.""" + assert not valid_identifier(name) + + +# ── infer_type ──────────────────────────────────────────────────────────────── + +class TestInferType: + @pytest.mark.parametrize( + "values, expected", + [ + (["1", "-2", "300"], "int8"), + (["1.5", "2", "-3.25"], "numeric"), + (["true", "no", "Y"], "boolean"), + (["2024-01-31", "2023-12-01"], "date"), + (["2024-01-31T10:00:00Z", "2024-02-01 08:30:00+10:00"], "timestamptz"), + (["abc", "1"], "text"), + ([], "text"), + (["", " "], "text"), + ], + ) + def test_picks_narrowest_type(self, values, expected): + """The narrowest type that fits every non-empty value is chosen.""" + assert infer_type(values) == expected + + def test_blank_values_are_ignored(self): + """Empty cells do not widen an otherwise-integer column to text.""" + assert infer_type(["1", "", "3"]) == "int8" + + def test_impossible_date_is_not_a_date(self): + """2024-02-30 matches the pattern but is not a real date.""" + assert infer_type(["2024-02-30"]) != "date" + + def test_result_is_always_an_allowed_type(self): + """infer_type never returns a type outside ALLOWED_TYPES.""" + for sample in (["1"], ["x"], ["true"], ["2024-01-01"], ["1.1"]): + assert infer_type(sample) in ALLOWED_TYPES + + +# ── cast_value ──────────────────────────────────────────────────────────────── + +class TestCastValue: + @pytest.mark.parametrize("col_type", ALLOWED_TYPES) + def test_empty_cell_is_null_for_every_type(self, col_type): + """A blank cell casts to NULL without error.""" + assert cast_value(" ", col_type) == (True, None, None) + + def test_int8_valid(self): + """Whole numbers cast to Python int.""" + assert cast_value(" 42 ", "int8") == (True, 42, None) + + def test_int8_invalid(self): + """A decimal is rejected for an int8 column with a readable reason.""" + ok, _, reason = cast_value("4.2", "int8") + assert not ok and "whole number" in reason + + def test_numeric_keeps_string_precision(self): + """Numerics are passed as strings so no float rounding occurs.""" + assert cast_value("0.1", "numeric") == (True, "0.1", None) + + def test_numeric_invalid(self): + """Non-numeric text is rejected for a numeric column.""" + assert cast_value("1e5", "numeric")[0] is False + + @pytest.mark.parametrize("raw, expected", [("Yes", True), ("f", False), ("1", True), ("N", False)]) + def test_boolean_valid(self, raw, expected): + """Common boolean spellings are accepted case-insensitively.""" + assert cast_value(raw, "boolean") == (True, expected, None) + + def test_boolean_invalid(self): + """Unrecognised words are rejected for a boolean column.""" + assert cast_value("maybe", "boolean")[0] is False + + def test_date_valid_and_invalid(self): + """Only real YYYY-MM-DD dates are accepted.""" + assert cast_value("2024-02-29", "date") == (True, "2024-02-29", None) + assert cast_value("2023-02-29", "date")[0] is False + assert cast_value("29/02/2024", "date")[0] is False + + def test_timestamp_without_zone_defaults_to_utc(self): + """A naive timestamp is interpreted as UTC.""" + ok, val, _ = cast_value("2024-01-01T10:00:00", "timestamptz") + assert ok and val.endswith("+00:00") + + def test_timestamp_z_suffix(self): + """A trailing Z is accepted as UTC.""" + assert cast_value("2024-01-01T10:00:00Z", "timestamptz")[0] is True + + def test_timestamp_invalid(self): + """Garbage is rejected for a timestamptz column.""" + assert cast_value("yesterday", "timestamptz")[0] is False + + def test_text_is_passed_through_raw(self): + """Text values keep their original whitespace.""" + assert cast_value(" hi ", "text") == (True, " hi ", None) + + +# ── pg_type_to_allowed_type ────────────────────────────────────────────────── + +class TestPgTypeMapping: + @pytest.mark.parametrize( + "pg, expected", + [ + ("integer", "int8"), ("BIGINT", "int8"), ("double precision", "numeric"), + ("timestamp without time zone", "timestamptz"), ("character varying", "text"), + ("boolean", "boolean"), ("date", "date"), + ], + ) + def test_known_types_map(self, pg, expected): + """Postgres data_type names map onto the six client-validated types.""" + assert pg_type_to_allowed_type(pg) == expected + + @pytest.mark.parametrize("pg", ["uuid", "USER-DEFINED", "ARRAY", "jsonb", "", None]) + def test_unknown_types_return_none(self, pg): + """Types we cannot validate client-side fall back to server casting.""" + assert pg_type_to_allowed_type(pg) is None + + +# ── build_preview ───────────────────────────────────────────────────────────── + +class TestBuildPreview: + def test_empty_file(self): + """An empty file reports invalid_structure/empty.""" + assert build_preview("") == {"status": "invalid_structure", "reason": "empty"} + + def test_header_only(self): + """A header with no data rows reports header_only.""" + assert build_preview("a,b\n")["reason"] == "header_only" + + def test_blank_header_row(self): + """A blank first row reports no_columns.""" + assert build_preview(",,\n1,2,3\n")["reason"] == "no_columns" + + def test_ok_payload_shape(self): + """A valid file returns headers, columns, types, sample rows and a row count.""" + out = build_preview("Id,Joined\n1,2024-01-01\n2,2024-01-02\n") + assert out["status"] == "ok" + assert out["headers"] == ["Id", "Joined"] + assert out["columns"] == ["id", "joined"] + assert out["inferredTypes"] == ["int8", "date"] + assert out["totalRowsApprox"] == 2 + + def test_short_rows_are_padded_and_long_rows_trimmed(self): + """Sample rows always have exactly one cell per column.""" + out = build_preview("a,b\n1\n1,2,3\n") + assert out["sampleRows"] == [["1", ""], ["1", "2"]] + + def test_sample_rows_are_capped(self): + """Only the first sample_rows rows are returned.""" + text = "a\n" + "\n".join(str(i) for i in range(50)) + "\n" + out = build_preview(text, sample_rows=5) + assert len(out["sampleRows"]) == 5 and out["totalRowsApprox"] == 50 diff --git a/tests/test_csv_utilise.py b/tests/unit/test_csv_utilise.py similarity index 98% rename from tests/test_csv_utilise.py rename to tests/unit/test_csv_utilise.py index 834e23d..7aeee41 100644 --- a/tests/test_csv_utilise.py +++ b/tests/unit/test_csv_utilise.py @@ -15,7 +15,7 @@ pytestmark = pytest.mark.unit -SCRIPT = Path(__file__).resolve().parents[1] / "build" / "csv_utilise.sh" +SCRIPT = Path(__file__).resolve().parents[2] / "build" / "csv_utilise.sh" def _find_bash(): diff --git a/tests/test_csv_validator.py b/tests/unit/test_csv_validator.py similarity index 98% rename from tests/test_csv_validator.py rename to tests/unit/test_csv_validator.py index f8ad4a3..c26fee2 100644 --- a/tests/test_csv_validator.py +++ b/tests/unit/test_csv_validator.py @@ -18,7 +18,7 @@ class CsvValidatorTests(unittest.TestCase): def setUp(self): - self.validator = Path(__file__).resolve().parents[1] / "build" / "csv" / "validator.py" + self.validator = Path(__file__).resolve().parents[2] / "build" / "csv" / "validator.py" def run_validator(self, env): result = subprocess.run( diff --git a/tests/unit/test_db_pool.py b/tests/unit/test_db_pool.py new file mode 100644 index 0000000..75e8061 --- /dev/null +++ b/tests/unit/test_db_pool.py @@ -0,0 +1,228 @@ +"""Unit tests for api/db.py, the app-level error handlers in api/main.py and +the database-free guard paths of the loaders. All database access is faked. +""" +from __future__ import annotations + +import threading +from concurrent.futures import ThreadPoolExecutor +from unittest import mock + +import psycopg2 +import psycopg2.pool +import pytest +from fastapi.testclient import TestClient + +from api import db +from api.config import settings +from api.main import app +from api.services import dynamic_loader, te_loader + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def isolated_pool(): + """Swap the module-level pool/executor for test doubles, then restore.""" + saved = (db._pool, db._getconn_executor, settings.POOL_GETCONN_TIMEOUT) + yield + if db._getconn_executor is not None and db._getconn_executor is not saved[1]: + db._getconn_executor.shutdown(wait=False, cancel_futures=True) + db._pool, db._getconn_executor, settings.POOL_GETCONN_TIMEOUT = saved + + +class TestConn: + def test_raises_when_pool_not_initialised(self, isolated_pool): + """Using Conn before init_pool() fails loudly, not with AttributeError.""" + db._pool = None + with pytest.raises(RuntimeError, match="not initialised"): + with db.Conn(): + pass + + def test_returns_connection_on_success(self, isolated_pool): + """The borrowed connection is always returned to the pool.""" + conn = mock.MagicMock() + db._pool = mock.MagicMock(getconn=mock.MagicMock(return_value=conn)) + db._getconn_executor = ThreadPoolExecutor(max_workers=1) + with db.Conn() as c: + assert c is conn + db._pool.putconn.assert_called_once_with(conn) + conn.rollback.assert_not_called() + + def test_rolls_back_and_returns_connection_on_error(self, isolated_pool): + """An exception inside the block rolls back and still returns the slot.""" + conn = mock.MagicMock() + db._pool = mock.MagicMock(getconn=mock.MagicMock(return_value=conn)) + db._getconn_executor = ThreadPoolExecutor(max_workers=1) + with pytest.raises(ValueError): + with db.Conn(): + raise ValueError("boom") + conn.rollback.assert_called_once() + db._pool.putconn.assert_called_once_with(conn) + + +class TestBorrowWithTimeout: + def test_hung_getconn_raises_pool_error(self, isolated_pool): + """BUG-030: a blocking getconn() surfaces as PoolError after the timeout.""" + release = threading.Event() + db._pool = mock.MagicMock(getconn=mock.MagicMock(side_effect=lambda: release.wait(5))) + db._getconn_executor = ThreadPoolExecutor(max_workers=1) + settings.POOL_GETCONN_TIMEOUT = 0.1 + try: + with pytest.raises(psycopg2.pool.PoolError, match="exhausted"): + db._borrow_with_timeout() + finally: + release.set() + + def test_late_connection_is_returned_to_pool(self, isolated_pool): + """A getconn() that completes after the timeout gives its slot back.""" + release = threading.Event() + late_conn = object() + + def slow_getconn(): + release.wait(5) + return late_conn + + pool = mock.MagicMock(getconn=mock.MagicMock(side_effect=slow_getconn)) + db._pool = pool + db._getconn_executor = ThreadPoolExecutor(max_workers=1) + settings.POOL_GETCONN_TIMEOUT = 0.1 + with pytest.raises(psycopg2.pool.PoolError): + db._borrow_with_timeout() + release.set() + db._getconn_executor.shutdown(wait=True) + pool.putconn.assert_called_once_with(late_conn) + + +class TestInitPool: + def test_retries_until_postgres_is_reachable(self, isolated_pool): + """BUG-026: transient OperationalError is retried with backoff.""" + attempts = [] + + def flaky_pool(**kwargs): + attempts.append(kwargs) + if len(attempts) < 3: + raise psycopg2.OperationalError("not yet") + return mock.MagicMock() + + with mock.patch.object(db.psycopg2.pool, "ThreadedConnectionPool", side_effect=flaky_pool), \ + mock.patch.object(db.time, "sleep") as sleep: + db.init_pool(max_attempts=5, base_delay=1.0) + assert len(attempts) == 3 + assert [c.args[0] for c in sleep.call_args_list] == [1.0, 2.0] + assert attempts[-1]["maxconn"] == db._MAXCONN + + def test_backoff_is_capped_at_ten_seconds(self, isolated_pool): + """Exponential backoff never sleeps longer than 10s per attempt.""" + with mock.patch.object(db.psycopg2.pool, "ThreadedConnectionPool", + side_effect=psycopg2.OperationalError("down")), \ + mock.patch.object(db.time, "sleep") as sleep: + with pytest.raises(psycopg2.OperationalError): + db.init_pool(max_attempts=6, base_delay=1.0) + assert max(c.args[0] for c in sleep.call_args_list) == 10.0 + + def test_gives_up_after_max_attempts(self, isolated_pool): + """The last OperationalError is re-raised once attempts run out.""" + with mock.patch.object(db.psycopg2.pool, "ThreadedConnectionPool", + side_effect=psycopg2.OperationalError("down")) as ctor, \ + mock.patch.object(db.time, "sleep"): + with pytest.raises(psycopg2.OperationalError, match="down"): + db.init_pool(max_attempts=3) + assert ctor.call_count == 3 + + +class TestBootstrap: + def test_falls_back_when_alembic_ini_missing(self, tmp_path): + """Without alembic.ini the hand-written CREATE IF NOT EXISTS path runs.""" + with mock.patch.object(db, "Path") as path_cls, \ + mock.patch.object(db, "_bootstrap_fallback_schema") as fallback: + path_cls.return_value.resolve.return_value.parent.parent = tmp_path + db.bootstrap() + fallback.assert_called_once() + + +class TestAppErrorHandling: + def test_pool_exhaustion_maps_to_503(self): + """A PoolError anywhere in a handler becomes HTTP 503, not 500.""" + client = TestClient(app) + with mock.patch("api.routers.csv_routes.Conn", + side_effect=psycopg2.pool.PoolError("exhausted")): + r = client.get("/api/csv/files") + assert r.status_code == 503 + assert "retry" in r.json()["detail"].lower() + + def test_health_degrades_without_leaking_details(self, isolated_pool): + """When the DB is unreachable /api/health returns 200 'degraded' with + no host/user/driver text in the body.""" + db._pool = None + r = TestClient(app).get("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/api/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "degraded" + assert settings.PG_HOST not in str(body) and "pool" not in str(body).lower() + + def test_preview_parse_crash_returns_structured_error(self): + """BUG-025: a parser exception never surfaces as a raw 500.""" + with mock.patch("api.routers.csv_routes.build_preview", side_effect=RecursionError("x")): + r = TestClient(app).post("/api/csv/preview", json={"fileName": "a.csv", "content": "a\n1\n"}) + assert r.status_code == 200 + assert r.json()["status"] == "invalid_structure" + + def test_preview_te_match_is_best_effort(self, isolated_pool): + """If the T&E lookup fails the preview still succeeds with a null match.""" + db._pool = None # te_table_columns() will raise RuntimeError + r = TestClient(app).post("/api/csv/preview", json={"fileName": "a.csv", "content": "a\n1\n"}) + assert r.json()["status"] == "ok" + assert r.json()["teTableMatch"] is None + + def test_cors_methods_are_restricted(self): + """Only GET/POST/DELETE are advertised to browsers.""" + origin = settings.CORS_ORIGINS[0] + r = TestClient(app).options("/api/csv/files", headers={ + "Origin": origin, "Access-Control-Request-Method": "PUT"}) + assert "PUT" not in r.headers.get("access-control-allow-methods", "") + + +class TestLoaderGuardsWithoutDatabase: + """Structural rejections happen before any database access.""" + + @pytest.fixture(autouse=True) + def _no_db(self, isolated_pool): + db._pool = None + + @pytest.mark.parametrize( + "content, reason", + [("", "empty"), ("a,b\n", "header_only"), (",\n1,2\n", "no_columns")], + ) + def test_dynamic_structure_errors(self, content, reason): + """Empty, header-only and blank-header files are rejected with a message.""" + out = dynamic_loader.upload_dynamic("f.csv", content, None, False) + assert out["status"] == "invalid_structure" + assert out["reason"] == reason and out["message"] + + def test_dynamic_row_cap(self, monkeypatch): + """BUG-027: files above MAX_ROWS are rejected up-front.""" + monkeypatch.setattr(settings, "MAX_ROWS", 3) + out = dynamic_loader.upload_dynamic("f.csv", "a\n1\n2\n3\n4\n", None, False) + assert out["status"] == "error" and "at most 3" in out["message"] + + def test_dynamic_logs_have_frontend_shape(self): + """Every processing-log entry carries ts/step/level/message.""" + out = dynamic_loader.upload_dynamic("f.csv", "", None, False) + assert out["logs"] and all({"ts", "step", "level", "message"} <= set(e) for e in out["logs"]) + + def test_te_rejects_unknown_target_table(self): + """Only the 12 whitelisted T&E tables are accepted.""" + out = te_loader.upload_te("f.csv", "a\n1\n", "pg_authid") + assert out["status"] == "error" and "not a T&E table" in out["message"] + + @pytest.mark.parametrize("content, reason", [("", "empty"), ("name\n", "header_only")]) + def test_te_structure_errors(self, content, reason): + """T&E mode needs a header and at least one data row.""" + out = te_loader.upload_te("f.csv", content, "organisations") + assert out["status"] == "invalid_structure" and out["reason"] == reason + + def test_te_row_cap(self, monkeypatch): + """BUG-027: the row cap also applies in T&E mode.""" + monkeypatch.setattr(settings, "MAX_ROWS", 1) + out = te_loader.upload_te("f.csv", "name\na\nb\n", "organisations") + assert out["status"] == "error" and "at most 1" in out["message"] diff --git a/tests/test_evals_runner.py b/tests/unit/test_evals_runner.py similarity index 98% rename from tests/test_evals_runner.py rename to tests/unit/test_evals_runner.py index 8ed9840..b37aa8c 100644 --- a/tests/test_evals_runner.py +++ b/tests/unit/test_evals_runner.py @@ -17,7 +17,7 @@ pytestmark = pytest.mark.unit -PROJECT_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = Path(__file__).resolve().parents[2] RUNNER_PATH = PROJECT_ROOT / "evals" / "runner.py" spec = importlib.util.spec_from_file_location("evals_runner", RUNNER_PATH) diff --git a/tests/test_regression.py b/tests/unit/test_regression.py similarity index 99% rename from tests/test_regression.py rename to tests/unit/test_regression.py index 536772f..814a0c1 100644 --- a/tests/test_regression.py +++ b/tests/unit/test_regression.py @@ -15,7 +15,7 @@ pytestmark = [pytest.mark.unit, pytest.mark.regression] -VALIDATOR = Path(__file__).resolve().parents[1] / "build" / "csv" / "validator.py" +VALIDATOR = Path(__file__).resolve().parents[2] / "build" / "csv" / "validator.py" def _run(env: dict) -> subprocess.CompletedProcess: diff --git a/tests/test_security.py b/tests/unit/test_security.py similarity index 99% rename from tests/test_security.py rename to tests/unit/test_security.py index cbd8cb6..286c2bd 100644 --- a/tests/test_security.py +++ b/tests/unit/test_security.py @@ -14,7 +14,7 @@ pytestmark = [pytest.mark.unit, pytest.mark.security] -ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] # ── Python source files to scan ─────────────────────────────────────────────── PY_SOURCE = [ diff --git a/tests/test_service_loader.py b/tests/unit/test_service_loader.py similarity index 100% rename from tests/test_service_loader.py rename to tests/unit/test_service_loader.py diff --git a/tests/test_snapshot.py b/tests/unit/test_snapshot.py similarity index 94% rename from tests/test_snapshot.py rename to tests/unit/test_snapshot.py index 805b551..eae1ee4 100644 --- a/tests/test_snapshot.py +++ b/tests/unit/test_snapshot.py @@ -4,8 +4,8 @@ file. On subsequent runs it compares the actual output against the stored file and fails if they differ. - UPDATE_SNAPSHOTS=1 pytest -m snapshot tests/test_snapshot.py # regenerate - pytest -m snapshot tests/test_snapshot.py # compare + UPDATE_SNAPSHOTS=1 pytest -m snapshot tests/unit/test_snapshot.py # regenerate + pytest -m snapshot tests/unit/test_snapshot.py # compare """ import os import subprocess @@ -18,7 +18,7 @@ pytestmark = [pytest.mark.unit, pytest.mark.snapshot] -VALIDATOR = Path(__file__).resolve().parents[1] / "build" / "csv" / "validator.py" +VALIDATOR = Path(__file__).resolve().parents[2] / "build" / "csv" / "validator.py" SNAPSHOTS_DIR = Path(__file__).resolve().parent / "snapshots" UPDATE = os.environ.get("UPDATE_SNAPSHOTS", "").lower() in ("1", "true", "yes") From 15268aeb90796fa20d060961355e4876fd67c821 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 10 Oct 2026 06:23:20 +0000 Subject: [PATCH 2/4] fix: accept preview parse_failed in the UI schema; allow the Vite port in CORS - csvPreviewResultSchema now accepts reason "parse_failed" (plus its message), which /api/csv/preview returns when the parser throws (BUG-025 handler). Previously the zod parse failed and the upload flow silently dropped the T&E table suggestion. - Default CORS_ORIGINS now includes http://localhost:8080, the port the Vite dev server actually listens on; 5173/3000 are kept. Docs, env.dev.example and start-frontend.ps1 updated to match. - Tests: SIT-17/18 check every preview invalid_structure reason the API can return (including a simulated parser crash) is in the UI's preview enum; unit tests check the default origins and a preflight from :8080. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CS4GeHuWThWvU74AkBL6E --- README.md | 2 +- api/config.py | 6 +- csv-table-hub-main/src/lib/csv.functions.ts | 5 +- docs/ENV_VARIABLES.md | 4 +- docs/TEST_CASES.md | 313 +++++++++--------- env.dev.example | 2 +- scripts/start-frontend.ps1 | 4 +- .../test_sit_frontend_contract.py | 27 +- tests/unit/test_config_auth.py | 4 +- tests/unit/test_db_pool.py | 8 + 10 files changed, 207 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 855de35..246efd4 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ See **`docs/ENV_VARIABLES.md`** for the full reference. Key variables: | `PGPASSWORD` | _(empty)_ | PostgreSQL password | | `PGDATABASE` | `te_mgmt_dev` | Target database | | `API_KEY` | _(empty)_ | Shared secret for `X-API-Key` header (empty = unauthenticated) | -| `CORS_ORIGINS` | `http://localhost:5173,...` | Allowed frontend origins | +| `CORS_ORIGINS` | `http://localhost:8080,...` | Allowed frontend origins | | `TE_SCHEMA` | `te_dev` | Schema for the 12 T&E tables | | `API_ALLOW_DESTRUCTIVE` | `false` | Enable `DELETE /api/csv/files/{id}` | diff --git a/api/config.py b/api/config.py index 0e07680..009acff 100644 --- a/api/config.py +++ b/api/config.py @@ -16,9 +16,11 @@ class Settings: # Schema holding the fixed T&E tables (deploy_all.sh dev creates te_dev). TE_SCHEMA: str = os.environ.get("TE_SCHEMA", "te_dev") - # CORS origins for the frontend dev server. + # CORS origins for the frontend dev server. 8080 is the port the Vite dev + # server (scripts/start-frontend.ps1) actually listens on; 5173/3000 are + # kept for other local setups. CORS_ORIGINS: list = os.environ.get( - "CORS_ORIGINS", "http://localhost:5173,http://localhost:3000" + "CORS_ORIGINS", "http://localhost:8080,http://localhost:5173,http://localhost:3000" ).split(",") MAX_UPLOAD_BYTES: int = int(os.environ.get("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024))) diff --git a/csv-table-hub-main/src/lib/csv.functions.ts b/csv-table-hub-main/src/lib/csv.functions.ts index a638181..365b4ce 100644 --- a/csv-table-hub-main/src/lib/csv.functions.ts +++ b/csv-table-hub-main/src/lib/csv.functions.ts @@ -175,7 +175,10 @@ const csvPreviewResultSchema = z.discriminatedUnion("status", [ }), z.object({ status: z.literal("invalid_structure"), - reason: z.enum(["empty", "header_only", "no_columns"]), + // "parse_failed" is returned (with a message) when the server-side parser + // throws — see the BUG-025 handler in api/routers/csv_routes.py. + reason: z.enum(["empty", "header_only", "no_columns", "parse_failed"]), + message: z.string().optional(), }), ]); export type CsvPreviewResult = z.infer<typeof csvPreviewResultSchema>; diff --git a/docs/ENV_VARIABLES.md b/docs/ENV_VARIABLES.md index 45d86e7..aa6f9dd 100644 --- a/docs/ENV_VARIABLES.md +++ b/docs/ENV_VARIABLES.md @@ -37,7 +37,7 @@ Read by `api/config.py`. Set these in the shell before running | `API_MAX_ROWS` | `100000` | No | Maximum data rows accepted per upload. Files over this limit are rejected before any per-row work. | | `API_MAX_ROW_ERRORS` | `200` | No | Maximum row errors included in the JSON response body. Summary counts still reflect the true total. | | `API_POOL_GETCONN_TIMEOUT` | `5.0` | No | Seconds a request waits to borrow a database connection from the pool. Exceeded = 503 response. | -| `CORS_ORIGINS` | `http://localhost:5173,http://localhost:3000` | Yes | Comma-separated list of allowed CORS origins. Set to your frontend URL in production. | +| `CORS_ORIGINS` | `http://localhost:8080,http://localhost:5173,http://localhost:3000` | Yes | Comma-separated list of allowed CORS origins. Set to your frontend URL in production. | | `CSV_UPLOADS_SCHEMA` | `csv_uploads` | No | Schema where per-CSV tables and the file registry live. | | `TE_SCHEMA` | `te_dev` | No | Schema holding the 12 fixed T&E tables. Switch to `te_test`, `te_staging`, or `te_prod` per environment. | | `MAX_UPLOAD_BYTES` | `52428800` (50 MB) | No | Maximum file size accepted by the upload endpoint. | @@ -134,7 +134,7 @@ Change them by editing the source file directly. | `5.0` | `api/config.py` | Default pool connection timeout (seconds) | | `52428800` | `api/config.py` | Default max upload size (50 MB) | | `8000` | `scripts/start-api.ps1` | API port | -| `5173` | `api/config.py` (CORS default) | Frontend dev server port | +| `8080` | `api/config.py` (CORS default) | Frontend dev server port (also allows `5173`, `3000`) | | `organisations, personnel, …` | `api/config.py` (TE_TABLES) | The 12 fixed T&E table names | --- diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md index 22d998a..ee63e9f 100644 --- a/docs/TEST_CASES.md +++ b/docs/TEST_CASES.md @@ -13,9 +13,9 @@ the behaviour asserted, and therefore the expected result. | Category | Folder | Tests | Cases | |----------|--------|-------|-------| -| [Unit tests](#unit-tests) | `tests/unit/` | 236 | 279 | +| [Unit tests](#unit-tests) | `tests/unit/` | 237 | 280 | | [System tests](#system-tests) | `tests/system/` | 68 | 70 | -| [System integration tests](#system-integration-tests) | `tests/system_integration/` | 61 | 200 | +| [System integration tests](#system-integration-tests) | `tests/system_integration/` | 63 | 204 | | [Performance tests](#performance-tests) | `tests/performance/` | 13 | 16 | | [Accessibility tests](#accessibility-tests) | `tests/accessibility/` | 14 | 17 | | [User acceptance tests](#user-acceptance-tests) | `tests/uat/` | 31 | 33 | @@ -132,136 +132,137 @@ Single functions and modules in isolation. No database, no network. | UT-104 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_health_degrades_without_leaking_details` | When the DB is unreachable /api/health returns 200 'degraded' with no host/user/driver text in the body. | 1 | | UT-105 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_preview_parse_crash_returns_structured_error` | BUG-025: a parser exception never surfaces as a raw 500. | 1 | | UT-106 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_preview_te_match_is_best_effort` | If the T&E lookup fails the preview still succeeds with a null match. | 1 | -| UT-107 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_methods_are_restricted` | Only GET/POST/DELETE are advertised to browsers. | 1 | -| UT-108 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_structure_errors` | Empty, header-only and blank-header files are rejected with a message. | 3 | -| UT-109 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_row_cap` | BUG-027: files above MAX\_ROWS are rejected up-front. | 1 | -| UT-110 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_logs_have_frontend_shape` | Every processing-log entry carries ts/step/level/message. | 1 | -| UT-111 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_rejects_unknown_target_table` | Only the 12 whitelisted T&E tables are accepted. | 1 | -| UT-112 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_structure_errors` | T&E mode needs a header and at least one data row. | 2 | -| UT-113 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_row_cap` | BUG-027: the row cap also applies in T&E mode. | 1 | -| UT-114 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_load_expected_returns_none_for_missing_scenario` | \_load\_expected with an unknown scenario name should return None without raising. | 1 | -| UT-115 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_discover_scenarios_filters_only_requested_name` | discover\_scenarios with a name argument should return only the directory whose name matches exactly. | 1 | -| UT-116 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_reports_unknown_runner_action_as_failure` | An expected JSON with an unrecognised runner\_action should produce a failed result containing 'Unknown runner\_action' in errors. | 1 | -| UT-117 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_generated_invalid_utf8_skips_row_cleanly` | The 23\_invalid\_utf8\_bytes eval scenario: an invalid-UTF-8 data row is skipped with a reason while valid rows still pass (exit\_code=0), per the loader's load-what-is-valid contract. No raw Traceback may appear. | 1 | -| UT-118 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_i_fails_when_postgresql_is_unavailable` | When \_can\_connect\_pg returns False, run\_tier\_i\_scenario must FAIL (not skip): an unavailable prerequisite is a failure, so a green run always means the scenario really executed. | 1 | -| UT-119 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_count_dev_rows_records_none_for_failed_count_query` | When the count query subprocess exits non-zero, \_count\_dev\_rows should record None for every entry in DEV\_SEED\_TABLES. | 1 | -| UT-120 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_have_psql_uses_path_lookup` | When shutil.which returns None, \_have\_psql should return False. | 1 | -| UT-121 | `tests/unit/test_regression.py` | `RegressionBomHandling.test_bom_csv_is_accepted` | Bom csv is accepted | 1 | -| UT-122 | `tests/unit/test_regression.py` | `RegressionCrlfLineEndings.test_crlf_csv_accepted` | Crlf csv accepted | 1 | -| UT-123 | `tests/unit/test_regression.py` | `RegressionHeaderOnlyCsv.test_header_only_exits_cleanly` | Header only exits cleanly | 1 | -| UT-124 | `tests/unit/test_regression.py` | `RegressionSingleColumnCsv.test_single_column_valid_rows_pass` | Single column valid rows pass | 1 | -| UT-125 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_missing_env_vars_no_traceback` | Missing env vars no traceback | 1 | -| UT-126 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_nonexistent_file_no_traceback` | Nonexistent file no traceback | 1 | -| UT-127 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_invalid_utf8_no_traceback` | Invalid utf8 no traceback | 1 | -| UT-128 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_validator_no_hardcoded_credentials` | Validator no hardcoded credentials | 1 | -| UT-129 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_runner_no_hardcoded_credentials` | Runner no hardcoded credentials | 1 | -| UT-130 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_gap_report_no_hardcoded_credentials` | Gap report no hardcoded credentials | 1 | -| UT-131 | `tests/unit/test_security.py` | `TestNoFStringSql.test_validator_no_fstring_sql` | Validator no fstring sql | 1 | -| UT-132 | `tests/unit/test_security.py` | `TestNoFStringSql.test_runner_no_fstring_sql` | Runner no fstring sql | 1 | -| UT-133 | `tests/unit/test_security.py` | `TestConfigEnvExample.test_pg_password_is_empty` | Pg password is empty | 1 | -| UT-134 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_adapters` | No echo password in adapters | 1 | -| UT-135 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_loaders` | No echo password in loaders | 1 | -| UT-136 | `tests/unit/test_security.py` | `TestSqlFilesNoInlineCredentials.test_env_sql_files_no_hardcoded_passwords` | Env sql files no hardcoded passwords | 1 | -| UT-137 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_dynamic_upload_rejects_mismatched_type_list_length` | Dynamic upload rejects mismatched type list length | 1 | -| UT-138 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_te_cleanup_prior_registry_entries_drops_dynamic_table` | Te cleanup prior registry entries drops dynamic table | 1 | -| UT-139 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_valid_csv_matches_snapshot` | Valid csv matches snapshot | 1 | -| UT-140 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_skip_csv_matches_snapshot` | Skip csv matches snapshot | 1 | -| UT-141 | `tests/unit/test_snapshot.py` | `SnapshotAllValidInput.test_all_valid_no_skips` | All valid no skips | 1 | -| UT-142 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns all jobs when search is empty | 1 | -| UT-143 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | filters by exact substring | 1 | -| UT-144 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | is case-insensitive | 1 | -| UT-145 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty array when nothing matches | 1 | -| UT-146 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | matches partial filenames | 1 | -| UT-147 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'all' returns every job | 1 | -| UT-148 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'done' returns only done jobs (including overwritten) | 1 | -| UT-149 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'overwritten' returns only done+overwritten jobs | 1 | -| UT-150 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'error' returns only failed jobs | 1 | -| UT-151 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'duplicate' returns only duplicate jobs | 1 | -| UT-152 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'cancelled' returns only cancelled jobs | 1 | -| UT-153 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | non-overwritten done job does NOT appear under 'overwritten' | 1 | -| UT-154 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom excludes jobs before the date | 1 | -| UT-155 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateTo includes jobs on the specified day; jobs strictly after it are excluded | 1 | -| UT-156 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom + dateTo together form a closed range | 1 | -| UT-157 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | empty date strings do not filter by date | 1 | -| UT-158 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + status combined narrow correctly | 1 | -| UT-159 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + date + status combined to a single result | 1 | -| UT-160 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty when search and status conflict | 1 | -| UT-161 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | lowercases and trims headers | 1 | -| UT-162 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | replaces non-alphanumeric chars with underscores | 1 | -| UT-163 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips leading and trailing underscores | 1 | -| UT-164 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | prefixes numeric-starting names with col\_ | 1 | -| UT-165 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | generates a name for empty/blank headers | 1 | -| UT-166 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | deduplicates by appending \_2, \_3, … | 1 | -| UT-167 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reserved system column names get a \_col suffix instead of colliding | 1 | -| UT-168 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | truncates names longer than 55 characters | 1 | -| UT-169 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles a mix of edge cases in one call | 1 | -| UT-170 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns headers and one sample row | 1 | -| UT-171 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips the BOM character | 1 | -| UT-172 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles quoted fields with embedded commas | 1 | -| UT-173 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reports zero data rows for a header-only file | 1 | -| UT-174 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns empty arrays for a completely empty file | 1 | -| UT-175 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers int8 for whole-number columns | 1 | -| UT-176 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers numeric for decimal columns | 1 | -| UT-177 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers boolean for true/false columns | 1 | -| UT-178 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers date for YYYY-MM-DD columns | 1 | -| UT-179 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | falls back to text when column has mixed types | 1 | -| UT-180 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers text for a column with all empty cells | 1 | -| UT-181 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | ignores empty cells when inferring type | 1 | -| UT-182 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | has a human label for every inferred type | 1 | -| UT-183 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for undefined input | 1 | -| UT-184 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for an empty mapping | 1 | -| UT-185 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when every column keeps its original name | 1 | -| UT-186 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when renamed is empty string | 1 | -| UT-187 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | lists renamed columns as 'original→renamed' | 1 | -| UT-188 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | omits columns that are unchanged | 1 | -| UT-189 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns one row per RowError | 1 | -| UT-190 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | maps rowNumber, column, value, reason correctly | 1 | -| UT-191 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes the file name in every row | 1 | -| UT-192 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes a mapping summary when mapping is provided | 1 | -| UT-193 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | when rowErrors is empty but errorMessage is set, emits one structural-error row | 1 | -| UT-194 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty array when both rowErrors and errorMessage are absent | 1 | -| UT-195 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .csv filename | 1 | -| UT-196 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the header row | 1 | -| UT-197 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the error row values | 1 | -| UT-198 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | escapes commas and quotes in values | 1 | -| UT-199 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .xlsx filename | 1 | -| UT-200 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX content is a valid workbook with an Errors sheet | 1 | -| UT-201 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: triggers download with audit-report filename | 1 | -| UT-202 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX: workbook has an Audit sheet | 1 | -| UT-203 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: includes header and data row | 1 | -| UT-204 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok -> done, success toast, notify, and invalidateRoute | 1 | -| UT-205 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok with overwritten and failedRows composes the full message | 1 | -| UT-206 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status duplicate\_file -> duplicate patch, warning toast, no invalidateRoute | 1 | -| UT-207 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status invalid\_structure -> error patch carrying invalidReason | 1 | -| UT-208 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status error -> error patch carrying rowErrors | 1 | -| UT-209 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | passes the header mapping through to the patch on every branch | 1 | -| UT-210 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | uses the error's message and stack, and never sets \`notify\` | 1 | -| UT-211 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | falls back to 'Unknown error' when the thrown value has no message | 1 | -| UT-212 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | defaults the cancellation reason when none is given | 1 | -| UT-213 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | carries an explicit reason through to both the patch and the toast | 1 | -| UT-214 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | shows the diff dialog when the job already has a tableName | 1 | -| UT-215 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly with overwrite when there is no tableName yet | 1 | -| UT-216 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly when the job is undefined (id no longer in the list) | 1 | -| UT-217 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns an empty array when no templates are stored | 1 | -| UT-218 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns stored templates | 1 | -| UT-219 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns [] when localStorage contains invalid JSON | 1 | -| UT-220 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | persists a new template | 1 | -| UT-221 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | replaces an existing template with the same id | 1 | -| UT-222 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | prepends the newest template so it appears first | 1 | -| UT-223 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | removes the template with the matching id | 1 | -| UT-224 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | is a no-op when the id does not exist | 1 | -| UT-225 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when there are no templates | 1 | -| UT-226 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns the template whose column originals match in order | 1 | -| UT-227 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the header set differs | 1 | -| UT-228 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the order differs | 1 | -| UT-229 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | sets lastUsedAt on the targeted template | 1 | -| UT-230 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | does not affect other templates | 1 | -| UT-231 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a successful upload ends in status done and invalidates the router | 1 | -| UT-232 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a duplicate\_file response is classified as a duplicate, not an error | 1 | -| UT-233 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | an invalid\_structure response is classified as an error | 1 | -| UT-234 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a rejected upload promise is classified as an error with the thrown message | 1 | -| UT-235 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | clicking Stop while uploading marks the job cancelled and ignores the late result | 1 | -| UT-236 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | overwrite flow: confirming the diff dialog re-uploads with overwrite=true | 1 | +| UT-107 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_allows_frontend_dev_server_by_default` | The Vite dev server origin (localhost, port 8080) is allowed by default. | 1 | +| UT-108 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_methods_are_restricted` | Only GET/POST/DELETE are advertised to browsers. | 1 | +| UT-109 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_structure_errors` | Empty, header-only and blank-header files are rejected with a message. | 3 | +| UT-110 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_row_cap` | BUG-027: files above MAX\_ROWS are rejected up-front. | 1 | +| UT-111 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_logs_have_frontend_shape` | Every processing-log entry carries ts/step/level/message. | 1 | +| UT-112 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_rejects_unknown_target_table` | Only the 12 whitelisted T&E tables are accepted. | 1 | +| UT-113 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_structure_errors` | T&E mode needs a header and at least one data row. | 2 | +| UT-114 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_row_cap` | BUG-027: the row cap also applies in T&E mode. | 1 | +| UT-115 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_load_expected_returns_none_for_missing_scenario` | \_load\_expected with an unknown scenario name should return None without raising. | 1 | +| UT-116 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_discover_scenarios_filters_only_requested_name` | discover\_scenarios with a name argument should return only the directory whose name matches exactly. | 1 | +| UT-117 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_reports_unknown_runner_action_as_failure` | An expected JSON with an unrecognised runner\_action should produce a failed result containing 'Unknown runner\_action' in errors. | 1 | +| UT-118 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_generated_invalid_utf8_skips_row_cleanly` | The 23\_invalid\_utf8\_bytes eval scenario: an invalid-UTF-8 data row is skipped with a reason while valid rows still pass (exit\_code=0), per the loader's load-what-is-valid contract. No raw Traceback may appear. | 1 | +| UT-119 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_i_fails_when_postgresql_is_unavailable` | When \_can\_connect\_pg returns False, run\_tier\_i\_scenario must FAIL (not skip): an unavailable prerequisite is a failure, so a green run always means the scenario really executed. | 1 | +| UT-120 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_count_dev_rows_records_none_for_failed_count_query` | When the count query subprocess exits non-zero, \_count\_dev\_rows should record None for every entry in DEV\_SEED\_TABLES. | 1 | +| UT-121 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_have_psql_uses_path_lookup` | When shutil.which returns None, \_have\_psql should return False. | 1 | +| UT-122 | `tests/unit/test_regression.py` | `RegressionBomHandling.test_bom_csv_is_accepted` | Bom csv is accepted | 1 | +| UT-123 | `tests/unit/test_regression.py` | `RegressionCrlfLineEndings.test_crlf_csv_accepted` | Crlf csv accepted | 1 | +| UT-124 | `tests/unit/test_regression.py` | `RegressionHeaderOnlyCsv.test_header_only_exits_cleanly` | Header only exits cleanly | 1 | +| UT-125 | `tests/unit/test_regression.py` | `RegressionSingleColumnCsv.test_single_column_valid_rows_pass` | Single column valid rows pass | 1 | +| UT-126 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_missing_env_vars_no_traceback` | Missing env vars no traceback | 1 | +| UT-127 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_nonexistent_file_no_traceback` | Nonexistent file no traceback | 1 | +| UT-128 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_invalid_utf8_no_traceback` | Invalid utf8 no traceback | 1 | +| UT-129 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_validator_no_hardcoded_credentials` | Validator no hardcoded credentials | 1 | +| UT-130 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_runner_no_hardcoded_credentials` | Runner no hardcoded credentials | 1 | +| UT-131 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_gap_report_no_hardcoded_credentials` | Gap report no hardcoded credentials | 1 | +| UT-132 | `tests/unit/test_security.py` | `TestNoFStringSql.test_validator_no_fstring_sql` | Validator no fstring sql | 1 | +| UT-133 | `tests/unit/test_security.py` | `TestNoFStringSql.test_runner_no_fstring_sql` | Runner no fstring sql | 1 | +| UT-134 | `tests/unit/test_security.py` | `TestConfigEnvExample.test_pg_password_is_empty` | Pg password is empty | 1 | +| UT-135 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_adapters` | No echo password in adapters | 1 | +| UT-136 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_loaders` | No echo password in loaders | 1 | +| UT-137 | `tests/unit/test_security.py` | `TestSqlFilesNoInlineCredentials.test_env_sql_files_no_hardcoded_passwords` | Env sql files no hardcoded passwords | 1 | +| UT-138 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_dynamic_upload_rejects_mismatched_type_list_length` | Dynamic upload rejects mismatched type list length | 1 | +| UT-139 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_te_cleanup_prior_registry_entries_drops_dynamic_table` | Te cleanup prior registry entries drops dynamic table | 1 | +| UT-140 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_valid_csv_matches_snapshot` | Valid csv matches snapshot | 1 | +| UT-141 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_skip_csv_matches_snapshot` | Skip csv matches snapshot | 1 | +| UT-142 | `tests/unit/test_snapshot.py` | `SnapshotAllValidInput.test_all_valid_no_skips` | All valid no skips | 1 | +| UT-143 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns all jobs when search is empty | 1 | +| UT-144 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | filters by exact substring | 1 | +| UT-145 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | is case-insensitive | 1 | +| UT-146 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty array when nothing matches | 1 | +| UT-147 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | matches partial filenames | 1 | +| UT-148 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'all' returns every job | 1 | +| UT-149 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'done' returns only done jobs (including overwritten) | 1 | +| UT-150 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'overwritten' returns only done+overwritten jobs | 1 | +| UT-151 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'error' returns only failed jobs | 1 | +| UT-152 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'duplicate' returns only duplicate jobs | 1 | +| UT-153 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'cancelled' returns only cancelled jobs | 1 | +| UT-154 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | non-overwritten done job does NOT appear under 'overwritten' | 1 | +| UT-155 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom excludes jobs before the date | 1 | +| UT-156 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateTo includes jobs on the specified day; jobs strictly after it are excluded | 1 | +| UT-157 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom + dateTo together form a closed range | 1 | +| UT-158 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | empty date strings do not filter by date | 1 | +| UT-159 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + status combined narrow correctly | 1 | +| UT-160 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + date + status combined to a single result | 1 | +| UT-161 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty when search and status conflict | 1 | +| UT-162 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | lowercases and trims headers | 1 | +| UT-163 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | replaces non-alphanumeric chars with underscores | 1 | +| UT-164 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips leading and trailing underscores | 1 | +| UT-165 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | prefixes numeric-starting names with col\_ | 1 | +| UT-166 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | generates a name for empty/blank headers | 1 | +| UT-167 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | deduplicates by appending \_2, \_3, … | 1 | +| UT-168 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reserved system column names get a \_col suffix instead of colliding | 1 | +| UT-169 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | truncates names longer than 55 characters | 1 | +| UT-170 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles a mix of edge cases in one call | 1 | +| UT-171 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns headers and one sample row | 1 | +| UT-172 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips the BOM character | 1 | +| UT-173 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles quoted fields with embedded commas | 1 | +| UT-174 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reports zero data rows for a header-only file | 1 | +| UT-175 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns empty arrays for a completely empty file | 1 | +| UT-176 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers int8 for whole-number columns | 1 | +| UT-177 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers numeric for decimal columns | 1 | +| UT-178 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers boolean for true/false columns | 1 | +| UT-179 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers date for YYYY-MM-DD columns | 1 | +| UT-180 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | falls back to text when column has mixed types | 1 | +| UT-181 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers text for a column with all empty cells | 1 | +| UT-182 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | ignores empty cells when inferring type | 1 | +| UT-183 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | has a human label for every inferred type | 1 | +| UT-184 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for undefined input | 1 | +| UT-185 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for an empty mapping | 1 | +| UT-186 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when every column keeps its original name | 1 | +| UT-187 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when renamed is empty string | 1 | +| UT-188 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | lists renamed columns as 'original→renamed' | 1 | +| UT-189 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | omits columns that are unchanged | 1 | +| UT-190 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns one row per RowError | 1 | +| UT-191 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | maps rowNumber, column, value, reason correctly | 1 | +| UT-192 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes the file name in every row | 1 | +| UT-193 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes a mapping summary when mapping is provided | 1 | +| UT-194 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | when rowErrors is empty but errorMessage is set, emits one structural-error row | 1 | +| UT-195 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty array when both rowErrors and errorMessage are absent | 1 | +| UT-196 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .csv filename | 1 | +| UT-197 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the header row | 1 | +| UT-198 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the error row values | 1 | +| UT-199 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | escapes commas and quotes in values | 1 | +| UT-200 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .xlsx filename | 1 | +| UT-201 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX content is a valid workbook with an Errors sheet | 1 | +| UT-202 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: triggers download with audit-report filename | 1 | +| UT-203 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX: workbook has an Audit sheet | 1 | +| UT-204 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: includes header and data row | 1 | +| UT-205 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok -> done, success toast, notify, and invalidateRoute | 1 | +| UT-206 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok with overwritten and failedRows composes the full message | 1 | +| UT-207 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status duplicate\_file -> duplicate patch, warning toast, no invalidateRoute | 1 | +| UT-208 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status invalid\_structure -> error patch carrying invalidReason | 1 | +| UT-209 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status error -> error patch carrying rowErrors | 1 | +| UT-210 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | passes the header mapping through to the patch on every branch | 1 | +| UT-211 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | uses the error's message and stack, and never sets \`notify\` | 1 | +| UT-212 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | falls back to 'Unknown error' when the thrown value has no message | 1 | +| UT-213 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | defaults the cancellation reason when none is given | 1 | +| UT-214 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | carries an explicit reason through to both the patch and the toast | 1 | +| UT-215 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | shows the diff dialog when the job already has a tableName | 1 | +| UT-216 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly with overwrite when there is no tableName yet | 1 | +| UT-217 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly when the job is undefined (id no longer in the list) | 1 | +| UT-218 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns an empty array when no templates are stored | 1 | +| UT-219 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns stored templates | 1 | +| UT-220 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns [] when localStorage contains invalid JSON | 1 | +| UT-221 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | persists a new template | 1 | +| UT-222 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | replaces an existing template with the same id | 1 | +| UT-223 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | prepends the newest template so it appears first | 1 | +| UT-224 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | removes the template with the matching id | 1 | +| UT-225 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | is a no-op when the id does not exist | 1 | +| UT-226 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when there are no templates | 1 | +| UT-227 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns the template whose column originals match in order | 1 | +| UT-228 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the header set differs | 1 | +| UT-229 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the order differs | 1 | +| UT-230 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | sets lastUsedAt on the targeted template | 1 | +| UT-231 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | does not affect other templates | 1 | +| UT-232 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a successful upload ends in status done and invalidates the router | 1 | +| UT-233 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a duplicate\_file response is classified as a duplicate, not an error | 1 | +| UT-234 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | an invalid\_structure response is classified as an error | 1 | +| UT-235 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a rejected upload promise is classified as an error with the thrown message | 1 | +| UT-236 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | clicking Stop while uploading marks the job cancelled and ignores the late result | 1 | +| UT-237 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | overwrite flow: confirming the diff dialog re-uploads with overwrite=true | 1 | ## System tests @@ -382,29 +383,31 @@ Contracts between components: API and frontend, API and PostgreSQL, deploy scrip | SIT-036 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_upload` | a T&E-mode response (including DB-side row errors) matches the UI schema. | 1 | | SIT-037 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_files_list` | /api/csv/files entries match csvFileSummarySchema. | 1 | | SIT-038 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_ok` | /api/csv/preview ok response matches csvPreviewResultSchema. | 1 | -| SIT-039 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_table_rows_values_are_json_primitives` | row values are string/number/boolean/null as previewRowsSchema requires. | 1 | -| SIT-040 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_tables` | /api/te/tables entries match teTableInfoSchema. | 1 | -| SIT-041 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_bootstrap_is_idempotent` | running bootstrap again on a live schema is a harmless no-op. | 1 | -| SIT-042 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_registry_tables_have_expected_columns` | csv\_files and audit\_log carry every column the API reads/writes. | 1 | -| SIT-043 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_dynamic_tables_live_only_in_uploads_schema` | a dynamic upload creates its table in the uploads schema, nowhere else. | 1 | -| SIT-044 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_te_row_counts_track_postgres_statistics` | /api/te/tables row counts agree with COUNT(\*) once statistics are fresh. | 1 | -| SIT-045 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_valid_te_upload_returns_ok` | A well-formed T&E CSV must return status=ok. | 1 | -| SIT-046 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_inserted_rows_count_matches_data_rows` | insertedRows must equal the number of data rows supplied. | 1 | -| SIT-047 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_table_name_in_response_is_schema_qualified` | tableName must be schema-qualified (e.g. te\_dev.organisations). | 1 | -| SIT-048 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_rows_are_queryable_after_upload` | Inserted rows must appear in the T&E table row count. | 1 | -| SIT-049 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_core_te_tables_undisturbed_after_upload` | A T&E upload must not drop or alter the other 11 core tables. | 1 | -| SIT-050 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_reupload_cleans_prior_registry_entry` | Re-uploading the same file name must not duplicate registry entries. | 1 | -| SIT-051 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_invalid_target_table_is_rejected` | A targetTable not in TE\_TABLES must return status=error. | 1 | -| SIT-052 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_empty_file_is_rejected` | Empty CSV is rejected — 422 from Pydantic min\_length or status=error. | 1 | -| SIT-053 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_header_only_file_is_rejected` | A header-only CSV (no data rows) must return status=invalid\_structure. | 1 | -| SIT-054 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_unknown_columns_are_rejected` | Columns not in the target table must return status=error. | 1 | -| SIT-055 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_missing_target_table_param_is_rejected` | mode=te with no targetTable must return 422 from the API layer. | 1 | -| SIT-056 | `tests/system_integration/test_te_loader_integration.py` | `DynamicOverwriteOfTeOriginFile.test_dynamic_overwrite_of_te_origin_entry_succeeds` | A dynamic-mode overwrite of a filename previously registered in TE mode must succeed (not crash, not silently no-op) and the registry must reflect the new dynamic-mode table, not a stale TE reference. | 1 | -| SIT-057 | `tests/suites/test_01_organisations_personnel.sql` | SQL suite | T&E schema constraints and business rules (23 assertions) | 23 | -| SIT-058 | `tests/suites/test_02_programs_phases.sql` | SQL suite | T&E schema constraints and business rules (25 assertions) | 25 | -| SIT-059 | `tests/suites/test_03_requirements_vcrm.sql` | SQL suite | T&E schema constraints and business rules (23 assertions) | 23 | -| SIT-060 | `tests/suites/test_04_execution_defects.sql` | SQL suite | T&E schema constraints and business rules (38 assertions) | 38 | -| SIT-061 | `tests/suites/test_05_schema_and_business_rules.sql` | SQL suite | T&E schema constraints and business rules (33 assertions) | 33 | +| SIT-039 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_invalid_structure` | every invalid\_structure reason /api/csv/preview returns is one the UI's csvPreviewResultSchema accepts. | 3 | +| SIT-040 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_parse_failure` | a parser crash returns reason=parse\_failed with a message, and the UI's preview schema accepts that reason. | 1 | +| SIT-041 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_table_rows_values_are_json_primitives` | row values are string/number/boolean/null as previewRowsSchema requires. | 1 | +| SIT-042 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_tables` | /api/te/tables entries match teTableInfoSchema. | 1 | +| SIT-043 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_bootstrap_is_idempotent` | running bootstrap again on a live schema is a harmless no-op. | 1 | +| SIT-044 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_registry_tables_have_expected_columns` | csv\_files and audit\_log carry every column the API reads/writes. | 1 | +| SIT-045 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_dynamic_tables_live_only_in_uploads_schema` | a dynamic upload creates its table in the uploads schema, nowhere else. | 1 | +| SIT-046 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_te_row_counts_track_postgres_statistics` | /api/te/tables row counts agree with COUNT(\*) once statistics are fresh. | 1 | +| SIT-047 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_valid_te_upload_returns_ok` | A well-formed T&E CSV must return status=ok. | 1 | +| SIT-048 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_inserted_rows_count_matches_data_rows` | insertedRows must equal the number of data rows supplied. | 1 | +| SIT-049 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_table_name_in_response_is_schema_qualified` | tableName must be schema-qualified (e.g. te\_dev.organisations). | 1 | +| SIT-050 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_rows_are_queryable_after_upload` | Inserted rows must appear in the T&E table row count. | 1 | +| SIT-051 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_core_te_tables_undisturbed_after_upload` | A T&E upload must not drop or alter the other 11 core tables. | 1 | +| SIT-052 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_reupload_cleans_prior_registry_entry` | Re-uploading the same file name must not duplicate registry entries. | 1 | +| SIT-053 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_invalid_target_table_is_rejected` | A targetTable not in TE\_TABLES must return status=error. | 1 | +| SIT-054 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_empty_file_is_rejected` | Empty CSV is rejected — 422 from Pydantic min\_length or status=error. | 1 | +| SIT-055 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_header_only_file_is_rejected` | A header-only CSV (no data rows) must return status=invalid\_structure. | 1 | +| SIT-056 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_unknown_columns_are_rejected` | Columns not in the target table must return status=error. | 1 | +| SIT-057 | `tests/system_integration/test_te_loader_integration.py` | `TeUploadOrganisations.test_missing_target_table_param_is_rejected` | mode=te with no targetTable must return 422 from the API layer. | 1 | +| SIT-058 | `tests/system_integration/test_te_loader_integration.py` | `DynamicOverwriteOfTeOriginFile.test_dynamic_overwrite_of_te_origin_entry_succeeds` | A dynamic-mode overwrite of a filename previously registered in TE mode must succeed (not crash, not silently no-op) and the registry must reflect the new dynamic-mode table, not a stale TE reference. | 1 | +| SIT-059 | `tests/suites/test_01_organisations_personnel.sql` | SQL suite | T&E schema constraints and business rules (23 assertions) | 23 | +| SIT-060 | `tests/suites/test_02_programs_phases.sql` | SQL suite | T&E schema constraints and business rules (25 assertions) | 25 | +| SIT-061 | `tests/suites/test_03_requirements_vcrm.sql` | SQL suite | T&E schema constraints and business rules (23 assertions) | 23 | +| SIT-062 | `tests/suites/test_04_execution_defects.sql` | SQL suite | T&E schema constraints and business rules (38 assertions) | 38 | +| SIT-063 | `tests/suites/test_05_schema_and_business_rules.sql` | SQL suite | T&E schema constraints and business rules (33 assertions) | 33 | ## Performance tests diff --git a/env.dev.example b/env.dev.example index 23075bc..ebe9947 100644 --- a/env.dev.example +++ b/env.dev.example @@ -23,7 +23,7 @@ CSV_UPLOADS_SCHEMA=csv_uploads TE_SCHEMA=te_dev # CORS — frontend dev server ports -CORS_ORIGINS=http://localhost:5173,http://localhost:3000 +CORS_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost:3000 # Upload limits MAX_UPLOAD_BYTES=52428800 diff --git a/scripts/start-frontend.ps1 b/scripts/start-frontend.ps1 index 1d5d7b8..c89e6ce 100644 --- a/scripts/start-frontend.ps1 +++ b/scripts/start-frontend.ps1 @@ -1,4 +1,4 @@ -# start-frontend.ps1 - Terminal 2: run the React frontend on http://localhost:5173 +# start-frontend.ps1 - Terminal 2: run the React frontend on http://localhost:8080 # First run installs node_modules automatically. $ErrorActionPreference = "Stop" @@ -10,5 +10,5 @@ if (-not (Test-Path "node_modules")) { npm install } -Write-Host "Frontend starting on http://localhost:5173" -ForegroundColor Green +Write-Host "Frontend starting on http://localhost:8080" -ForegroundColor Green npm run dev diff --git a/tests/system_integration/test_sit_frontend_contract.py b/tests/system_integration/test_sit_frontend_contract.py index 6d097c7..b69e47c 100644 --- a/tests/system_integration/test_sit_frontend_contract.py +++ b/tests/system_integration/test_sit_frontend_contract.py @@ -29,10 +29,11 @@ TE_SCHEMA_SQL = ROOT / "build" / "te_core_schema.sql" -def _ts_enum(after: str) -> set[str]: - """Values of the first z.enum([...]) that follows ``after`` in the TS file.""" +def _ts_enum(after: str, within: str | None = None) -> set[str]: + """Values of the first z.enum([...]) that follows ``after`` in the TS file + (searching from ``within`` first, when given, to pick one schema).""" src = TS_CONTRACT.read_text(encoding="utf-8") - start = src.index(after) + start = src.index(after, src.index(within) if within else 0) m = re.search(r"z\.enum\(\[(.*?)\]\)", src[start:], re.S) assert m, f"no z.enum after {after!r} in {TS_CONTRACT}" return set(re.findall(r'"([^"]+)"', m.group(1))) @@ -46,6 +47,8 @@ def contract(): "log_levels": _ts_enum("level: z.enum"), "invalid_reasons": _ts_enum('status: z.literal("invalid_structure")'), "duplicate_reasons": _ts_enum('status: z.literal("duplicate_file")'), + "preview_invalid_reasons": _ts_enum('status: z.literal("invalid_structure")', + within="const csvPreviewResultSchema"), } @@ -164,6 +167,24 @@ def test_preview_ok(self, live_api, contract): assert all(isinstance(c, str) for row in r["sampleRows"] for c in row) assert r["teTableMatch"] == "organisations" + @pytest.mark.parametrize("content", ["\n", "a,b\n", ",\n1,2\n"]) + def test_preview_invalid_structure(self, live_api, contract, content): + """SIT-17 every invalid_structure reason /api/csv/preview returns is one + the UI's csvPreviewResultSchema accepts.""" + r = live_api.post("/api/csv/preview", json={"fileName": "p.csv", "content": content}).json() + assert r["status"] == "invalid_structure" + assert r["reason"] in contract["preview_invalid_reasons"] + + def test_preview_parse_failure(self, live_api, contract): + """SIT-18 a parser crash returns reason=parse_failed with a message, and + the UI's preview schema accepts that reason.""" + from unittest import mock + + with mock.patch("api.routers.csv_routes.build_preview", side_effect=RecursionError("x")): + r = live_api.post("/api/csv/preview", json={"fileName": "p.csv", "content": "a\n1\n"}).json() + assert r["reason"] == "parse_failed" and isinstance(r["message"], str) + assert r["reason"] in contract["preview_invalid_reasons"] + def test_table_rows_values_are_json_primitives(self, live_api, registry_cleanup, unique_tag): """SIT-11 row values are string/number/boolean/null as previewRowsSchema requires.""" up = _upload(live_api, registry_cleanup, f"sit_{unique_tag}.csv", diff --git a/tests/unit/test_config_auth.py b/tests/unit/test_config_auth.py index 9752ca1..ee838c1 100644 --- a/tests/unit/test_config_auth.py +++ b/tests/unit/test_config_auth.py @@ -55,7 +55,7 @@ def test_wrong_or_missing_key_is_401(self, sent): class TestSettings: def test_defaults(self, monkeypatch): """Unset env vars fall back to the documented local-dev defaults.""" - for key in ("PGHOST", "PGPORT", "API_ALLOW_DESTRUCTIVE", "API_MAX_ROWS", + for key in ("PGHOST", "PGPORT", "CORS_ORIGINS", "API_ALLOW_DESTRUCTIVE", "API_MAX_ROWS", "API_MAX_ROW_ERRORS", "MAX_UPLOAD_BYTES", "CSV_UPLOADS_SCHEMA"): monkeypatch.delenv(key, raising=False) s = _reload_settings(monkeypatch) @@ -65,6 +65,8 @@ def test_defaults(self, monkeypatch): assert s.MAX_ROWS == 100000 assert s.MAX_ROW_ERRORS_REPORTED == 200 assert s.MAX_UPLOAD_BYTES == 50 * 1024 * 1024 + assert s.CORS_ORIGINS == ["http://localhost:8080", "http://localhost:5173", + "http://localhost:3000"] def test_destructive_operations_disabled_by_default(self, monkeypatch): """BUG-040: DELETE is off unless explicitly enabled.""" diff --git a/tests/unit/test_db_pool.py b/tests/unit/test_db_pool.py index 75e8061..c63de94 100644 --- a/tests/unit/test_db_pool.py +++ b/tests/unit/test_db_pool.py @@ -174,6 +174,14 @@ def test_preview_te_match_is_best_effort(self, isolated_pool): assert r.json()["status"] == "ok" assert r.json()["teTableMatch"] is None + def test_cors_allows_frontend_dev_server_by_default(self): + """The Vite dev server origin (localhost, port 8080) is allowed by default.""" + assert "http://localhost:8080" in settings.CORS_ORIGINS + r = TestClient(app).options("/api/csv/files", headers={ + "Origin": "http://localhost:8080", "Access-Control-Request-Method": "GET"}) + assert r.status_code == 200 + assert r.headers["access-control-allow-origin"] == "http://localhost:8080" + def test_cors_methods_are_restricted(self): """Only GET/POST/DELETE are advertised to browsers.""" origin = settings.CORS_ORIGINS[0] From 09ff668da085d0e763db30c05ac71b3f8c349d13 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 10 Oct 2026 06:31:17 +0000 Subject: [PATCH 3/4] test: regression tests for #42 (preview parse_failed) and #43 (CORS :8080) - New vitest suite src/__tests__/unit/preview-contract.test.ts drives the real previewCsv() with a mocked fetch: parse_failed (+ message) is accepted, the three original reasons still are, and an unknown reason is still rejected. - New unit test: an unlisted origin is still refused by CORS, so adding :8080 did not loosen the policy. - Existing SIT-17/18 and CORS default tests now reference the issues. Verified against the pre-fix source (15268ae~1): the #42/#43 tests fail (4 failures); with the fix all pass. Refs #42, #43 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CS4GeHuWThWvU74AkBL6E --- .../__tests__/unit/preview-contract.test.ts | 55 ++++ docs/TEST_CASES.md | 275 +++++++++--------- .../test_sit_frontend_contract.py | 4 +- tests/unit/test_config_auth.py | 3 +- tests/unit/test_db_pool.py | 10 +- 5 files changed, 208 insertions(+), 139 deletions(-) create mode 100644 csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts diff --git a/csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts b/csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts new file mode 100644 index 0000000..ab5df68 --- /dev/null +++ b/csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts @@ -0,0 +1,55 @@ +// Regression tests for issue #42: the frontend must accept every response +// shape /api/csv/preview can return — including reason "parse_failed", which +// the API sends (with a message) when its CSV parser throws. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { previewCsv } from "@/lib/csv.functions"; + +function mockFetchJson(payload: unknown, status = 200) { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("previewCsv response contract (issue #42)", () => { + it("accepts reason parse_failed and keeps the server message", async () => { + const message = "The CSV couldn't be parsed. Check the file's structure and try again."; + mockFetchJson({ status: "invalid_structure", reason: "parse_failed", message }); + + const result = await previewCsv("broken.csv", "a\n1\n"); + + expect(result.status).toBe("invalid_structure"); + if (result.status !== "invalid_structure") throw new Error("unreachable"); + expect(result.reason).toBe("parse_failed"); + expect(result.message).toBe(message); + }); + + it.each(["empty", "header_only", "no_columns"] as const)( + "still accepts reason %s without a message", + async (reason) => { + mockFetchJson({ status: "invalid_structure", reason }); + await expect(previewCsv("f.csv", "x")).resolves.toEqual({ status: "invalid_structure", reason }); + }, + ); + + it("still rejects a reason the API never sends", async () => { + mockFetchJson({ status: "invalid_structure", reason: "not_a_reason" }); + await expect(previewCsv("f.csv", "x")).rejects.toThrow(/Unexpected response shape/); + }); + + it("posts fileName and content to /api/csv/preview", async () => { + const fetchMock = mockFetchJson({ status: "invalid_structure", reason: "empty" }); + await previewCsv("f.csv", "a\n"); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(/\/api\/csv\/preview$/); + expect(JSON.parse(init.body)).toEqual({ fileName: "f.csv", content: "a\n" }); + }); +}); diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md index ee63e9f..4126c98 100644 --- a/docs/TEST_CASES.md +++ b/docs/TEST_CASES.md @@ -13,7 +13,7 @@ the behaviour asserted, and therefore the expected result. | Category | Folder | Tests | Cases | |----------|--------|-------|-------| -| [Unit tests](#unit-tests) | `tests/unit/` | 237 | 280 | +| [Unit tests](#unit-tests) | `tests/unit/` | 242 | 285 | | [System tests](#system-tests) | `tests/system/` | 68 | 70 | | [System integration tests](#system-integration-tests) | `tests/system_integration/` | 63 | 204 | | [Performance tests](#performance-tests) | `tests/performance/` | 13 | 16 | @@ -53,7 +53,7 @@ Single functions and modules in isolation. No database, no network. | UT-025 | `tests/unit/test_config_auth.py` | `TestRequireApiKey.test_no_key_configured_allows_request` | With API\_KEY unset (local dev) every request is allowed. | 1 | | UT-026 | `tests/unit/test_config_auth.py` | `TestRequireApiKey.test_correct_key_is_accepted` | A matching X-API-Key header passes. | 1 | | UT-027 | `tests/unit/test_config_auth.py` | `TestRequireApiKey.test_wrong_or_missing_key_is_401` | A missing or mismatched key raises 401. | 4 | -| UT-028 | `tests/unit/test_config_auth.py` | `TestSettings.test_defaults` | Unset env vars fall back to the documented local-dev defaults. | 1 | +| UT-028 | `tests/unit/test_config_auth.py` | `TestSettings.test_defaults` | Unset env vars fall back to the documented local-dev defaults, including the Vite dev-server origin on port 8080 (issue #43). | 1 | | UT-029 | `tests/unit/test_config_auth.py` | `TestSettings.test_destructive_operations_disabled_by_default` | BUG-040: DELETE is off unless explicitly enabled. | 1 | | UT-030 | `tests/unit/test_config_auth.py` | `TestSettings.test_destructive_truthy_values` | 1/true/yes (any case) enable destructive operations. | 4 | | UT-031 | `tests/unit/test_config_auth.py` | `TestSettings.test_destructive_other_values_stay_disabled` | Anything else keeps destructive operations disabled. | 5 | @@ -132,137 +132,142 @@ Single functions and modules in isolation. No database, no network. | UT-104 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_health_degrades_without_leaking_details` | When the DB is unreachable /api/health returns 200 'degraded' with no host/user/driver text in the body. | 1 | | UT-105 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_preview_parse_crash_returns_structured_error` | BUG-025: a parser exception never surfaces as a raw 500. | 1 | | UT-106 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_preview_te_match_is_best_effort` | If the T&E lookup fails the preview still succeeds with a null match. | 1 | -| UT-107 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_allows_frontend_dev_server_by_default` | The Vite dev server origin (localhost, port 8080) is allowed by default. | 1 | -| UT-108 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_methods_are_restricted` | Only GET/POST/DELETE are advertised to browsers. | 1 | -| UT-109 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_structure_errors` | Empty, header-only and blank-header files are rejected with a message. | 3 | -| UT-110 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_row_cap` | BUG-027: files above MAX\_ROWS are rejected up-front. | 1 | -| UT-111 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_logs_have_frontend_shape` | Every processing-log entry carries ts/step/level/message. | 1 | -| UT-112 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_rejects_unknown_target_table` | Only the 12 whitelisted T&E tables are accepted. | 1 | -| UT-113 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_structure_errors` | T&E mode needs a header and at least one data row. | 2 | -| UT-114 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_row_cap` | BUG-027: the row cap also applies in T&E mode. | 1 | -| UT-115 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_load_expected_returns_none_for_missing_scenario` | \_load\_expected with an unknown scenario name should return None without raising. | 1 | -| UT-116 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_discover_scenarios_filters_only_requested_name` | discover\_scenarios with a name argument should return only the directory whose name matches exactly. | 1 | -| UT-117 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_reports_unknown_runner_action_as_failure` | An expected JSON with an unrecognised runner\_action should produce a failed result containing 'Unknown runner\_action' in errors. | 1 | -| UT-118 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_generated_invalid_utf8_skips_row_cleanly` | The 23\_invalid\_utf8\_bytes eval scenario: an invalid-UTF-8 data row is skipped with a reason while valid rows still pass (exit\_code=0), per the loader's load-what-is-valid contract. No raw Traceback may appear. | 1 | -| UT-119 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_i_fails_when_postgresql_is_unavailable` | When \_can\_connect\_pg returns False, run\_tier\_i\_scenario must FAIL (not skip): an unavailable prerequisite is a failure, so a green run always means the scenario really executed. | 1 | -| UT-120 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_count_dev_rows_records_none_for_failed_count_query` | When the count query subprocess exits non-zero, \_count\_dev\_rows should record None for every entry in DEV\_SEED\_TABLES. | 1 | -| UT-121 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_have_psql_uses_path_lookup` | When shutil.which returns None, \_have\_psql should return False. | 1 | -| UT-122 | `tests/unit/test_regression.py` | `RegressionBomHandling.test_bom_csv_is_accepted` | Bom csv is accepted | 1 | -| UT-123 | `tests/unit/test_regression.py` | `RegressionCrlfLineEndings.test_crlf_csv_accepted` | Crlf csv accepted | 1 | -| UT-124 | `tests/unit/test_regression.py` | `RegressionHeaderOnlyCsv.test_header_only_exits_cleanly` | Header only exits cleanly | 1 | -| UT-125 | `tests/unit/test_regression.py` | `RegressionSingleColumnCsv.test_single_column_valid_rows_pass` | Single column valid rows pass | 1 | -| UT-126 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_missing_env_vars_no_traceback` | Missing env vars no traceback | 1 | -| UT-127 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_nonexistent_file_no_traceback` | Nonexistent file no traceback | 1 | -| UT-128 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_invalid_utf8_no_traceback` | Invalid utf8 no traceback | 1 | -| UT-129 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_validator_no_hardcoded_credentials` | Validator no hardcoded credentials | 1 | -| UT-130 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_runner_no_hardcoded_credentials` | Runner no hardcoded credentials | 1 | -| UT-131 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_gap_report_no_hardcoded_credentials` | Gap report no hardcoded credentials | 1 | -| UT-132 | `tests/unit/test_security.py` | `TestNoFStringSql.test_validator_no_fstring_sql` | Validator no fstring sql | 1 | -| UT-133 | `tests/unit/test_security.py` | `TestNoFStringSql.test_runner_no_fstring_sql` | Runner no fstring sql | 1 | -| UT-134 | `tests/unit/test_security.py` | `TestConfigEnvExample.test_pg_password_is_empty` | Pg password is empty | 1 | -| UT-135 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_adapters` | No echo password in adapters | 1 | -| UT-136 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_loaders` | No echo password in loaders | 1 | -| UT-137 | `tests/unit/test_security.py` | `TestSqlFilesNoInlineCredentials.test_env_sql_files_no_hardcoded_passwords` | Env sql files no hardcoded passwords | 1 | -| UT-138 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_dynamic_upload_rejects_mismatched_type_list_length` | Dynamic upload rejects mismatched type list length | 1 | -| UT-139 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_te_cleanup_prior_registry_entries_drops_dynamic_table` | Te cleanup prior registry entries drops dynamic table | 1 | -| UT-140 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_valid_csv_matches_snapshot` | Valid csv matches snapshot | 1 | -| UT-141 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_skip_csv_matches_snapshot` | Skip csv matches snapshot | 1 | -| UT-142 | `tests/unit/test_snapshot.py` | `SnapshotAllValidInput.test_all_valid_no_skips` | All valid no skips | 1 | -| UT-143 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns all jobs when search is empty | 1 | -| UT-144 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | filters by exact substring | 1 | -| UT-145 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | is case-insensitive | 1 | -| UT-146 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty array when nothing matches | 1 | -| UT-147 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | matches partial filenames | 1 | -| UT-148 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'all' returns every job | 1 | -| UT-149 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'done' returns only done jobs (including overwritten) | 1 | -| UT-150 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'overwritten' returns only done+overwritten jobs | 1 | -| UT-151 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'error' returns only failed jobs | 1 | -| UT-152 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'duplicate' returns only duplicate jobs | 1 | -| UT-153 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'cancelled' returns only cancelled jobs | 1 | -| UT-154 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | non-overwritten done job does NOT appear under 'overwritten' | 1 | -| UT-155 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom excludes jobs before the date | 1 | -| UT-156 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateTo includes jobs on the specified day; jobs strictly after it are excluded | 1 | -| UT-157 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom + dateTo together form a closed range | 1 | -| UT-158 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | empty date strings do not filter by date | 1 | -| UT-159 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + status combined narrow correctly | 1 | -| UT-160 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + date + status combined to a single result | 1 | -| UT-161 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty when search and status conflict | 1 | -| UT-162 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | lowercases and trims headers | 1 | -| UT-163 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | replaces non-alphanumeric chars with underscores | 1 | -| UT-164 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips leading and trailing underscores | 1 | -| UT-165 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | prefixes numeric-starting names with col\_ | 1 | -| UT-166 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | generates a name for empty/blank headers | 1 | -| UT-167 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | deduplicates by appending \_2, \_3, … | 1 | -| UT-168 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reserved system column names get a \_col suffix instead of colliding | 1 | -| UT-169 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | truncates names longer than 55 characters | 1 | -| UT-170 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles a mix of edge cases in one call | 1 | -| UT-171 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns headers and one sample row | 1 | -| UT-172 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips the BOM character | 1 | -| UT-173 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles quoted fields with embedded commas | 1 | -| UT-174 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reports zero data rows for a header-only file | 1 | -| UT-175 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns empty arrays for a completely empty file | 1 | -| UT-176 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers int8 for whole-number columns | 1 | -| UT-177 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers numeric for decimal columns | 1 | -| UT-178 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers boolean for true/false columns | 1 | -| UT-179 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers date for YYYY-MM-DD columns | 1 | -| UT-180 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | falls back to text when column has mixed types | 1 | -| UT-181 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers text for a column with all empty cells | 1 | -| UT-182 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | ignores empty cells when inferring type | 1 | -| UT-183 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | has a human label for every inferred type | 1 | -| UT-184 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for undefined input | 1 | -| UT-185 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for an empty mapping | 1 | -| UT-186 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when every column keeps its original name | 1 | -| UT-187 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when renamed is empty string | 1 | -| UT-188 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | lists renamed columns as 'original→renamed' | 1 | -| UT-189 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | omits columns that are unchanged | 1 | -| UT-190 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns one row per RowError | 1 | -| UT-191 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | maps rowNumber, column, value, reason correctly | 1 | -| UT-192 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes the file name in every row | 1 | -| UT-193 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes a mapping summary when mapping is provided | 1 | -| UT-194 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | when rowErrors is empty but errorMessage is set, emits one structural-error row | 1 | -| UT-195 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty array when both rowErrors and errorMessage are absent | 1 | -| UT-196 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .csv filename | 1 | -| UT-197 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the header row | 1 | -| UT-198 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the error row values | 1 | -| UT-199 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | escapes commas and quotes in values | 1 | -| UT-200 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .xlsx filename | 1 | -| UT-201 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX content is a valid workbook with an Errors sheet | 1 | -| UT-202 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: triggers download with audit-report filename | 1 | -| UT-203 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX: workbook has an Audit sheet | 1 | -| UT-204 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: includes header and data row | 1 | -| UT-205 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok -> done, success toast, notify, and invalidateRoute | 1 | -| UT-206 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok with overwritten and failedRows composes the full message | 1 | -| UT-207 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status duplicate\_file -> duplicate patch, warning toast, no invalidateRoute | 1 | -| UT-208 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status invalid\_structure -> error patch carrying invalidReason | 1 | -| UT-209 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status error -> error patch carrying rowErrors | 1 | -| UT-210 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | passes the header mapping through to the patch on every branch | 1 | -| UT-211 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | uses the error's message and stack, and never sets \`notify\` | 1 | -| UT-212 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | falls back to 'Unknown error' when the thrown value has no message | 1 | -| UT-213 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | defaults the cancellation reason when none is given | 1 | -| UT-214 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | carries an explicit reason through to both the patch and the toast | 1 | -| UT-215 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | shows the diff dialog when the job already has a tableName | 1 | -| UT-216 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly with overwrite when there is no tableName yet | 1 | -| UT-217 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly when the job is undefined (id no longer in the list) | 1 | -| UT-218 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns an empty array when no templates are stored | 1 | -| UT-219 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns stored templates | 1 | -| UT-220 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns [] when localStorage contains invalid JSON | 1 | -| UT-221 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | persists a new template | 1 | -| UT-222 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | replaces an existing template with the same id | 1 | -| UT-223 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | prepends the newest template so it appears first | 1 | -| UT-224 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | removes the template with the matching id | 1 | -| UT-225 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | is a no-op when the id does not exist | 1 | -| UT-226 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when there are no templates | 1 | -| UT-227 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns the template whose column originals match in order | 1 | -| UT-228 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the header set differs | 1 | -| UT-229 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the order differs | 1 | -| UT-230 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | sets lastUsedAt on the targeted template | 1 | -| UT-231 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | does not affect other templates | 1 | -| UT-232 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a successful upload ends in status done and invalidates the router | 1 | -| UT-233 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a duplicate\_file response is classified as a duplicate, not an error | 1 | -| UT-234 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | an invalid\_structure response is classified as an error | 1 | -| UT-235 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a rejected upload promise is classified as an error with the thrown message | 1 | -| UT-236 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | clicking Stop while uploading marks the job cancelled and ignores the late result | 1 | -| UT-237 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | overwrite flow: confirming the diff dialog re-uploads with overwrite=true | 1 | +| UT-107 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_allows_frontend_dev_server_by_default` | The Vite dev server origin (localhost, port 8080) is allowed by default (regression for issue #43). | 1 | +| UT-108 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_still_refuses_unlisted_origins` | Adding :8080 did not open CORS up: an unlisted origin is refused (issue #43). | 1 | +| UT-109 | `tests/unit/test_db_pool.py` | `TestAppErrorHandling.test_cors_methods_are_restricted` | Only GET/POST/DELETE are advertised to browsers. | 1 | +| UT-110 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_structure_errors` | Empty, header-only and blank-header files are rejected with a message. | 3 | +| UT-111 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_row_cap` | BUG-027: files above MAX\_ROWS are rejected up-front. | 1 | +| UT-112 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_dynamic_logs_have_frontend_shape` | Every processing-log entry carries ts/step/level/message. | 1 | +| UT-113 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_rejects_unknown_target_table` | Only the 12 whitelisted T&E tables are accepted. | 1 | +| UT-114 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_structure_errors` | T&E mode needs a header and at least one data row. | 2 | +| UT-115 | `tests/unit/test_db_pool.py` | `TestLoaderGuardsWithoutDatabase.test_te_row_cap` | BUG-027: the row cap also applies in T&E mode. | 1 | +| UT-116 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_load_expected_returns_none_for_missing_scenario` | \_load\_expected with an unknown scenario name should return None without raising. | 1 | +| UT-117 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_discover_scenarios_filters_only_requested_name` | discover\_scenarios with a name argument should return only the directory whose name matches exactly. | 1 | +| UT-118 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_reports_unknown_runner_action_as_failure` | An expected JSON with an unrecognised runner\_action should produce a failed result containing 'Unknown runner\_action' in errors. | 1 | +| UT-119 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_p_generated_invalid_utf8_skips_row_cleanly` | The 23\_invalid\_utf8\_bytes eval scenario: an invalid-UTF-8 data row is skipped with a reason while valid rows still pass (exit\_code=0), per the loader's load-what-is-valid contract. No raw Traceback may appear. | 1 | +| UT-120 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_tier_i_fails_when_postgresql_is_unavailable` | When \_can\_connect\_pg returns False, run\_tier\_i\_scenario must FAIL (not skip): an unavailable prerequisite is a failure, so a green run always means the scenario really executed. | 1 | +| UT-121 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_count_dev_rows_records_none_for_failed_count_query` | When the count query subprocess exits non-zero, \_count\_dev\_rows should record None for every entry in DEV\_SEED\_TABLES. | 1 | +| UT-122 | `tests/unit/test_evals_runner.py` | `EvalsRunnerTests.test_have_psql_uses_path_lookup` | When shutil.which returns None, \_have\_psql should return False. | 1 | +| UT-123 | `tests/unit/test_regression.py` | `RegressionBomHandling.test_bom_csv_is_accepted` | Bom csv is accepted | 1 | +| UT-124 | `tests/unit/test_regression.py` | `RegressionCrlfLineEndings.test_crlf_csv_accepted` | Crlf csv accepted | 1 | +| UT-125 | `tests/unit/test_regression.py` | `RegressionHeaderOnlyCsv.test_header_only_exits_cleanly` | Header only exits cleanly | 1 | +| UT-126 | `tests/unit/test_regression.py` | `RegressionSingleColumnCsv.test_single_column_valid_rows_pass` | Single column valid rows pass | 1 | +| UT-127 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_missing_env_vars_no_traceback` | Missing env vars no traceback | 1 | +| UT-128 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_nonexistent_file_no_traceback` | Nonexistent file no traceback | 1 | +| UT-129 | `tests/unit/test_regression.py` | `RegressionNoTraceback.test_invalid_utf8_no_traceback` | Invalid utf8 no traceback | 1 | +| UT-130 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_validator_no_hardcoded_credentials` | Validator no hardcoded credentials | 1 | +| UT-131 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_runner_no_hardcoded_credentials` | Runner no hardcoded credentials | 1 | +| UT-132 | `tests/unit/test_security.py` | `TestNoHardcodedCredentials.test_gap_report_no_hardcoded_credentials` | Gap report no hardcoded credentials | 1 | +| UT-133 | `tests/unit/test_security.py` | `TestNoFStringSql.test_validator_no_fstring_sql` | Validator no fstring sql | 1 | +| UT-134 | `tests/unit/test_security.py` | `TestNoFStringSql.test_runner_no_fstring_sql` | Runner no fstring sql | 1 | +| UT-135 | `tests/unit/test_security.py` | `TestConfigEnvExample.test_pg_password_is_empty` | Pg password is empty | 1 | +| UT-136 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_adapters` | No echo password in adapters | 1 | +| UT-137 | `tests/unit/test_security.py` | `TestShellScriptsNoPrintedPasswords.test_no_echo_password_in_loaders` | No echo password in loaders | 1 | +| UT-138 | `tests/unit/test_security.py` | `TestSqlFilesNoInlineCredentials.test_env_sql_files_no_hardcoded_passwords` | Env sql files no hardcoded passwords | 1 | +| UT-139 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_dynamic_upload_rejects_mismatched_type_list_length` | Dynamic upload rejects mismatched type list length | 1 | +| UT-140 | `tests/unit/test_service_loader.py` | `ServiceLoaderUnitTests.test_te_cleanup_prior_registry_entries_drops_dynamic_table` | Te cleanup prior registry entries drops dynamic table | 1 | +| UT-141 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_valid_csv_matches_snapshot` | Valid csv matches snapshot | 1 | +| UT-142 | `tests/unit/test_snapshot.py` | `SnapshotBasicMixedInput.test_skip_csv_matches_snapshot` | Skip csv matches snapshot | 1 | +| UT-143 | `tests/unit/test_snapshot.py` | `SnapshotAllValidInput.test_all_valid_no_skips` | All valid no skips | 1 | +| UT-144 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns all jobs when search is empty | 1 | +| UT-145 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | filters by exact substring | 1 | +| UT-146 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | is case-insensitive | 1 | +| UT-147 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty array when nothing matches | 1 | +| UT-148 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | matches partial filenames | 1 | +| UT-149 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'all' returns every job | 1 | +| UT-150 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'done' returns only done jobs (including overwritten) | 1 | +| UT-151 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'overwritten' returns only done+overwritten jobs | 1 | +| UT-152 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'error' returns only failed jobs | 1 | +| UT-153 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'duplicate' returns only duplicate jobs | 1 | +| UT-154 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | 'cancelled' returns only cancelled jobs | 1 | +| UT-155 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | non-overwritten done job does NOT appear under 'overwritten' | 1 | +| UT-156 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom excludes jobs before the date | 1 | +| UT-157 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateTo includes jobs on the specified day; jobs strictly after it are excluded | 1 | +| UT-158 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | dateFrom + dateTo together form a closed range | 1 | +| UT-159 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | empty date strings do not filter by date | 1 | +| UT-160 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + status combined narrow correctly | 1 | +| UT-161 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | search + date + status combined to a single result | 1 | +| UT-162 | `csv-table-hub-main/src/__tests__/unit/audit-filters.test.ts` | vitest | returns empty when search and status conflict | 1 | +| UT-163 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | lowercases and trims headers | 1 | +| UT-164 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | replaces non-alphanumeric chars with underscores | 1 | +| UT-165 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips leading and trailing underscores | 1 | +| UT-166 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | prefixes numeric-starting names with col\_ | 1 | +| UT-167 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | generates a name for empty/blank headers | 1 | +| UT-168 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | deduplicates by appending \_2, \_3, … | 1 | +| UT-169 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reserved system column names get a \_col suffix instead of colliding | 1 | +| UT-170 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | truncates names longer than 55 characters | 1 | +| UT-171 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles a mix of edge cases in one call | 1 | +| UT-172 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns headers and one sample row | 1 | +| UT-173 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | strips the BOM character | 1 | +| UT-174 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | handles quoted fields with embedded commas | 1 | +| UT-175 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | reports zero data rows for a header-only file | 1 | +| UT-176 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | returns empty arrays for a completely empty file | 1 | +| UT-177 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers int8 for whole-number columns | 1 | +| UT-178 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers numeric for decimal columns | 1 | +| UT-179 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers boolean for true/false columns | 1 | +| UT-180 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers date for YYYY-MM-DD columns | 1 | +| UT-181 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | falls back to text when column has mixed types | 1 | +| UT-182 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | infers text for a column with all empty cells | 1 | +| UT-183 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | ignores empty cells when inferring type | 1 | +| UT-184 | `csv-table-hub-main/src/__tests__/unit/csv-preview.test.ts` | vitest | has a human label for every inferred type | 1 | +| UT-185 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for undefined input | 1 | +| UT-186 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty string for an empty mapping | 1 | +| UT-187 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when every column keeps its original name | 1 | +| UT-188 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns '(no renames)' when renamed is empty string | 1 | +| UT-189 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | lists renamed columns as 'original→renamed' | 1 | +| UT-190 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | omits columns that are unchanged | 1 | +| UT-191 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns one row per RowError | 1 | +| UT-192 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | maps rowNumber, column, value, reason correctly | 1 | +| UT-193 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes the file name in every row | 1 | +| UT-194 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | includes a mapping summary when mapping is provided | 1 | +| UT-195 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | when rowErrors is empty but errorMessage is set, emits one structural-error row | 1 | +| UT-196 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | returns empty array when both rowErrors and errorMessage are absent | 1 | +| UT-197 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .csv filename | 1 | +| UT-198 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the header row | 1 | +| UT-199 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV content contains the error row values | 1 | +| UT-200 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | escapes commas and quotes in values | 1 | +| UT-201 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | triggers a download with a .xlsx filename | 1 | +| UT-202 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX content is a valid workbook with an Errors sheet | 1 | +| UT-203 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: triggers download with audit-report filename | 1 | +| UT-204 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | XLSX: workbook has an Audit sheet | 1 | +| UT-205 | `csv-table-hub-main/src/__tests__/unit/export.test.ts` | vitest | CSV: includes header and data row | 1 | +| UT-206 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok -> done, success toast, notify, and invalidateRoute | 1 | +| UT-207 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status ok with overwritten and failedRows composes the full message | 1 | +| UT-208 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status duplicate\_file -> duplicate patch, warning toast, no invalidateRoute | 1 | +| UT-209 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status invalid\_structure -> error patch carrying invalidReason | 1 | +| UT-210 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | status error -> error patch carrying rowErrors | 1 | +| UT-211 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | passes the header mapping through to the patch on every branch | 1 | +| UT-212 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | uses the error's message and stack, and never sets \`notify\` | 1 | +| UT-213 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | falls back to 'Unknown error' when the thrown value has no message | 1 | +| UT-214 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | defaults the cancellation reason when none is given | 1 | +| UT-215 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | carries an explicit reason through to both the patch and the toast | 1 | +| UT-216 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | shows the diff dialog when the job already has a tableName | 1 | +| UT-217 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly with overwrite when there is no tableName yet | 1 | +| UT-218 | `csv-table-hub-main/src/__tests__/unit/job-outcome.test.ts` | vitest | retries directly when the job is undefined (id no longer in the list) | 1 | +| UT-219 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns an empty array when no templates are stored | 1 | +| UT-220 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns stored templates | 1 | +| UT-221 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns [] when localStorage contains invalid JSON | 1 | +| UT-222 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | persists a new template | 1 | +| UT-223 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | replaces an existing template with the same id | 1 | +| UT-224 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | prepends the newest template so it appears first | 1 | +| UT-225 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | removes the template with the matching id | 1 | +| UT-226 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | is a no-op when the id does not exist | 1 | +| UT-227 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when there are no templates | 1 | +| UT-228 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns the template whose column originals match in order | 1 | +| UT-229 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the header set differs | 1 | +| UT-230 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | returns null when the order differs | 1 | +| UT-231 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | sets lastUsedAt on the targeted template | 1 | +| UT-232 | `csv-table-hub-main/src/__tests__/unit/mapping-templates.test.ts` | vitest | does not affect other templates | 1 | +| UT-233 | `csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts` | vitest | accepts reason parse\_failed and keeps the server message | 1 | +| UT-234 | `csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts` | vitest | still accepts reason %s without a message | 1 | +| UT-235 | `csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts` | vitest | still rejects a reason the API never sends | 1 | +| UT-236 | `csv-table-hub-main/src/__tests__/unit/preview-contract.test.ts` | vitest | posts fileName and content to /api/csv/preview | 1 | +| UT-237 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a successful upload ends in status done and invalidates the router | 1 | +| UT-238 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a duplicate\_file response is classified as a duplicate, not an error | 1 | +| UT-239 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | an invalid\_structure response is classified as an error | 1 | +| UT-240 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | a rejected upload promise is classified as an error with the thrown message | 1 | +| UT-241 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | clicking Stop while uploading marks the job cancelled and ignores the late result | 1 | +| UT-242 | `csv-table-hub-main/src/__tests__/unit/uploader-state-machine.test.ts` | vitest | overwrite flow: confirming the diff dialog re-uploads with overwrite=true | 1 | ## System tests @@ -383,8 +388,8 @@ Contracts between components: API and frontend, API and PostgreSQL, deploy scrip | SIT-036 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_upload` | a T&E-mode response (including DB-side row errors) matches the UI schema. | 1 | | SIT-037 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_files_list` | /api/csv/files entries match csvFileSummarySchema. | 1 | | SIT-038 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_ok` | /api/csv/preview ok response matches csvPreviewResultSchema. | 1 | -| SIT-039 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_invalid_structure` | every invalid\_structure reason /api/csv/preview returns is one the UI's csvPreviewResultSchema accepts. | 3 | -| SIT-040 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_parse_failure` | a parser crash returns reason=parse\_failed with a message, and the UI's preview schema accepts that reason. | 1 | +| SIT-039 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_invalid_structure` | every invalid\_structure reason /api/csv/preview returns is one the UI's csvPreviewResultSchema accepts (regression for issue #42). | 3 | +| SIT-040 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_preview_parse_failure` | a parser crash returns reason=parse\_failed with a message, and the UI's preview schema accepts that reason (regression for issue #42). | 1 | | SIT-041 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_table_rows_values_are_json_primitives` | row values are string/number/boolean/null as previewRowsSchema requires. | 1 | | SIT-042 | `tests/system_integration/test_sit_frontend_contract.py` | `TestLiveResponsesSatisfyUiSchemas.test_te_tables` | /api/te/tables entries match teTableInfoSchema. | 1 | | SIT-043 | `tests/system_integration/test_sit_frontend_contract.py` | `TestApiDatabaseIntegration.test_bootstrap_is_idempotent` | running bootstrap again on a live schema is a harmless no-op. | 1 | diff --git a/tests/system_integration/test_sit_frontend_contract.py b/tests/system_integration/test_sit_frontend_contract.py index b69e47c..f28d34d 100644 --- a/tests/system_integration/test_sit_frontend_contract.py +++ b/tests/system_integration/test_sit_frontend_contract.py @@ -170,14 +170,14 @@ def test_preview_ok(self, live_api, contract): @pytest.mark.parametrize("content", ["\n", "a,b\n", ",\n1,2\n"]) def test_preview_invalid_structure(self, live_api, contract, content): """SIT-17 every invalid_structure reason /api/csv/preview returns is one - the UI's csvPreviewResultSchema accepts.""" + the UI's csvPreviewResultSchema accepts (regression for issue #42).""" r = live_api.post("/api/csv/preview", json={"fileName": "p.csv", "content": content}).json() assert r["status"] == "invalid_structure" assert r["reason"] in contract["preview_invalid_reasons"] def test_preview_parse_failure(self, live_api, contract): """SIT-18 a parser crash returns reason=parse_failed with a message, and - the UI's preview schema accepts that reason.""" + the UI's preview schema accepts that reason (regression for issue #42).""" from unittest import mock with mock.patch("api.routers.csv_routes.build_preview", side_effect=RecursionError("x")): diff --git a/tests/unit/test_config_auth.py b/tests/unit/test_config_auth.py index ee838c1..1162684 100644 --- a/tests/unit/test_config_auth.py +++ b/tests/unit/test_config_auth.py @@ -54,7 +54,8 @@ def test_wrong_or_missing_key_is_401(self, sent): class TestSettings: def test_defaults(self, monkeypatch): - """Unset env vars fall back to the documented local-dev defaults.""" + """Unset env vars fall back to the documented local-dev defaults, including + the Vite dev-server origin on port 8080 (issue #43).""" for key in ("PGHOST", "PGPORT", "CORS_ORIGINS", "API_ALLOW_DESTRUCTIVE", "API_MAX_ROWS", "API_MAX_ROW_ERRORS", "MAX_UPLOAD_BYTES", "CSV_UPLOADS_SCHEMA"): monkeypatch.delenv(key, raising=False) diff --git a/tests/unit/test_db_pool.py b/tests/unit/test_db_pool.py index c63de94..0cc5057 100644 --- a/tests/unit/test_db_pool.py +++ b/tests/unit/test_db_pool.py @@ -175,13 +175,21 @@ def test_preview_te_match_is_best_effort(self, isolated_pool): assert r.json()["teTableMatch"] is None def test_cors_allows_frontend_dev_server_by_default(self): - """The Vite dev server origin (localhost, port 8080) is allowed by default.""" + """The Vite dev server origin (localhost, port 8080) is allowed by default + (regression for issue #43).""" assert "http://localhost:8080" in settings.CORS_ORIGINS r = TestClient(app).options("/api/csv/files", headers={ "Origin": "http://localhost:8080", "Access-Control-Request-Method": "GET"}) assert r.status_code == 200 assert r.headers["access-control-allow-origin"] == "http://localhost:8080" + def test_cors_still_refuses_unlisted_origins(self): + """Adding :8080 did not open CORS up: an unlisted origin is refused (issue #43).""" + r = TestClient(app).options("/api/csv/files", headers={ + "Origin": "http://evil.example", "Access-Control-Request-Method": "GET"}) + assert r.status_code == 400 + assert "access-control-allow-origin" not in r.headers + def test_cors_methods_are_restricted(self): """Only GET/POST/DELETE are advertised to browsers.""" origin = settings.CORS_ORIGINS[0] From 6f6814a9400dbe65ec750aa89107532b8f10c165 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 10 Oct 2026 06:42:18 +0000 Subject: [PATCH 4/4] fix(a11y): settle animations before axe scans; darken sonner toast text CI failed test_table_preview_dialog_has_no_violations with a color-contrast violation on the dialog's <th> cells. Cause: the dialog fades in (200ms) and the scan could start mid-animation, when semi-transparent text measures low contrast. Reproduced deterministically by freezing the fade at 50ms (6 color-contrast nodes) vs. after it finishes (none). _axe() now waits until document.getAnimations() has nothing running. Waiting for animations also lets the import toast render before the scan, which exposed a real defect: sonner's richColors light-theme text is below WCAG AA on its pale backgrounds (success 4.29:1, info 4.35:1, warning 3.07:1, error 4.36:1). Darker text, same hues: 5.23 / 6.46 / 6.72 / 5.75:1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CS4GeHuWThWvU74AkBL6E --- csv-table-hub-main/src/styles.css | 12 ++++++++++++ tests/accessibility/test_accessibility.py | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/csv-table-hub-main/src/styles.css b/csv-table-hub-main/src/styles.css index a9806b4..cf3a13f 100644 --- a/csv-table-hub-main/src/styles.css +++ b/csv-table-hub-main/src/styles.css @@ -142,3 +142,15 @@ color: var(--color-foreground); } } + +/* Sonner "richColors" toasts: the library's light-theme text colours fall below + WCAG AA contrast (4.5:1) on their pale backgrounds (success green ~4.3:1, + warning orange ~3:1). + Darker text, same hues. The extra :root raises specificity above sonner's + own [data-sonner-toaster][data-sonner-theme="light"] rule, injected at runtime. */ +:root [data-sonner-toaster][data-sonner-theme="light"] { + --success-text: hsl(140, 100%, 24%); + --info-text: hsl(210, 92%, 35%); + --warning-text: hsl(31, 92%, 28%); + --error-text: hsl(360, 100%, 38%); +} diff --git a/tests/accessibility/test_accessibility.py b/tests/accessibility/test_accessibility.py index e5631f3..b9dd25e 100644 --- a/tests/accessibility/test_accessibility.py +++ b/tests/accessibility/test_accessibility.py @@ -23,9 +23,18 @@ CSV = b"name,org_type,country\nACME,prime,AU\nBETA,academic,AU\n" +def _settle(page): + """Wait until no CSS animation/transition is running. Dialogs fade in, and + axe measures colour contrast on the current (semi-transparent) frame, so a + scan taken mid-animation reports false contrast failures.""" + page.wait_for_function( + "() => document.getAnimations().every(a => a.playState !== 'running')") + + def _axe(page, context=None): from axe_playwright_python.sync_playwright import Axe + _settle(page) options = {"runOnly": {"type": "tag", "values": WCAG_TAGS}} return Axe().run(page, context=context, options=options)