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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
55
backend/app/api/routes/admin_cleanup.py
Normal file
55
backend/app/api/routes/admin_cleanup.py
Normal 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,
|
||||
)
|
||||
95
backend/app/repositories/pricing_cleanup.py
Normal file
95
backend/app/repositories/pricing_cleanup.py
Normal 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)
|
||||
51
backend/app/schemas/admin_cleanup.py
Normal file
51
backend/app/schemas/admin_cleanup.py
Normal 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]
|
||||
127
backend/app/services/pricing_cleanup.py
Normal file
127
backend/app/services/pricing_cleanup.py
Normal 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
|
||||
],
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
38
backend/scripts/cleanup_test_pricing_data.sh
Executable file
38
backend/scripts/cleanup_test_pricing_data.sh
Executable 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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user