feat(backend): add operational smoke tooling and safe pricing cleanup endpoints

- add backend README and refresh API map and smoke regression docs
- add full backend smoke regression script
- add admin pricing cleanup preview and dry-run endpoints
- add helper script for test pricing cleanup
- verify typed error contracts, draft flow, publish readiness and preview flows
- verify publish preview retention and clean backend startup behavior
This commit is contained in:
greebo
2026-03-19 22:54:12 +03:00
parent 127c5bff71
commit 0f9c2a1cbd
16 changed files with 551 additions and 235 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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
],
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <<JSON
{
"code_prefixes": ["FAIL_", "DIAG_", "AUTO_", "TYPED_"],
"name_prefixes": ["should-fail-", "diag-", "auto ", "typed-response-"],
"pricing_category_ids": [],
"delete_only_without_rules": true,
"dry_run": ${DRY_RUN}
}
JSON
)
echo "===== CLEANUP PREVIEW ====="
curl -sS \
-H "X-API-Key: ${API_KEY}" \
"${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-" \
| python3 -m json.tool
echo
echo "===== CLEANUP EXECUTE (DRY_RUN=${DRY_RUN}) ====="
curl -sS -X POST \
-H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-d "${REQUEST_BODY}" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
| python3 -m json.tool

View File

@@ -1,201 +1,203 @@
#!/usr/bin/env bash
set -Eeuo pipefail
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:-82086336d385427f9d56244f9e1dd772}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
HDR_AUTH=(-H "X-API-Key: ${API_KEY}")
HDR_JSON=(-H "Content-Type: application/json")
step() {
echo
echo "===== $1 ====="
}
fail() {
echo
echo "[FAIL] $1" >&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 - <<PY
python3 - "$file" "$expr" <<'PY'
import json
from pathlib import Path
import sys
payload = json.loads(Path("$file").read_text())
value = payload
for part in "$expr".split("."):
path = sys.argv[2].split(".")
value = json.load(open(sys.argv[1], "r", encoding="utf-8"))
for part in path:
if not part:
continue
if part.isdigit():
value = value[int(part)]
else:
value = value[part]
print(value if value is not None else "")
if isinstance(value, bool):
print("true" if value else "false")
elif value is None:
print("null")
else:
print(value)
PY
}
assert_json_value() {
assert_json_eq() {
local file="$1"
local expr="$2"
local expected="$3"
local actual
actual="$(json_get "$file" "$expr")"
if [[ "$actual" != "$expected" ]]; then
fail "JSON assertion failed for ${expr}: expected '${expected}', got '${actual}'"
actual="$(json_get "${file}" "${expr}")"
if [[ "${actual}" != "${expected}" ]]; then
echo "[FAIL] ${expr}: expected '${expected}', got '${actual}'" >&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"