diff --git a/0 b/0 new file mode 100644 index 0000000..e69de29 diff --git a/0, b/0, new file mode 100644 index 0000000..e69de29 diff --git a/200 b/200 new file mode 100644 index 0000000..e69de29 diff --git a/404 b/404 new file mode 100644 index 0000000..e69de29 diff --git a/409 b/409 new file mode 100644 index 0000000..e69de29 diff --git a/422 b/422 new file mode 100644 index 0000000..e69de29 diff --git a/backend/README.md b/backend/README.md index 2adad4e..3fa917d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # svg-service backend -Backend for SVG scheme upload, draft editing, pricing, publish preview, and publish lifecycle. +Backend for SVG scheme upload, draft editing, pricing, diagnostics, publish preview, and publish lifecycle. ## Stack @@ -32,16 +32,15 @@ Default local admin key: ## Core lifecycle -The backend works with a scheme lifecycle: - 1. Upload SVG 2. Normalize and persist structure -3. Work in current draft -4. Create / update pricing -5. Build pricing snapshot -6. Inspect publish preview / readiness -7. Publish current draft -8. If editing is needed after publish, create or ensure a new draft again +3. Enter editor flow through context + ensure draft +4. Edit sectors / groups / seats in current draft +5. Configure pricing and inspect diagnostics +6. Build pricing snapshot +7. Inspect publish readiness and publish preview +8. Publish current draft +9. If editing is needed after publish, create or ensure a new draft again ## Main concepts @@ -49,23 +48,19 @@ The backend works with a scheme lifecycle: Top-level business entity. ### Scheme version -Concrete version of the scheme. -A version can be `draft` or `published`. +Concrete version of the scheme. A version can be `draft` or `published`. ### Current version The version referenced by the scheme registry as active current. ### Draft -Editable current version. -All editor mutations and draft pricing operations must target a current draft version only. +Editable current version. All editor mutations and draft pricing operations must target a current draft version only. ### Published version -Non-editable current version. -If current version is published, editor flow must first create or ensure a new draft. +Non-editable current version. If current version is published, editor flow must first create or ensure a new draft. ### Upload artifacts Stored technical artifacts, including: - - original svg - sanitized svg - normalized json @@ -74,30 +69,23 @@ Stored technical artifacts, including: ## Editor entry flow -Use this flow from frontend or operator scripts. - ### 1. Inspect editor state - `GET /api/v1/schemes/{scheme_id}/editor/context` Response tells whether: - - current version is draft - editor is available - a new draft should be created - recommended action is `use_current_draft` or `create_draft` ### 2. Ensure editable draft - `POST /api/v1/schemes/{scheme_id}/draft/ensure` Behavior: - - if current version is already draft: returns it with `created=false` - if current version is published: clones current version into a new current draft and returns it with `created=true` -Returned `scheme_version_id` must be reused as: - +Returned `scheme_version_id` should be reused as: - `expected_scheme_version_id` for draft reads and mutations. @@ -105,14 +93,10 @@ for draft reads and mutations. ## Optimistic concurrency Mutable draft flows support optimistic concurrency through query params: - - `expected_current_scheme_version_id` - `expected_scheme_version_id` -These guards prevent frontend/editor from mutating a stale draft after another version switch. - -Typical typed conflict payload: - +Typical typed conflicts: - `stale_current_version` - `stale_draft_version` - `draft_not_editable` @@ -120,22 +104,19 @@ Typical typed conflict payload: ## Main operator routes -## System - +### System - `GET /healthz` - `GET /api/v1/ping` - `GET /api/v1/db/ping` - `GET /api/v1/manifest` -## Uploads - +### Uploads - `POST /api/v1/schemes/upload` - `GET /api/v1/uploads` - `GET /api/v1/uploads/{upload_id}` - `GET /api/v1/uploads/{upload_id}/normalized` -## Scheme registry - +### Scheme registry - `GET /api/v1/schemes` - `GET /api/v1/schemes/{scheme_id}` - `GET /api/v1/schemes/{scheme_id}/current` @@ -147,8 +128,7 @@ Typical typed conflict payload: - `POST /api/v1/schemes/{scheme_id}/unpublish` - `POST /api/v1/schemes/{scheme_id}/rollback` -## Editor / draft - +### Editor / draft - `GET /api/v1/schemes/{scheme_id}/editor/context` - `POST /api/v1/schemes/{scheme_id}/draft/ensure` - `GET /api/v1/schemes/{scheme_id}/draft/summary` @@ -168,8 +148,7 @@ Typical typed conflict payload: - `PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}` - `POST /api/v1/schemes/{scheme_id}/draft/repair-references` -## Pricing - +### Pricing - `GET /api/v1/schemes/{scheme_id}/pricing` - `POST /api/v1/schemes/{scheme_id}/pricing/categories` - `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}` @@ -178,22 +157,19 @@ Typical typed conflict payload: - `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}` - `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}` -## Pricing diagnostics - +### Pricing diagnostics - `GET /api/v1/schemes/{scheme_id}/pricing/coverage` - `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats` - `GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id}` - `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics` -## Publish preview - +### Publish preview - `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot` - `GET /api/v1/schemes/{scheme_id}/draft/publish-preview` - `POST /api/v1/schemes/{scheme_id}/draft/remap/preview` - `POST /api/v1/schemes/{scheme_id}/draft/remap/apply` -## Structure read model - +### Structure read model - `GET /api/v1/schemes/{scheme_id}/current/sectors` - `GET /api/v1/schemes/{scheme_id}/current/groups` - `GET /api/v1/schemes/{scheme_id}/current/seats` @@ -202,129 +178,85 @@ Typical typed conflict payload: - `GET /api/v1/schemes/{scheme_id}/current/svg/display` - `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta` -## Test mode - +### Test mode - `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}` -## Audit - +### Audit - `GET /api/v1/schemes/{scheme_id}/audit` -## Admin / ops - +### Admin / ops - `GET /api/v1/admin/schemes/{scheme_id}/current/artifacts` - `GET /api/v1/admin/schemes/{scheme_id}/current/validation` - `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate` - `POST /api/v1/admin/display/backfill` - `GET /api/v1/admin/artifacts/publish-preview/audit` - `POST /api/v1/admin/artifacts/publish-preview/cleanup` +- `GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview` +- `POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup` + +## Cleanup of test pricing data + +Cleanup endpoints are intended for removing diagnostic / test categories accidentally accumulated in a shared scheme. + +Preview candidates: +`GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview` + +Execute cleanup: +`POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup` + +Safety notes: +- use `dry_run=true` first +- keep `delete_only_without_rules=true` unless you intentionally want a harder cleanup +- prefer matching by prefixes instead of raw ids for repetitive test artifacts + +Helper script: +- `backend/scripts/cleanup_test_pricing_data.sh` + +Example: +`SCHEME_ID=... DRY_RUN=true ./backend/scripts/cleanup_test_pricing_data.sh` ## Typical local flow -## 1. Read current version - -Use: - +### 1. Read current version `GET /api/v1/schemes/{scheme_id}/current` -## 2. Ensure draft - -Use: - +### 2. Ensure draft `POST /api/v1/schemes/{scheme_id}/draft/ensure` Store returned: - - `scheme_version_id` -## 3. Read draft state - -Use: - +### 3. Read draft state - `GET /draft/summary?expected_scheme_version_id=...` - `GET /draft/structure?expected_scheme_version_id=...` - `GET /draft/validation?expected_scheme_version_id=...` - `GET /draft/compare-preview?expected_scheme_version_id=...` -## 4. Perform editor mutations - +### 4. Perform editor mutations Pass: - - `expected_scheme_version_id={draft_scheme_version_id}` on every mutation route. -## 5. Inspect pricing quality - -Use: - +### 5. Inspect pricing quality - `GET /pricing/coverage` - `GET /pricing/unpriced-seats` - `GET /pricing/explain/{seat_id}` - `GET /pricing/rules/diagnostics` -## 6. Create pricing snapshot +### 6. Build snapshot and inspect readiness +- `POST /draft/pricing/snapshot` +- `GET /draft/publish-readiness` +- `GET /draft/publish-preview?refresh=true` -Use: +### 7. Publish +- `POST /publish?expected_scheme_version_id=...` -`POST /draft/pricing/snapshot?expected_scheme_version_id=...` +## Regression -## 7. Inspect readiness +Main operator regression: +- `backend/scripts/smoke_regression.sh` -Use: +Run: +`API_URL=http://127.0.0.1:9020 API_KEY=admin-local-dev-key SCHEME_ID=... ./backend/scripts/smoke_regression.sh` -`GET /draft/publish-readiness?expected_scheme_version_id=...` - -## 8. Publish - -Use: - -`POST /publish?expected_scheme_version_id=...` - -## Typed error expectations - -Examples of stable typed errors already used in the service: - -### Draft concurrency/state -- `stale_current_version` -- `stale_draft_version` -- `draft_not_editable` - -### Editor validation -- `duplicate_seat_id` -- `duplicate_seat_id_in_payload` -- `duplicate_sector_id` -- `duplicate_group_id` -- `duplicate_sector_element_id` -- `duplicate_group_element_id` -- `unknown_sector_id` -- `unknown_group_id` -- `unknown_sector_ids` -- `unknown_group_ids` -- `unknown_target_sector_id` -- `unknown_target_group_id` -- `remap_filter_required` - -### Pricing / publish -- `invalid_amount` -- `publish_not_ready` - -## Draft summary semantics - -`GET /draft/summary` is the compact route for editor bootstrap. - -It returns: - -- current draft counters -- validation summary -- structure diff summary -- publish readiness summary - -This route is intended for frontend side panels / header status / quick preflight. - -## Notes - -- Draft-only routes must not mutate a published current version. -- Published current version should require `draft/ensure` before any editor mutation. -- Publish readiness can fail even if validation passes, for example when pricing snapshot is missing. -- `api-map.md` and `smoke-regression.md` must be updated together with route changes. diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 6b2c32a..35e5e88 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.routes.admin import router as admin_router +from app.api.routes.admin_cleanup import router as admin_cleanup_router from app.api.routes.audit import router as audit_router from app.api.routes.editor import router as editor_router from app.api.routes.pricing import router as pricing_router @@ -22,5 +23,6 @@ router.include_router(pricing_diagnostics_router) router.include_router(test_mode_router) router.include_router(audit_router) router.include_router(admin_router) +router.include_router(admin_cleanup_router) router.include_router(editor_router) router.include_router(publish_router) diff --git a/backend/app/api/routes/admin_cleanup.py b/backend/app/api/routes/admin_cleanup.py new file mode 100644 index 0000000..b6fa2de --- /dev/null +++ b/backend/app/api/routes/admin_cleanup.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, Query + +from app.core.config import settings +from app.schemas.admin_cleanup import ( + PricingCleanupExecuteRequest, + PricingCleanupExecuteResponse, + PricingCleanupPreviewResponse, +) +from app.security.auth import require_api_key +from app.services.pricing_cleanup import ( + build_pricing_cleanup_preview, + execute_pricing_cleanup, +) + +router = APIRouter() + + +@router.get( + f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/pricing/categories/cleanup-preview", + response_model=PricingCleanupPreviewResponse, +) +async def get_pricing_cleanup_preview( + scheme_id: str, + code_prefix: list[str] = Query(default_factory=list), + name_prefix: list[str] = Query(default_factory=list), + pricing_category_id: list[str] = Query(default_factory=list), + delete_only_without_rules: bool = Query(default=True), + role: str = Depends(require_api_key), +): + return await build_pricing_cleanup_preview( + scheme_id=scheme_id, + code_prefixes=code_prefix, + name_prefixes=name_prefix, + pricing_category_ids=pricing_category_id, + delete_only_without_rules=delete_only_without_rules, + ) + + +@router.post( + f"{settings.api_v1_prefix}/admin/schemes/{{scheme_id}}/pricing/categories/cleanup", + response_model=PricingCleanupExecuteResponse, +) +async def post_pricing_cleanup( + scheme_id: str, + payload: PricingCleanupExecuteRequest, + role: str = Depends(require_api_key), +): + return await execute_pricing_cleanup( + scheme_id=scheme_id, + code_prefixes=payload.code_prefixes, + name_prefixes=payload.name_prefixes, + pricing_category_ids=payload.pricing_category_ids, + delete_only_without_rules=payload.delete_only_without_rules, + dry_run=payload.dry_run, + ) diff --git a/backend/app/repositories/pricing_cleanup.py b/backend/app/repositories/pricing_cleanup.py new file mode 100644 index 0000000..4e76182 --- /dev/null +++ b/backend/app/repositories/pricing_cleanup.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from importlib import import_module + +from sqlalchemy import delete, func, outerjoin, select + +from app.db.session import AsyncSessionLocal + + +def _resolve_model(module_path: str, *candidate_names: str): + module = import_module(module_path) + for name in candidate_names: + model = getattr(module, name, None) + if model is not None: + return model + raise ImportError( + f"Unable to resolve model from {module_path}. " + f"Tried: {', '.join(candidate_names)}" + ) + + +PricingCategoryModel = _resolve_model( + "app.models.pricing_category", + "PricingCategory", + "PricingCategoryRecord", +) +PriceRuleModel = _resolve_model( + "app.models.price_rule", + "PriceRule", + "PriceRuleRecord", +) + + +async def list_pricing_categories_with_rule_counts( + *, + scheme_id: str, +) -> list[dict]: + async with AsyncSessionLocal() as session: + stmt = ( + select( + PricingCategoryModel.pricing_category_id, + PricingCategoryModel.scheme_id, + PricingCategoryModel.name, + PricingCategoryModel.code, + func.count(PriceRuleModel.price_rule_id).label("rules_count"), + ) + .select_from( + outerjoin( + PricingCategoryModel, + PriceRuleModel, + PricingCategoryModel.pricing_category_id == PriceRuleModel.pricing_category_id, + ) + ) + .where(PricingCategoryModel.scheme_id == scheme_id) + .group_by( + PricingCategoryModel.pricing_category_id, + PricingCategoryModel.scheme_id, + PricingCategoryModel.name, + PricingCategoryModel.code, + ) + .order_by( + PricingCategoryModel.name.asc(), + PricingCategoryModel.code.asc(), + PricingCategoryModel.pricing_category_id.asc(), + ) + ) + rows = (await session.execute(stmt)).all() + return [ + { + "pricing_category_id": row.pricing_category_id, + "scheme_id": row.scheme_id, + "name": row.name, + "code": row.code, + "rules_count": int(row.rules_count or 0), + } + for row in rows + ] + + +async def delete_pricing_categories_by_ids( + *, + scheme_id: str, + pricing_category_ids: list[str], +) -> int: + if not pricing_category_ids: + return 0 + + async with AsyncSessionLocal() as session: + stmt = delete(PricingCategoryModel).where( + PricingCategoryModel.scheme_id == scheme_id, + PricingCategoryModel.pricing_category_id.in_(pricing_category_ids), + ) + result = await session.execute(stmt) + await session.commit() + return int(result.rowcount or 0) diff --git a/backend/app/schemas/admin_cleanup.py b/backend/app/schemas/admin_cleanup.py new file mode 100644 index 0000000..9736f60 --- /dev/null +++ b/backend/app/schemas/admin_cleanup.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel, Field + + +class PricingCleanupPreviewItem(BaseModel): + pricing_category_id: str + name: str + code: str + rules_count: int = Field(ge=0) + matched_by: list[str] + deletable: bool + + +class PricingCleanupPreviewResponse(BaseModel): + scheme_id: str + code_prefixes: list[str] + name_prefixes: list[str] + pricing_category_ids: list[str] + delete_only_without_rules: bool + total_candidates: int = Field(ge=0) + safe_to_delete_count: int = Field(ge=0) + items: list[PricingCleanupPreviewItem] + + +class PricingCleanupExecuteRequest(BaseModel): + code_prefixes: list[str] = Field(default_factory=list) + name_prefixes: list[str] = Field(default_factory=list) + pricing_category_ids: list[str] = Field(default_factory=list) + delete_only_without_rules: bool = True + dry_run: bool = True + + +class PricingCleanupSkippedItem(BaseModel): + pricing_category_id: str + reason: str + + +class PricingCleanupExecuteResponse(BaseModel): + scheme_id: str + dry_run: bool + delete_only_without_rules: bool + + requested_total: int = Field(ge=0) + matched_total: int = Field(ge=0) + + would_delete_count: int = Field(ge=0) + deleted_count: int = Field(ge=0) + skipped_count: int = Field(ge=0) + + would_delete_category_ids: list[str] + deleted_category_ids: list[str] + skipped: list[PricingCleanupSkippedItem] diff --git a/backend/app/services/pricing_cleanup.py b/backend/app/services/pricing_cleanup.py new file mode 100644 index 0000000..577214b --- /dev/null +++ b/backend/app/services/pricing_cleanup.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from app.repositories.pricing_cleanup import ( + delete_pricing_categories_by_ids, + list_pricing_categories_with_rule_counts, +) + + +def _matches_any_prefix(value: str | None, prefixes: list[str]) -> list[str]: + if not value: + return [] + matches: list[str] = [] + lower_value = value.lower() + for prefix in prefixes: + if lower_value.startswith(prefix.lower()): + matches.append(prefix) + return matches + + +async def build_pricing_cleanup_preview( + *, + scheme_id: str, + code_prefixes: list[str], + name_prefixes: list[str], + pricing_category_ids: list[str], + delete_only_without_rules: bool, +) -> dict: + rows = await list_pricing_categories_with_rule_counts(scheme_id=scheme_id) + requested_ids = set(pricing_category_ids) + + items: list[dict] = [] + safe_to_delete_count = 0 + + for row in rows: + matched_by: list[str] = [] + + for prefix in _matches_any_prefix(row["code"], code_prefixes): + matched_by.append(f"code_prefix:{prefix}") + + for prefix in _matches_any_prefix(row["name"], name_prefixes): + matched_by.append(f"name_prefix:{prefix}") + + if row["pricing_category_id"] in requested_ids: + matched_by.append("pricing_category_id") + + if not matched_by: + continue + + deletable = True + if delete_only_without_rules and row["rules_count"] > 0: + deletable = False + + if deletable: + safe_to_delete_count += 1 + + items.append( + { + "pricing_category_id": row["pricing_category_id"], + "name": row["name"], + "code": row["code"], + "rules_count": row["rules_count"], + "matched_by": matched_by, + "deletable": deletable, + } + ) + + return { + "scheme_id": scheme_id, + "code_prefixes": code_prefixes, + "name_prefixes": name_prefixes, + "pricing_category_ids": pricing_category_ids, + "delete_only_without_rules": delete_only_without_rules, + "total_candidates": len(items), + "safe_to_delete_count": safe_to_delete_count, + "items": items, + } + + +async def execute_pricing_cleanup( + *, + scheme_id: str, + code_prefixes: list[str], + name_prefixes: list[str], + pricing_category_ids: list[str], + delete_only_without_rules: bool, + dry_run: bool, +) -> dict: + preview = await build_pricing_cleanup_preview( + scheme_id=scheme_id, + code_prefixes=code_prefixes, + name_prefixes=name_prefixes, + pricing_category_ids=pricing_category_ids, + delete_only_without_rules=delete_only_without_rules, + ) + + deletable_items = [item for item in preview["items"] if item["deletable"]] + skipped_items = [item for item in preview["items"] if not item["deletable"]] + + would_delete_ids = [item["pricing_category_id"] for item in deletable_items] + deleted_ids: list[str] = [] + + if not dry_run and would_delete_ids: + await delete_pricing_categories_by_ids( + scheme_id=scheme_id, + pricing_category_ids=would_delete_ids, + ) + deleted_ids = list(would_delete_ids) + + return { + "scheme_id": scheme_id, + "dry_run": dry_run, + "delete_only_without_rules": delete_only_without_rules, + "requested_total": len(pricing_category_ids) + len(code_prefixes) + len(name_prefixes), + "matched_total": preview["total_candidates"], + "would_delete_count": len(would_delete_ids), + "deleted_count": 0 if dry_run else len(deleted_ids), + "skipped_count": len(skipped_items), + "would_delete_category_ids": would_delete_ids, + "deleted_category_ids": deleted_ids, + "skipped": [ + { + "pricing_category_id": item["pricing_category_id"], + "reason": "category_has_rules", + } + for item in skipped_items + ], + } diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index 6914f7c..d5388c7 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -90,6 +90,10 @@ - GET /api/v1/admin/artifacts/publish-preview/audit - POST /api/v1/admin/artifacts/publish-preview/cleanup +## app/api/routes/admin_cleanup.py +- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview +- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup + ## Notes - This file is an operational route index, not a generated OpenAPI export. - Update this map in the same change set when adding, removing, renaming, or moving routes. diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md index c5a7851..3497d70 100644 --- a/backend/docs/smoke-regression.md +++ b/backend/docs/smoke-regression.md @@ -17,6 +17,14 @@ export API_URL="http://127.0.0.1:9020" export API_KEY="admin-local-dev-key" export SCHEME_ID="82086336d385427f9d56244f9e1dd772" +## Main script + +Primary operator regression: + +`backend/scripts/smoke_regression.sh` + +The script is expected to fail fast on any contract break or unexpected 5xx. + ## 1. Health / system - GET /healthz -> 200 @@ -175,14 +183,14 @@ Validate: - GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200 - GET /api/v1/admin/artifacts/publish-preview/audit -> 200 - POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200 - -Optional: -- POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate?mode=passthrough -> 200 -- POST /api/v1/admin/display/backfill?mode=passthrough&limit=10&only_missing=true -> 200 +- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview -> 200 +- POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true -> 200 Validate: -- audit endpoint does not report orphan files or missing files for DB rows in normal state +- artifact audit does not report orphan files or missing files for DB rows in normal state - validation report is readable and deterministic +- pricing cleanup preview returns matched candidates and safe_to_delete_count +- pricing cleanup dry-run returns deleted_count=0 and would_delete_count>0 - admin routes do not produce 500 for healthy scheme state ## 13. Audit trail @@ -207,6 +215,7 @@ Regression is considered failed if any of the following happen: - draft summary / structure / validation / compare-preview returns 500 - pricing bundle or diagnostics contract changes unexpectedly - admin audit/cleanup endpoints fail on healthy environment +- pricing cleanup dry-run mutates data - artifact retention grows without bound for repeated preview refresh on same variant ## 15. Operator note @@ -220,3 +229,4 @@ Run this checklist after: - startup/import/config changes - draft lifecycle changes - publish readiness changes +- admin cleanup changes diff --git a/backend/scripts/cleanup_test_pricing_data.sh b/backend/scripts/cleanup_test_pricing_data.sh new file mode 100755 index 0000000..4a8edc5 --- /dev/null +++ b/backend/scripts/cleanup_test_pricing_data.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_URL="${API_URL:-http://127.0.0.1:9020}" +API_KEY="${API_KEY:-admin-local-dev-key}" +SCHEME_ID="${SCHEME_ID:-}" +DRY_RUN="${DRY_RUN:-true}" + +if [[ -z "${SCHEME_ID}" ]]; then + echo "SCHEME_ID is required" + exit 1 +fi + +REQUEST_BODY=$(cat <&2 - exit 1 -} +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT request() { local name="$1" local method="$2" local url="$3" - local expected="$4" + local expected_status="$4" local body="${5:-}" - local outfile="${WORKDIR}/${name}.body" - local codefile="${WORKDIR}/${name}.code" + local out_file="${TMP_DIR}/${name}.body" + local status_file="${TMP_DIR}/${name}.status" - if [[ -n "$body" ]]; then + echo + echo "===== ${name} =====" + + if [[ -n "${body}" ]]; then curl -sS \ - -X "$method" \ - "${HDR_AUTH[@]}" \ - "${HDR_JSON[@]}" \ - -o "$outfile" \ + -X "${method}" \ + -H "X-API-Key: ${API_KEY}" \ + -H "Content-Type: application/json" \ + -o "${out_file}" \ -w "%{http_code}" \ - "$url" \ - --data "$body" > "$codefile" + "${url}" \ + --data "${body}" > "${status_file}" else curl -sS \ - -X "$method" \ - "${HDR_AUTH[@]}" \ - -o "$outfile" \ + -X "${method}" \ + -H "X-API-Key: ${API_KEY}" \ + -o "${out_file}" \ -w "%{http_code}" \ - "$url" > "$codefile" + "${url}" > "${status_file}" fi - local code - code="$(cat "$codefile")" + local actual_status + actual_status="$(cat "${status_file}")" - echo "[$method] $url -> $code" - cat "$outfile" + echo "[${method}] ${url} -> ${actual_status}" + cat "${out_file}" echo - if [[ "$code" != "$expected" ]]; then - fail "Unexpected HTTP status for ${name}: expected ${expected}, got ${code}" + if [[ "${actual_status}" != "${expected_status}" ]]; then + echo "[FAIL] Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}" >&2 + exit 1 fi } json_get() { local file="$1" local expr="$2" - python3 - <&2 + exit 1 fi echo "[OK] ${expr}=${actual}" } -step "health" -curl -sS -i "${API_URL}/healthz" || fail "Health endpoint is unavailable" +assert_json_int_gt() { + local file="$1" + local expr="$2" + local threshold="$3" + local actual + actual="$(json_get "${file}" "${expr}")" + if ! [[ "${actual}" =~ ^[0-9]+$ ]]; then + echo "[FAIL] ${expr}: expected integer, got '${actual}'" >&2 + exit 1 + fi + if (( actual <= threshold )); then + echo "[FAIL] ${expr}: expected > ${threshold}, got ${actual}" >&2 + exit 1 + fi + echo "[OK] ${expr}=${actual} (> ${threshold})" +} + +echo "===== health =====" +curl -i "${API_URL}/healthz" -step "system" request "ping" "GET" "${API_URL}/api/v1/ping" "200" request "db_ping" "GET" "${API_URL}/api/v1/db/ping" "200" request "manifest" "GET" "${API_URL}/api/v1/manifest" "200" -step "scheme current" -request "current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200" -CURRENT_VERSION_ID="$(json_get "${WORKDIR}/current.body" "scheme_version_id")" -CURRENT_STATUS="$(json_get "${WORKDIR}/current.body" "status")" +request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200" +CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")" +CURRENT_STATUS="$(json_get "${TMP_DIR}/scheme_current.body" "status")" echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}" echo "CURRENT_STATUS=${CURRENT_STATUS}" -step "editor context" request "editor_context" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" "200" - -step "ensure draft" request "ensure_draft" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure" "200" -DRAFT_VERSION_ID="$(json_get "${WORKDIR}/ensure_draft.body" "scheme_version_id")" -DRAFT_CREATED="$(json_get "${WORKDIR}/ensure_draft.body" "created")" +DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")" +DRAFT_CREATED="$(json_get "${TMP_DIR}/ensure_draft.body" "created")" echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}" echo "DRAFT_CREATED=${DRAFT_CREATED}" -step "draft summary" request "draft_summary" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" -assert_json_value "${WORKDIR}/draft_summary.body" "scheme_version_id" "${DRAFT_VERSION_ID}" -assert_json_value "${WORKDIR}/draft_summary.body" "status" "draft" +assert_json_eq "${TMP_DIR}/draft_summary.body" "scheme_version_id" "${DRAFT_VERSION_ID}" +assert_json_eq "${TMP_DIR}/draft_summary.body" "status" "draft" -step "draft structure" request "draft_structure" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" -assert_json_value "${WORKDIR}/draft_structure.body" "scheme_version_id" "${DRAFT_VERSION_ID}" -SEAT_RECORD_ID="$(json_get "${WORKDIR}/draft_structure.body" "seats.0.seat_record_id")" -SECTOR_RECORD_ID="$(json_get "${WORKDIR}/draft_structure.body" "sectors.0.sector_record_id")" -GROUP_RECORD_ID="$(json_get "${WORKDIR}/draft_structure.body" "groups.0.group_record_id")" -PRICED_SEAT_ID="$(json_get "${WORKDIR}/draft_structure.body" "seats.0.seat_id")" -UNPRICED_SEAT_ID="$(json_get "${WORKDIR}/draft_structure.body" "seats.2.seat_id")" +assert_json_eq "${TMP_DIR}/draft_structure.body" "scheme_version_id" "${DRAFT_VERSION_ID}" + +SEAT_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.0.seat_record_id")" +SECTOR_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "sectors.0.sector_record_id")" +GROUP_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "groups.0.group_record_id")" +PRICED_SEAT_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.0.seat_id")" +UNPRICED_SEAT_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.2.seat_id")" + echo "SEAT_RECORD_ID=${SEAT_RECORD_ID}" echo "SECTOR_RECORD_ID=${SECTOR_RECORD_ID}" echo "GROUP_RECORD_ID=${GROUP_RECORD_ID}" echo "PRICED_SEAT_ID=${PRICED_SEAT_ID}" echo "UNPRICED_SEAT_ID=${UNPRICED_SEAT_ID}" -step "draft validation" request "draft_validation" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/validation?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" -assert_json_value "${WORKDIR}/draft_validation.body" "status" "draft" +assert_json_eq "${TMP_DIR}/draft_validation.body" "status" "draft" -step "draft compare preview" -request "draft_compare" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" +request "draft_compare_preview" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" -step "stale draft conflict" -request "draft_summary_stale" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" "409" -assert_json_value "${WORKDIR}/draft_summary_stale.body" "detail.code" "stale_draft_version" +request "stale_draft_conflict" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" "409" +assert_json_eq "${TMP_DIR}/stale_draft_conflict.body" "detail.code" "stale_draft_version" -step "draft record reads" request "draft_seat_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${SEAT_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "draft_sector_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/sectors/records/${SECTOR_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "draft_group_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/groups/records/${GROUP_RECORD_ID}?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "draft_unknown_record" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/deadbeefdeadbeefdeadbeefdeadbeef" "404" -step "structure read model" request "current_sectors" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/sectors" "200" request "current_groups" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/groups" "200" request "current_seats" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/seats" "200" +request "display_meta" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/svg/display/meta" "200" -step "svg display pipeline" -request "current_svg_meta" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/svg/display/meta" "200" - -step "pricing read model" request "pricing_bundle" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" "200" request "pricing_coverage" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/coverage" "200" request "pricing_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/unpriced-seats" "200" request "pricing_explain_priced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${PRICED_SEAT_ID}" "200" request "pricing_explain_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/explain/${UNPRICED_SEAT_ID}" "200" -request "pricing_diagnostics" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules/diagnostics" "200" +request "pricing_rule_diagnostics" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules/diagnostics" "200" request "seat_price" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current/seats/${PRICED_SEAT_ID}/price" "200" request "test_mode_priced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${PRICED_SEAT_ID}" "200" request "test_mode_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${UNPRICED_SEAT_ID}" "200" -step "typed validation errors" -request "invalid_amount" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" "422" '{"pricing_category_id":"deadbeefdeadbeefdeadbeefdeadbeef","target_type":"seat","target_ref":"seat-x","amount":"bad","currency":"RUB"}' -assert_json_value "${WORKDIR}/invalid_amount.body" "detail.code" "invalid_amount" +request "typed_invalid_amount" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" "422" '{"pricing_category_id":"4ef9e2b78fe0447f9f5db02714c7cad5","target_type":"sector","target_ref":"vip","amount":"bad","currency":"RUB"}' +assert_json_eq "${TMP_DIR}/typed_invalid_amount.body" "detail.code" "invalid_amount" -request "remap_no_filters" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/remap/preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "422" '{"seat_record_ids":null,"from_sector_id":null,"to_sector_id":"vip","from_group_id":null,"to_group_id":null}' -assert_json_value "${WORKDIR}/remap_no_filters.body" "detail.code" "remap_filter_required" +request "typed_remap_filter_required" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/remap/preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "422" '{}' +assert_json_eq "${TMP_DIR}/typed_remap_filter_required.body" "detail.code" "remap_filter_required" -step "draft pricing snapshot" -request "draft_snapshot" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" - -step "publish readiness" +request "draft_pricing_snapshot" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "publish_readiness" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-readiness?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" - -step "publish preview" request "publish_preview_refresh" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" request "publish_preview_cached" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200" -step "admin ops" request "admin_artifacts" "GET" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" "200" request "admin_validation" "GET" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" "200" request "admin_preview_audit" "GET" "${API_URL}/api/v1/admin/artifacts/publish-preview/audit" "200" -request "admin_preview_cleanup" "POST" "${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" "200" +request "admin_preview_cleanup_dry_run" "POST" "${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" "200" -step "audit trail" -request "audit" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200" +request "admin_cleanup_preview" "GET" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=FAIL_&code_prefix=DIAG_&code_prefix=AUTO_&code_prefix=TYPED_&name_prefix=should-fail-&name_prefix=diag-&name_prefix=auto%20&name_prefix=typed-response-" "200" +request "admin_cleanup_dry_run" "POST" "${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" "200" '{"code_prefixes":["FAIL_","DIAG_","AUTO_","TYPED_"],"name_prefixes":["should-fail-","diag-","auto ","typed-response-"],"delete_only_without_rules":true,"dry_run":true}' -step "done" +assert_json_eq "${TMP_DIR}/admin_cleanup_dry_run.body" "dry_run" "true" +assert_json_eq "${TMP_DIR}/admin_cleanup_dry_run.body" "deleted_count" "0" +assert_json_int_gt "${TMP_DIR}/admin_cleanup_dry_run.body" "would_delete_count" "0" + +request "audit_trail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200" + +echo +echo "===== done =====" echo "[OK] smoke regression completed successfully"