feat(backend): stabilize draft editor flow and complete smoke regression baseline

- add editor entry flow with editor context and ensure-draft bootstrap
- add draft summary read model and single-record draft read endpoints
- add typed draft, edit and publish conflicts with validation errors
- add pricing diagnostics and publish readiness endpoints
- fix Decimal serialization in seat price and test preview flows
- harden draft lifecycle guards for published vs draft current version
- update API map and smoke regression checklist
- add backend README and smoke regression script
This commit is contained in:
greebo
2026-03-19 22:23:46 +03:00
parent 77496dac46
commit 127c5bff71
5 changed files with 681 additions and 49 deletions

330
backend/README.md Normal file
View File

@@ -0,0 +1,330 @@
# svg-service backend
Backend for SVG scheme upload, draft editing, pricing, publish preview, and publish lifecycle.
## Stack
- Python 3.11
- FastAPI
- SQLAlchemy async
- PostgreSQL 16
- Docker Compose
## Runtime
Default backend port: `9020`
Health check:
- `GET /healthz`
Main API prefix:
- `/api/v1`
Auth header:
- `X-API-Key`
Default local admin key:
- `admin-local-dev-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
## Main concepts
### Scheme
Top-level business entity.
### Scheme version
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.
### Published version
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
- display svg
- publish preview json
## 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:
- `expected_scheme_version_id`
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:
- `stale_current_version`
- `stale_draft_version`
- `draft_not_editable`
- `publish_not_ready`
## Main operator routes
## System
- `GET /healthz`
- `GET /api/v1/ping`
- `GET /api/v1/db/ping`
- `GET /api/v1/manifest`
## 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
- `GET /api/v1/schemes`
- `GET /api/v1/schemes/{scheme_id}`
- `GET /api/v1/schemes/{scheme_id}/current`
- `GET /api/v1/schemes/{scheme_id}/versions`
- `POST /api/v1/schemes/{scheme_id}/versions`
- `GET /api/v1/schemes/{scheme_id}/publish/validation`
- `GET /api/v1/schemes/{scheme_id}/draft/publish-readiness`
- `POST /api/v1/schemes/{scheme_id}/publish`
- `POST /api/v1/schemes/{scheme_id}/unpublish`
- `POST /api/v1/schemes/{scheme_id}/rollback`
## 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`
- `GET /api/v1/schemes/{scheme_id}/draft/structure`
- `GET /api/v1/schemes/{scheme_id}/draft/validation`
- `GET /api/v1/schemes/{scheme_id}/draft/compare-preview`
- `GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}`
- `GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}`
- `GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
- `POST /api/v1/schemes/{scheme_id}/draft/sectors`
- `POST /api/v1/schemes/{scheme_id}/draft/groups`
- `DELETE /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}`
- `DELETE /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
- `PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}`
- `POST /api/v1/schemes/{scheme_id}/draft/seats/bulk`
- `PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}`
- `PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}`
- `POST /api/v1/schemes/{scheme_id}/draft/repair-references`
## 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}`
- `DELETE /api/v1/schemes/{scheme_id}/pricing/categories/{pricing_category_id}`
- `POST /api/v1/schemes/{scheme_id}/pricing/rules`
- `PUT /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
- `DELETE /api/v1/schemes/{scheme_id}/pricing/rules/{price_rule_id}`
## 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
- `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
- `GET /api/v1/schemes/{scheme_id}/current/sectors`
- `GET /api/v1/schemes/{scheme_id}/current/groups`
- `GET /api/v1/schemes/{scheme_id}/current/seats`
- `GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price`
- `GET /api/v1/schemes/{scheme_id}/current/svg`
- `GET /api/v1/schemes/{scheme_id}/current/svg/display`
- `GET /api/v1/schemes/{scheme_id}/current/svg/display/meta`
## Test mode
- `GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id}`
## Audit
- `GET /api/v1/schemes/{scheme_id}/audit`
## 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`
## Typical local flow
## 1. Read current version
Use:
`GET /api/v1/schemes/{scheme_id}/current`
## 2. Ensure draft
Use:
`POST /api/v1/schemes/{scheme_id}/draft/ensure`
Store returned:
- `scheme_version_id`
## 3. Read draft state
Use:
- `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
Pass:
- `expected_scheme_version_id={draft_scheme_version_id}`
on every mutation route.
## 5. Inspect pricing quality
Use:
- `GET /pricing/coverage`
- `GET /pricing/unpriced-seats`
- `GET /pricing/explain/{seat_id}`
- `GET /pricing/rules/diagnostics`
## 6. Create pricing snapshot
Use:
`POST /draft/pricing/snapshot?expected_scheme_version_id=...`
## 7. Inspect readiness
Use:
`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

