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

0
0 Normal file
View File

0
0, Normal file
View File

0
200 Normal file
View File

0
404 Normal file
View File

0
409 Normal file
View File

0
422 Normal file
View File

View File

@@ -1,6 +1,6 @@
# svg-service backend # 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 ## Stack
@@ -32,16 +32,15 @@ Default local admin key:
## Core lifecycle ## Core lifecycle
The backend works with a scheme lifecycle:
1. Upload SVG 1. Upload SVG
2. Normalize and persist structure 2. Normalize and persist structure
3. Work in current draft 3. Enter editor flow through context + ensure draft
4. Create / update pricing 4. Edit sectors / groups / seats in current draft
5. Build pricing snapshot 5. Configure pricing and inspect diagnostics
6. Inspect publish preview / readiness 6. Build pricing snapshot
7. Publish current draft 7. Inspect publish readiness and publish preview
8. If editing is needed after publish, create or ensure a new draft again 8. Publish current draft
9. If editing is needed after publish, create or ensure a new draft again
## Main concepts ## Main concepts
@@ -49,23 +48,19 @@ The backend works with a scheme lifecycle:
Top-level business entity. Top-level business entity.
### Scheme version ### Scheme version
Concrete version of the scheme. Concrete version of the scheme. A version can be `draft` or `published`.
A version can be `draft` or `published`.
### Current version ### Current version
The version referenced by the scheme registry as active current. The version referenced by the scheme registry as active current.
### Draft ### Draft
Editable current version. Editable current version. All editor mutations and draft pricing operations must target a current draft version only.
All editor mutations and draft pricing operations must target a current draft version only.
### Published version ### Published version
Non-editable current version. Non-editable current version. If current version is published, editor flow must first create or ensure a new draft.
If current version is published, editor flow must first create or ensure a new draft.
### Upload artifacts ### Upload artifacts
Stored technical artifacts, including: Stored technical artifacts, including:
- original svg - original svg
- sanitized svg - sanitized svg
- normalized json - normalized json
@@ -74,30 +69,23 @@ Stored technical artifacts, including:
## Editor entry flow ## Editor entry flow
Use this flow from frontend or operator scripts.
### 1. Inspect editor state ### 1. Inspect editor state
`GET /api/v1/schemes/{scheme_id}/editor/context` `GET /api/v1/schemes/{scheme_id}/editor/context`
Response tells whether: Response tells whether:
- current version is draft - current version is draft
- editor is available - editor is available
- a new draft should be created - a new draft should be created
- recommended action is `use_current_draft` or `create_draft` - recommended action is `use_current_draft` or `create_draft`
### 2. Ensure editable draft ### 2. Ensure editable draft
`POST /api/v1/schemes/{scheme_id}/draft/ensure` `POST /api/v1/schemes/{scheme_id}/draft/ensure`
Behavior: Behavior:
- if current version is already draft: returns it with `created=false` - 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` - 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` - `expected_scheme_version_id`
for draft reads and mutations. for draft reads and mutations.
@@ -105,14 +93,10 @@ for draft reads and mutations.
## Optimistic concurrency ## Optimistic concurrency
Mutable draft flows support optimistic concurrency through query params: Mutable draft flows support optimistic concurrency through query params:
- `expected_current_scheme_version_id` - `expected_current_scheme_version_id`
- `expected_scheme_version_id` - `expected_scheme_version_id`
These guards prevent frontend/editor from mutating a stale draft after another version switch. Typical typed conflicts:
Typical typed conflict payload:
- `stale_current_version` - `stale_current_version`
- `stale_draft_version` - `stale_draft_version`
- `draft_not_editable` - `draft_not_editable`
@@ -120,22 +104,19 @@ Typical typed conflict payload:
## Main operator routes ## Main operator routes
## System ### System
- `GET /healthz` - `GET /healthz`
- `GET /api/v1/ping` - `GET /api/v1/ping`
- `GET /api/v1/db/ping` - `GET /api/v1/db/ping`
- `GET /api/v1/manifest` - `GET /api/v1/manifest`
## Uploads ### Uploads
- `POST /api/v1/schemes/upload` - `POST /api/v1/schemes/upload`
- `GET /api/v1/uploads` - `GET /api/v1/uploads`
- `GET /api/v1/uploads/{upload_id}` - `GET /api/v1/uploads/{upload_id}`
- `GET /api/v1/uploads/{upload_id}/normalized` - `GET /api/v1/uploads/{upload_id}/normalized`
## Scheme registry ### Scheme registry
- `GET /api/v1/schemes` - `GET /api/v1/schemes`
- `GET /api/v1/schemes/{scheme_id}` - `GET /api/v1/schemes/{scheme_id}`
- `GET /api/v1/schemes/{scheme_id}/current` - `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}/unpublish`
- `POST /api/v1/schemes/{scheme_id}/rollback` - `POST /api/v1/schemes/{scheme_id}/rollback`
## Editor / draft ### Editor / draft
- `GET /api/v1/schemes/{scheme_id}/editor/context` - `GET /api/v1/schemes/{scheme_id}/editor/context`
- `POST /api/v1/schemes/{scheme_id}/draft/ensure` - `POST /api/v1/schemes/{scheme_id}/draft/ensure`
- `GET /api/v1/schemes/{scheme_id}/draft/summary` - `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}` - `PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
- `POST /api/v1/schemes/{scheme_id}/draft/repair-references` - `POST /api/v1/schemes/{scheme_id}/draft/repair-references`
## Pricing ### Pricing
- `GET /api/v1/schemes/{scheme_id}/pricing` - `GET /api/v1/schemes/{scheme_id}/pricing`
- `POST /api/v1/schemes/{scheme_id}/pricing/categories` - `POST /api/v1/schemes/{scheme_id}/pricing/categories`
- `PUT /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}` - `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}` - `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
- `DELETE /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/coverage`
- `GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats` - `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/explain/{seat_id}`
- `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics` - `GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics`
## Publish preview ### Publish preview
- `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot` - `POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot`
- `GET /api/v1/schemes/{scheme_id}/draft/publish-preview` - `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/preview`
- `POST /api/v1/schemes/{scheme_id}/draft/remap/apply` - `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/sectors`
- `GET /api/v1/schemes/{scheme_id}/current/groups` - `GET /api/v1/schemes/{scheme_id}/current/groups`
- `GET /api/v1/schemes/{scheme_id}/current/seats` - `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`
- `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta` - `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta`
## Test mode ### Test mode
- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}` - `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}`
## Audit ### Audit
- `GET /api/v1/schemes/{scheme_id}/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/artifacts`
- `GET /api/v1/admin/schemes/{scheme_id}/current/validation` - `GET /api/v1/admin/schemes/{scheme_id}/current/validation`
- `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate` - `POST /api/v1/admin/schemes/{scheme_id}/current/display/regenerate`
- `POST /api/v1/admin/display/backfill` - `POST /api/v1/admin/display/backfill`
- `GET /api/v1/admin/artifacts/publish-preview/audit` - `GET /api/v1/admin/artifacts/publish-preview/audit`
- `POST /api/v1/admin/artifacts/publish-preview/cleanup` - `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 ## Typical local flow
## 1. Read current version ### 1. Read current version
Use:
`GET /api/v1/schemes/{scheme_id}/current` `GET /api/v1/schemes/{scheme_id}/current`
## 2. Ensure draft ### 2. Ensure draft
Use:
`POST /api/v1/schemes/{scheme_id}/draft/ensure` `POST /api/v1/schemes/{scheme_id}/draft/ensure`
Store returned: Store returned:
- `scheme_version_id` - `scheme_version_id`
## 3. Read draft state ### 3. Read draft state
Use:
- `GET /draft/summary?expected_scheme_version_id=...` - `GET /draft/summary?expected_scheme_version_id=...`
- `GET /draft/structure?expected_scheme_version_id=...` - `GET /draft/structure?expected_scheme_version_id=...`
- `GET /draft/validation?expected_scheme_version_id=...` - `GET /draft/validation?expected_scheme_version_id=...`
- `GET /draft/compare-preview?expected_scheme_version_id=...` - `GET /draft/compare-preview?expected_scheme_version_id=...`
## 4. Perform editor mutations ### 4. Perform editor mutations
Pass: Pass:
- `expected_scheme_version_id={draft_scheme_version_id}` - `expected_scheme_version_id={draft_scheme_version_id}`
on every mutation route. on every mutation route.
## 5. Inspect pricing quality ### 5. Inspect pricing quality
Use:
- `GET /pricing/coverage` - `GET /pricing/coverage`
- `GET /pricing/unpriced-seats` - `GET /pricing/unpriced-seats`
- `GET /pricing/explain/{seat_id}` - `GET /pricing/explain/{seat_id}`
- `GET /pricing/rules/diagnostics` - `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 fastapi import APIRouter
from app.api.routes.admin import router as admin_router 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.audit import router as audit_router
from app.api.routes.editor import router as editor_router from app.api.routes.editor import router as editor_router
from app.api.routes.pricing import router as pricing_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(test_mode_router)
router.include_router(audit_router) router.include_router(audit_router)
router.include_router(admin_router) router.include_router(admin_router)
router.include_router(admin_cleanup_router)
router.include_router(editor_router) router.include_router(editor_router)
router.include_router(publish_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 - GET /api/v1/admin/artifacts/publish-preview/audit
- POST /api/v1/admin/artifacts/publish-preview/cleanup - 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 ## Notes
- This file is an operational route index, not a generated OpenAPI export. - 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. - 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 API_KEY="admin-local-dev-key"
export SCHEME_ID="82086336d385427f9d56244f9e1dd772" 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 ## 1. Health / system
- GET /healthz -> 200 - GET /healthz -> 200
@@ -175,14 +183,14 @@ Validate:
- GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200 - GET /api/v1/admin/schemes/{scheme_id}/current/validation -> 200
- GET /api/v1/admin/artifacts/publish-preview/audit -> 200 - GET /api/v1/admin/artifacts/publish-preview/audit -> 200
- POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200 - POST /api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true -> 200
- GET /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup-preview -> 200
Optional: - POST /api/v1/admin/schemes/{scheme_id}/pricing/categories/cleanup with dry_run=true -> 200
- 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
Validate: 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 - 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 - admin routes do not produce 500 for healthy scheme state
## 13. Audit trail ## 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 - draft summary / structure / validation / compare-preview returns 500
- pricing bundle or diagnostics contract changes unexpectedly - pricing bundle or diagnostics contract changes unexpectedly
- admin audit/cleanup endpoints fail on healthy environment - 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 - artifact retention grows without bound for repeated preview refresh on same variant
## 15. Operator note ## 15. Operator note
@@ -220,3 +229,4 @@ Run this checklist after:
- startup/import/config changes - startup/import/config changes
- draft lifecycle changes - draft lifecycle changes
- publish readiness 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 #!/usr/bin/env bash
set -Eeuo pipefail set -euo pipefail
API_URL="${API_URL:-http://127.0.0.1:9020}" API_URL="${API_URL:-http://127.0.0.1:9020}"
API_KEY="${API_KEY:-admin-local-dev-key}" API_KEY="${API_KEY:-admin-local-dev-key}"
SCHEME_ID="${SCHEME_ID:-82086336d385427f9d56244f9e1dd772}" SCHEME_ID="${SCHEME_ID:-82086336d385427f9d56244f9e1dd772}"
WORKDIR="$(mktemp -d)" TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT trap 'rm -rf "${TMP_DIR}"' 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
}
request() { request() {
local name="$1" local name="$1"
local method="$2" local method="$2"
local url="$3" local url="$3"
local expected="$4" local expected_status="$4"
local body="${5:-}" local body="${5:-}"
local outfile="${WORKDIR}/${name}.body" local out_file="${TMP_DIR}/${name}.body"
local codefile="${WORKDIR}/${name}.code" local status_file="${TMP_DIR}/${name}.status"
if [[ -n "$body" ]]; then echo
echo "===== ${name} ====="
if [[ -n "${body}" ]]; then
curl -sS \ curl -sS \
-X "$method" \ -X "${method}" \
"${HDR_AUTH[@]}" \ -H "X-API-Key: ${API_KEY}" \
"${HDR_JSON[@]}" \ -H "Content-Type: application/json" \
-o "$outfile" \ -o "${out_file}" \
-w "%{http_code}" \ -w "%{http_code}" \
"$url" \ "${url}" \
--data "$body" > "$codefile" --data "${body}" > "${status_file}"
else else
curl -sS \ curl -sS \
-X "$method" \ -X "${method}" \
"${HDR_AUTH[@]}" \ -H "X-API-Key: ${API_KEY}" \
-o "$outfile" \ -o "${out_file}" \
-w "%{http_code}" \ -w "%{http_code}" \
"$url" > "$codefile" "${url}" > "${status_file}"
fi fi
local code local actual_status
code="$(cat "$codefile")" actual_status="$(cat "${status_file}")"
echo "[$method] $url -> $code" echo "[${method}] ${url} -> ${actual_status}"
cat "$outfile" cat "${out_file}"
echo echo
if [[ "$code" != "$expected" ]]; then if [[ "${actual_status}" != "${expected_status}" ]]; then
fail "Unexpected HTTP status for ${name}: expected ${expected}, got ${code}" echo "[FAIL] Unexpected HTTP status for ${name}: expected ${expected_status}, got ${actual_status}" >&2
exit 1
fi fi
} }
json_get() { json_get() {
local file="$1" local file="$1"
local expr="$2" local expr="$2"
python3 - <<PY python3 - "$file" "$expr" <<'PY'
import json import json
from pathlib import Path import sys
payload = json.loads(Path("$file").read_text()) path = sys.argv[2].split(".")
value = payload value = json.load(open(sys.argv[1], "r", encoding="utf-8"))
for part in "$expr".split("."): for part in path:
if not part:
continue
if part.isdigit(): if part.isdigit():
value = value[int(part)] value = value[int(part)]
else: else:
value = value[part] 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 PY
} }
assert_json_value() { assert_json_eq() {
local file="$1" local file="$1"
local expr="$2" local expr="$2"
local expected="$3" local expected="$3"
local actual local actual
actual="$(json_get "$file" "$expr")" actual="$(json_get "${file}" "${expr}")"
if [[ "$actual" != "$expected" ]]; then if [[ "${actual}" != "${expected}" ]]; then
fail "JSON assertion failed for ${expr}: expected '${expected}', got '${actual}'" echo "[FAIL] ${expr}: expected '${expected}', got '${actual}'" >&2
exit 1
fi fi
echo "[OK] ${expr}=${actual}" echo "[OK] ${expr}=${actual}"
} }
step "health" assert_json_int_gt() {
curl -sS -i "${API_URL}/healthz" || fail "Health endpoint is unavailable" 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 "ping" "GET" "${API_URL}/api/v1/ping" "200"
request "db_ping" "GET" "${API_URL}/api/v1/db/ping" "200" request "db_ping" "GET" "${API_URL}/api/v1/db/ping" "200"
request "manifest" "GET" "${API_URL}/api/v1/manifest" "200" request "manifest" "GET" "${API_URL}/api/v1/manifest" "200"
step "scheme current" request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
request "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_VERSION_ID="$(json_get "${WORKDIR}/current.body" "scheme_version_id")" CURRENT_STATUS="$(json_get "${TMP_DIR}/scheme_current.body" "status")"
CURRENT_STATUS="$(json_get "${WORKDIR}/current.body" "status")"
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}" echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
echo "CURRENT_STATUS=${CURRENT_STATUS}" echo "CURRENT_STATUS=${CURRENT_STATUS}"
step "editor context"
request "editor_context" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" "200" 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" 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_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
DRAFT_CREATED="$(json_get "${WORKDIR}/ensure_draft.body" "created")" DRAFT_CREATED="$(json_get "${TMP_DIR}/ensure_draft.body" "created")"
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}" echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
echo "DRAFT_CREATED=${DRAFT_CREATED}" 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" 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_eq "${TMP_DIR}/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" "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" 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}" assert_json_eq "${TMP_DIR}/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")" SEAT_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "seats.0.seat_record_id")"
GROUP_RECORD_ID="$(json_get "${WORKDIR}/draft_structure.body" "groups.0.group_record_id")" SECTOR_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "sectors.0.sector_record_id")"
PRICED_SEAT_ID="$(json_get "${WORKDIR}/draft_structure.body" "seats.0.seat_id")" GROUP_RECORD_ID="$(json_get "${TMP_DIR}/draft_structure.body" "groups.0.group_record_id")"
UNPRICED_SEAT_ID="$(json_get "${WORKDIR}/draft_structure.body" "seats.2.seat_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 "SEAT_RECORD_ID=${SEAT_RECORD_ID}"
echo "SECTOR_RECORD_ID=${SECTOR_RECORD_ID}" echo "SECTOR_RECORD_ID=${SECTOR_RECORD_ID}"
echo "GROUP_RECORD_ID=${GROUP_RECORD_ID}" echo "GROUP_RECORD_ID=${GROUP_RECORD_ID}"
echo "PRICED_SEAT_ID=${PRICED_SEAT_ID}" echo "PRICED_SEAT_ID=${PRICED_SEAT_ID}"
echo "UNPRICED_SEAT_ID=${UNPRICED_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" 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_preview" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
request "draft_compare" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
step "stale draft conflict" request "stale_draft_conflict" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/summary?expected_scheme_version_id=deadbeefdeadbeefdeadbeefdeadbeef" "409"
request "draft_summary_stale" "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"
assert_json_value "${WORKDIR}/draft_summary_stale.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_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_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_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" 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_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_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 "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_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_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_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_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_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 "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_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" request "test_mode_unpriced" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/test/seats/${UNPRICED_SEAT_ID}" "200"
step "typed validation errors" 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"}'
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_eq "${TMP_DIR}/typed_invalid_amount.body" "detail.code" "invalid_amount"
assert_json_value "${WORKDIR}/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}' 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_value "${WORKDIR}/remap_no_filters.body" "detail.code" "remap_filter_required" assert_json_eq "${TMP_DIR}/typed_remap_filter_required.body" "detail.code" "remap_filter_required"
step "draft pricing snapshot" request "draft_pricing_snapshot" "POST" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" "200"
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 "publish_readiness" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-readiness?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_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" 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_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_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_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 "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 "audit" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "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" echo "[OK] smoke regression completed successfully"