@@ -244,7 +244,7 @@ async def get_effective_seat_price(scheme_id: str, seat_id: str, role: str = Dep
matched_rule_level=matched_rule_level,
matched_target_ref=rule["target_ref"],
pricing_category_id=rule["pricing_category_id"],
amount=rule["amount"],
amount=str(rule["amount"]),
currency=rule["currency"],
)

View File

@@ -21,6 +21,7 @@
- GET /api/v1/schemes/{scheme_id}/versions
- POST /api/v1/schemes/{scheme_id}/versions
- GET /api/v1/schemes/{scheme_id}/publish/validation
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness
- POST /api/v1/schemes/{scheme_id}/publish
- POST /api/v1/schemes/{scheme_id}/unpublish
- POST /api/v1/schemes/{scheme_id}/rollback
@@ -58,7 +59,6 @@
## app/api/routes/publish.py
- 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-readiness
- POST /api/v1/schemes/{scheme_id}/draft/remap/preview
- POST /api/v1/schemes/{scheme_id}/draft/remap/apply
@@ -94,4 +94,4 @@
- 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.
- Query guards such as expected_current_scheme_version_id / expected_scheme_version_id are part of the operational contract for optimistic concurrency on mutable flows.
- Draft/editor routes may legally return 409 draft_not_editable when current version is already published and no editable draft exists yet.
- Draft editor flow starts from editor/context and draft/ensure, not from direct blind mutation calls.

View File

@@ -31,19 +31,78 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772"
- GET /api/v1/schemes/{scheme_id}/current -> 200
- GET /api/v1/schemes/{scheme_id}/versions -> 200
## 3. Structure read model
Validate:
- scheme_id is stable
- current version exists
- version list contains current version
- status and counts are consistent
## 3. Editor entry flow
- GET /api/v1/schemes/{scheme_id}/editor/context -> 200
- POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200
Validate:
- editor context returns current_scheme_version_id
- editor context distinguishes draft vs published state correctly
- ensure endpoint is idempotent on current draft
- ensure endpoint creates a new draft from published current when needed
- returned scheme_version_id is reusable as expected_scheme_version_id
## 4. Draft read model
Using current draft version id from draft/ensure:
- GET /api/v1/schemes/{scheme_id}/draft/summary?expected_scheme_version_id={draft_version_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/structure?expected_scheme_version_id={draft_version_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/validation?expected_scheme_version_id={draft_version_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/compare-preview?expected_scheme_version_id={draft_version_id} -> 200
Validate:
- summary returns total_seats / total_sectors / total_groups
- summary returns validation_summary / structure_diff_summary / publish_readiness
- structure returns lists for seats / sectors / groups
- validation is deterministic
- compare preview returns stable diff structure
- stale expected_scheme_version_id returns typed 409 conflict
## 5. Draft entity reads
- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200
Validate:
- record endpoints return exact draft entities
- unknown record id returns 404
- stale expected_scheme_version_id returns typed 409 conflict
## 6. Structure read model
- GET /api/v1/schemes/{scheme_id}/current/sectors -> 200
- GET /api/v1/schemes/{scheme_id}/current/groups -> 200
- GET /api/v1/schemes/{scheme_id}/current/seats -> 200
## 4. SVG / display pipeline
Validate:
- total counts are non-negative
- known sample scheme returns expected object lists
- seats contain seat_id / sector_id / group_id contract where applicable
## 7. SVG / display pipeline
- GET /api/v1/schemes/{scheme_id}/current/svg -> 200
- GET /api/v1/schemes/{scheme_id}/current/svg/display -> 200
- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta -> 200
- GET /api/v1/schemes/{scheme_id}/current/svg/display?mode=optimized -> 200 or explicit controlled failure
- GET /api/v1/schemes/{scheme_id}/current/svg/display/meta?mode=optimized -> 200 or explicit controlled failure
## 5. Pricing read model / diagnostics
Validate:
- response content type for svg endpoints is image/svg+xml
- meta returns scheme_id, scheme_version_id, view_box, width, height
- no 500 on passthrough mode
- unsupported mode returns 422
## 8. Pricing read model
- GET /api/v1/schemes/{scheme_id}/pricing -> 200
- GET /api/v1/schemes/{scheme_id}/pricing/coverage -> 200
@@ -51,71 +110,113 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772"
- GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id} -> 200
- GET /api/v1/schemes/{scheme_id}/pricing/rules/diagnostics -> 200
- GET /api/v1/schemes/{scheme_id}/current/seats/{seat_id}/price -> 200 for priced seat
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for priced and unpriced seat
## 6. Editor entry workflow
- GET /api/v1/schemes/{scheme_id}/editor/context -> 200 always
- if context.needs_new_draft=true -> POST /api/v1/schemes/{scheme_id}/draft/ensure -> 200
- after ensure -> GET /api/v1/schemes/{scheme_id}/editor/context -> 200 and editable=true
- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for known seat
Validate:
- published current version does not break editor bootstrap
- ensure returns created_new_draft=true when current was published
- ensure returns created_new_draft=false when current was already draft
- pricing bundle contains categories and rules arrays
- coverage values are internally consistent
- unpriced seats list explains reason_code / reason_message
- explain endpoint shows matched rule for priced seat and null for unpriced seat
- diagnostics returns orphan/active rule visibility
- test seat preview explains selectable / has_price state
- priced test seat amount is serialized as string
## 7. Draft editor read model
## 9. Draft mutations and validation guards
- GET /api/v1/schemes/{scheme_id}/draft/summary -> 200 when current version is draft
- GET /api/v1/schemes/{scheme_id}/draft/structure -> 200 when current version is draft
- GET /api/v1/schemes/{scheme_id}/draft/validation -> 200 when current version is draft
- GET /api/v1/schemes/{scheme_id}/draft/compare-preview -> 200 when current version is draft
- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200
- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200
For current draft version:
## 8. Draft mutations
- POST /api/v1/schemes/{scheme_id}/draft/sectors -> 200 or typed 422 conflict
- POST /api/v1/schemes/{scheme_id}/draft/groups -> 200 or typed 422 conflict
- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 or typed 422 validation error
- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk -> 200 or typed 422 validation error
- PATCH /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200
- PATCH /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200
- POST /api/v1/schemes/{scheme_id}/draft/sectors -> 200 or 422
- POST /api/v1/schemes/{scheme_id}/draft/groups -> 200 or 422
- PATCH /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 or 422
- POST /api/v1/schemes/{scheme_id}/draft/seats/bulk -> 200 or 422
- POST /api/v1/schemes/{scheme_id}/draft/remap/preview -> 200 or 422
- POST /api/v1/schemes/{scheme_id}/draft/remap/apply -> 200 or 422
- POST /api/v1/schemes/{scheme_id}/draft/repair-references -> 200
- POST /api/v1/schemes/{scheme_id}/draft/remap/preview -> 200 or typed 422 validation error
- POST /api/v1/schemes/{scheme_id}/draft/remap/apply -> 200 or typed 422 validation error
## 9. Publish preview / readiness
Validate:
- duplicate ids return typed 422
- duplicate element binding returns typed 422
- unknown sector/group references return typed 422
- remap without filters returns typed 422
- stale expected_scheme_version_id returns typed 409
- published current version rejects draft mutations with typed draft_not_editable conflict
## 10. Draft publish preview
- GET /api/v1/schemes/{scheme_id}/publish/validation -> 200
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 when current version is draft
- POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot -> 200 when scheme is in draft
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true -> 200
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview -> 200
- GET /api/v1/schemes/{scheme_id}/draft/publish-preview?refresh=true&baseline_scheme_version_id={published_version_id} -> 200
## 10. Publish lifecycle
Validate:
- refresh and cached read both succeed
- preview summary contains is_publishable / has_structure_changes / has_artifacts / snapshot_available
- pricing_coverage is internally consistent
- baseline override returns override strategy when explicit baseline is provided
- preview retention does not grow unbounded for same version+variant
- POST /api/v1/schemes/{scheme_id}/publish -> 200 when draft is ready
- POST /api/v1/schemes/{scheme_id}/publish with stale expected_scheme_version_id -> 409
- POST /api/v1/schemes/{scheme_id}/unpublish -> 200
- POST /api/v1/schemes/{scheme_id}/rollback -> 200
## 11. Publish readiness and publish flow
## 11. Admin / ops
For current draft version:
- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200
- POST /api/v1/schemes/{scheme_id}/publish?expected_scheme_version_id={draft_version_id} -> 200 or 409
Validate:
- readiness explicitly shows snapshot_available and pricing gate state
- publish with stale expected version returns typed 409
- publish without draft state returns typed 409
- publish success updates current status to published
- audit trail contains scheme.published event
## 12. Admin / ops
- GET /api/v1/admin/schemes/{scheme_id}/current/artifacts -> 200
- 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
## 12. Audit trail
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
Validate:
- audit endpoint does not report orphan files or missing files for DB rows in normal state
- validation report is readable and deterministic
- admin routes do not produce 500 for healthy scheme state
## 13. Audit trail
- GET /api/v1/schemes/{scheme_id}/audit -> 200
## 13. Fail criteria
Validate:
- recent publish preview / pricing / version / publish events are present when corresponding operations were run
- audit total is non-negative
- event payloads stay JSON-serializable
Regression is considered failed if:
## 14. Fail criteria
Regression is considered failed if any of the following happen:
- health or db ping fails
- any stable read endpoint returns 500
- published current version cannot be converted to draft through ensure flow
- draft editor summary/read endpoints return inconsistent data
- publish-state mutation guard is bypassed
- passthrough display endpoint fails on known-good sample
- publish preview refresh or cached read returns 500
- publish readiness returns 500
- editor context or draft ensure returns 500
- draft summary / structure / validation / compare-preview returns 500
- pricing bundle or diagnostics contract changes unexpectedly
- admin audit/cleanup endpoints fail on healthy environment
- artifact retention grows without bound for repeated preview refresh on same variant
## 15. Operator note
Run this checklist after:
- schema changes
- pricing schema/repository refactors
- artifact lifecycle changes
- display pipeline changes
- route reorganization
- startup/import/config changes
- draft lifecycle changes
- publish readiness changes

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -Eeuo 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
}
request() {
local name="$1"
local method="$2"
local url="$3"
local expected="$4"
local body="${5:-}"
local outfile="${WORKDIR}/${name}.body"
local codefile="${WORKDIR}/${name}.code"
if [[ -n "$body" ]]; then
curl -sS \
-X "$method" \
"${HDR_AUTH[@]}" \
"${HDR_JSON[@]}" \
-o "$outfile" \
-w "%{http_code}" \
"$url" \
--data "$body" > "$codefile"
else
curl -sS \
-X "$method" \
"${HDR_AUTH[@]}" \
-o "$outfile" \
-w "%{http_code}" \
"$url" > "$codefile"
fi
local code
code="$(cat "$codefile")"
echo "[$method] $url -> $code"
cat "$outfile"
echo
if [[ "$code" != "$expected" ]]; then
fail "Unexpected HTTP status for ${name}: expected ${expected}, got ${code}"
fi
}
json_get() {
local file="$1"
local expr="$2"
python3 - <<PY
import json
from pathlib import Path
payload = json.loads(Path("$file").read_text())
value = payload
for part in "$expr".split("."):
if part.isdigit():
value = value[int(part)]
else:
value = value[part]
print(value if value is not None else "")
PY
}
assert_json_value() {
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}'"
fi
echo "[OK] ${expr}=${actual}"
}
step "health"
curl -sS -i "${API_URL}/healthz" || fail "Health endpoint is unavailable"
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")"
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")"
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"
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")"
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"
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"
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"
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"
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 "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 "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"
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 "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"
step "audit trail"
request "audit" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200"
step "done"
echo "[OK] smoke regression completed successfully"