From 8d4255181b2b3c01bab79d888119da275fb0f3d1 Mon Sep 17 00:00:00 2001 From: greebo Date: Thu, 19 Mar 2026 20:29:58 +0300 Subject: [PATCH] feat(backend): add publish readiness contract and pricing diagnostics add backend readiness contract for publish prechecks add pricing diagnostics to explain publish-blocking conditions make publish decisions more explicit and easier to debug for clients --- backend/app/api/routes/pricing_diagnostics.py | 137 +++++++++--------- backend/app/api/routes/publish.py | 13 +- backend/app/api/routes/schemes.py | 22 ++- backend/app/schemas/pricing_diagnostics.py | 50 +++---- backend/app/schemas/publish_readiness.py | 51 ++++++- backend/app/services/api_errors.py | 48 +++--- backend/app/services/draft_guard.py | 23 +-- backend/app/services/publish_readiness.py | 32 ++-- backend/app/services/publish_service.py | 40 ++--- backend/docs/api-map.md | 12 +- backend/docs/smoke-regression.md | 124 ++-------------- 11 files changed, 251 insertions(+), 301 deletions(-) diff --git a/backend/app/api/routes/pricing_diagnostics.py b/backend/app/api/routes/pricing_diagnostics.py index 6887ba9..ee5d3b5 100644 --- a/backend/app/api/routes/pricing_diagnostics.py +++ b/backend/app/api/routes/pricing_diagnostics.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from app.core.config import settings from app.repositories.pricing import find_effective_price_rule @@ -6,34 +6,22 @@ from app.repositories.scheme_seats import get_scheme_version_seat_by_seat_id, li from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.schemes import get_scheme_record_by_scheme_id from app.schemas.pricing_diagnostics import ( - PricingCoverageSummaryResponse, - PricingExplainMatchedRule, - PricingExplainResponse, + ExplainMatchedRule, + ExplainSeatPriceResponse, + PricingCoverageResponse, UnpricedSeatItem, - UnpricedSeatsResponse, + UnpricedSeatListResponse, ) from app.security.auth import require_api_key router = APIRouter() -def _build_unpriced_reason(*, seat_id: str | None) -> tuple[str, str]: - if not seat_id: - return ( - "missing_seat_id", - "Seat has no seat_id, so price resolution is impossible.", - ) - return ( - "no_price_rule", - "No effective price rule was found for this seat.", - ) - - @router.get( f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/coverage", - response_model=PricingCoverageSummaryResponse, + response_model=PricingCoverageResponse, ) -async def get_pricing_coverage_summary( +async def get_pricing_coverage( scheme_id: str, role: str = Depends(require_api_key), ): @@ -60,13 +48,15 @@ async def get_pricing_coverage_summary( sector_id=seat.sector_id, ) priced_seats += 1 - except Exception: + except HTTPException as exc: + if exc.status_code != status.HTTP_404_NOT_FOUND: + raise unpriced_seats += 1 total_seats = len(seats) - coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 0.0 + coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 100.0 - return PricingCoverageSummaryResponse( + return PricingCoverageResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, total_seats=total_seats, @@ -78,7 +68,7 @@ async def get_pricing_coverage_summary( @router.get( f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/unpriced-seats", - response_model=UnpricedSeatsResponse, + response_model=UnpricedSeatListResponse, ) async def get_unpriced_seats( scheme_id: str, @@ -94,34 +84,47 @@ async def get_unpriced_seats( items: list[UnpricedSeatItem] = [] for seat in seats: - if seat.seat_id: - try: - await find_effective_price_rule( - scheme_id=scheme.scheme_id, + if not seat.seat_id: + items.append( + UnpricedSeatItem( + seat_record_id=seat.seat_record_id, seat_id=seat.seat_id, - group_id=seat.group_id, + element_id=seat.element_id, sector_id=seat.sector_id, + group_id=seat.group_id, + row_label=seat.row_label, + seat_number=seat.seat_number, + reason_code="missing_seat_id", + reason_message="Seat has no seat_id, so price resolution is not possible.", ) - continue - except Exception: - pass - - reason_code, reason_message = _build_unpriced_reason(seat_id=seat.seat_id) - items.append( - UnpricedSeatItem( - seat_record_id=seat.seat_record_id, - seat_id=seat.seat_id, - element_id=seat.element_id, - sector_id=seat.sector_id, - group_id=seat.group_id, - row_label=seat.row_label, - seat_number=seat.seat_number, - reason_code=reason_code, - reason_message=reason_message, ) - ) + continue - return UnpricedSeatsResponse( + try: + await find_effective_price_rule( + scheme_id=scheme.scheme_id, + seat_id=seat.seat_id, + group_id=seat.group_id, + sector_id=seat.sector_id, + ) + except HTTPException as exc: + if exc.status_code != status.HTTP_404_NOT_FOUND: + raise + items.append( + UnpricedSeatItem( + seat_record_id=seat.seat_record_id, + seat_id=seat.seat_id, + element_id=seat.element_id, + sector_id=seat.sector_id, + group_id=seat.group_id, + row_label=seat.row_label, + seat_number=seat.seat_number, + reason_code="no_price_rule", + reason_message="No effective price rule was found for this seat.", + ) + ) + + return UnpricedSeatListResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, total=len(items), @@ -131,9 +134,9 @@ async def get_unpriced_seats( @router.get( f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/pricing/explain/{{seat_id}}", - response_model=PricingExplainResponse, + response_model=ExplainSeatPriceResponse, ) -async def explain_pricing_for_seat( +async def explain_seat_price( scheme_id: str, seat_id: str, role: str = Depends(require_api_key), @@ -149,19 +152,18 @@ async def explain_pricing_for_seat( ) if not seat.seat_id: - reason_code, reason_message = _build_unpriced_reason(seat_id=seat.seat_id) - return PricingExplainResponse( + return ExplainSeatPriceResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, - seat_id=seat.seat_id, + seat_id=seat_id, element_id=seat.element_id, sector_id=seat.sector_id, group_id=seat.group_id, row_label=seat.row_label, seat_number=seat.seat_number, has_price=False, - reason_code=reason_code, - reason_message=reason_message, + reason_code="missing_seat_id", + reason_message="Seat has no seat_id, so price resolution is not possible.", matched_rule=None, ) @@ -172,7 +174,14 @@ async def explain_pricing_for_seat( group_id=seat.group_id, sector_id=seat.sector_id, ) - return PricingExplainResponse( + matched_rule = ExplainMatchedRule( + matched_rule_level=matched_rule_level, + matched_target_ref=rule["target_ref"], + pricing_category_id=rule["pricing_category_id"], + amount=str(rule["amount"]), + currency=rule["currency"], + ) + return ExplainSeatPriceResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, seat_id=seat.seat_id, @@ -184,17 +193,13 @@ async def explain_pricing_for_seat( has_price=True, reason_code="ok", reason_message="Effective price rule resolved successfully.", - matched_rule=PricingExplainMatchedRule( - matched_rule_level=matched_rule_level, - matched_target_ref=rule["target_ref"], - pricing_category_id=rule["pricing_category_id"], - amount=str(rule["amount"]), - currency=rule["currency"], - ), + matched_rule=matched_rule, ) - except Exception: - reason_code, reason_message = _build_unpriced_reason(seat_id=seat.seat_id) - return PricingExplainResponse( + except HTTPException as exc: + if exc.status_code != status.HTTP_404_NOT_FOUND: + raise + + return ExplainSeatPriceResponse( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, seat_id=seat.seat_id, @@ -204,7 +209,7 @@ async def explain_pricing_for_seat( row_label=seat.row_label, seat_number=seat.seat_number, has_price=False, - reason_code=reason_code, - reason_message=reason_message, + reason_code="no_price_rule", + reason_message="No effective price rule was found for this seat.", matched_rule=None, ) diff --git a/backend/app/api/routes/publish.py b/backend/app/api/routes/publish.py index bb81398..1472993 100644 --- a/backend/app/api/routes/publish.py +++ b/backend/app/api/routes/publish.py @@ -67,25 +67,16 @@ async def get_draft_publish_readiness( readiness = await build_publish_readiness( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, - require_full_pricing_coverage=settings.publish_require_full_pricing_coverage, - ) - return PublishReadinessResponse( - scheme_id=scheme.scheme_id, - scheme_version_id=version.scheme_version_id, - status=version.status, - validation_summary=readiness["validation_summary"], - pricing_coverage=readiness["pricing_coverage"], - snapshot=readiness["snapshot"], - readiness=readiness["readiness"], ) + return PublishReadinessResponse(**readiness) @router.get(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/draft/publish-preview", response_model=PublishPreviewResponse) async def get_publish_preview( scheme_id: str, baseline_scheme_version_id: str | None = Query(default=None), - refresh: bool = Query(default=False), expected_scheme_version_id: str | None = Query(default=None), + refresh: bool = Query(default=False), role: str = Depends(require_api_key), ): scheme, version = await get_current_draft_context( diff --git a/backend/app/api/routes/schemes.py b/backend/app/api/routes/schemes.py index 7c43f39..422d63b 100644 --- a/backend/app/api/routes/schemes.py +++ b/backend/app/api/routes/schemes.py @@ -18,6 +18,7 @@ from app.repositories.schemes import ( rollback_scheme_to_version, unpublish_scheme, ) +from app.schemas.publish_readiness import SchemePublishActionResponse from app.schemas.scheme_registry import ( SchemeCurrentResponse, SchemeDetailResponse, @@ -33,6 +34,7 @@ from app.schemas.scheme_versions import ( SchemeVersionListResponse, ) from app.security.auth import require_api_key +from app.services.api_errors import raise_conflict from app.services.publish_service import publish_current_draft_scheme from app.services.scheme_validation import build_scheme_validation_report @@ -146,13 +148,14 @@ async def create_next_scheme_version_endpoint( current_version_number=current_scheme.current_version_number, ) - if expected_current_scheme_version_id and expected_current_scheme_version_id != current_version.scheme_version_id: - from fastapi import HTTPException, status - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "code": "stale_current_version", - "message": "Current scheme version changed. Reload scheme state before creating a new version.", + if ( + expected_current_scheme_version_id + and expected_current_scheme_version_id != current_version.scheme_version_id + ): + raise_conflict( + code="stale_current_version", + message="Current scheme version changed. Reload scheme state before creating a new version.", + details={ "expected_scheme_version_id": expected_current_scheme_version_id, "actual_scheme_version_id": current_version.scheme_version_id, }, @@ -212,7 +215,10 @@ async def get_publish_validation(scheme_id: str, role: str = Depends(require_api } -@router.post(f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish") +@router.post( + f"{settings.api_v1_prefix}/schemes/{{scheme_id}}/publish", + response_model=SchemePublishActionResponse, +) async def publish_scheme_endpoint( scheme_id: str, expected_scheme_version_id: str | None = Query(default=None), diff --git a/backend/app/schemas/pricing_diagnostics.py b/backend/app/schemas/pricing_diagnostics.py index 5374e72..a452df3 100644 --- a/backend/app/schemas/pricing_diagnostics.py +++ b/backend/app/schemas/pricing_diagnostics.py @@ -1,27 +1,13 @@ from pydantic import BaseModel -class PricingExplainMatchedRule(BaseModel): - matched_rule_level: str - matched_target_ref: str - pricing_category_id: str - amount: str - currency: str - - -class PricingExplainResponse(BaseModel): +class PricingCoverageResponse(BaseModel): scheme_id: str scheme_version_id: str - seat_id: str | None - element_id: str | None - sector_id: str | None - group_id: str | None - row_label: str | None - seat_number: str | None - has_price: bool - reason_code: str - reason_message: str - matched_rule: PricingExplainMatchedRule | None + total_seats: int + priced_seats: int + unpriced_seats: int + coverage_percent: float class UnpricedSeatItem(BaseModel): @@ -36,17 +22,31 @@ class UnpricedSeatItem(BaseModel): reason_message: str -class UnpricedSeatsResponse(BaseModel): +class UnpricedSeatListResponse(BaseModel): scheme_id: str scheme_version_id: str total: int items: list[UnpricedSeatItem] -class PricingCoverageSummaryResponse(BaseModel): +class ExplainMatchedRule(BaseModel): + matched_rule_level: str + matched_target_ref: str + pricing_category_id: str + amount: str + currency: str + + +class ExplainSeatPriceResponse(BaseModel): scheme_id: str scheme_version_id: str - total_seats: int - priced_seats: int - unpriced_seats: int - coverage_percent: float + seat_id: str + element_id: str | None + sector_id: str | None + group_id: str | None + row_label: str | None + seat_number: str | None + has_price: bool + reason_code: str + reason_message: str + matched_rule: ExplainMatchedRule | None diff --git a/backend/app/schemas/publish_readiness.py b/backend/app/schemas/publish_readiness.py index 82b738b..abc558f 100644 --- a/backend/app/schemas/publish_readiness.py +++ b/backend/app/schemas/publish_readiness.py @@ -1,11 +1,54 @@ from pydantic import BaseModel +class PublishReadinessValidationSummary(BaseModel): + sectors_count: int + groups_count: int + seats_count: int + priced_seats_count: int + unpriced_seats_count: int + duplicate_seat_ids_count: int + seats_with_missing_contract_count: int + is_publishable: bool + + +class PublishReadinessPricingCoverage(BaseModel): + total_seats: int + priced_seats: int + unpriced_seats: int + coverage_percent: float + + +class PublishReadinessSnapshot(BaseModel): + available: bool + categories_count: int + rules_count: int + + +class PublishReadinessFlags(BaseModel): + validation_publishable: bool + snapshot_available: bool + require_full_pricing_coverage: bool + full_pricing_coverage: bool + pricing_gate_passed: bool + is_ready_to_publish: bool + + class PublishReadinessResponse(BaseModel): scheme_id: str scheme_version_id: str status: str - validation_summary: dict - pricing_coverage: dict - snapshot: dict - readiness: dict + validation_summary: PublishReadinessValidationSummary + pricing_coverage: PublishReadinessPricingCoverage + snapshot: PublishReadinessSnapshot + readiness: PublishReadinessFlags + + +class SchemePublishActionResponse(BaseModel): + scheme_id: str + scheme_version_id: str + status: str + current_version_number: int + published_at: str | None + pricing_snapshot: dict + validation_summary: PublishReadinessValidationSummary diff --git a/backend/app/services/api_errors.py b/backend/app/services/api_errors.py index 2881e92..bcbeb16 100644 --- a/backend/app/services/api_errors.py +++ b/backend/app/services/api_errors.py @@ -1,40 +1,42 @@ +from __future__ import annotations + from fastapi import HTTPException, status -def raise_conflict(*, code: str, message: str, details: dict | None = None) -> None: +def build_error_detail( + *, + code: str, + message: str, + details: dict | None = None, +) -> dict: payload = { "code": code, "message": message, } if details: - payload.update(details) + payload["details"] = details + return payload + +def raise_conflict( + *, + code: str, + message: str, + details: dict | None = None, +) -> None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=payload, + detail=build_error_detail(code=code, message=message, details=details), ) -def raise_unprocessable(*, code: str, message: str, details: dict | None = None) -> None: - payload = { - "code": code, - "message": message, - } - if details: - payload.update(details) - +def raise_unprocessable( + *, + code: str, + message: str, + details: dict | None = None, +) -> None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=payload, - ) - - -def raise_publish_not_ready(*, reason: str, details: dict) -> None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "code": "publish_not_ready", - "message": reason, - "details": details, - }, + detail=build_error_detail(code=code, message=message, details=details), ) diff --git a/backend/app/services/draft_guard.py b/backend/app/services/draft_guard.py index 84b5a00..1a67251 100644 --- a/backend/app/services/draft_guard.py +++ b/backend/app/services/draft_guard.py @@ -1,3 +1,5 @@ +from fastapi import HTTPException, status + from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.schemes import get_scheme_record_by_scheme_id from app.services.api_errors import raise_conflict @@ -27,19 +29,20 @@ async def get_current_draft_context( ) if version.status != "draft" or scheme.status != "draft": - raise_conflict( - code="draft_not_editable", - message="Current scheme version is not editable because it is not in draft state", - scheme_status=scheme.status, - scheme_version_status=version.status, - actual_scheme_version_id=version.scheme_version_id, + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Current scheme version is not editable because it is not in draft state", ) if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id: - raise_conflict(**build_stale_draft_version_detail( - expected_scheme_version_id=expected_scheme_version_id, - actual_scheme_version_id=version.scheme_version_id, - )) + raise_conflict( + code="stale_draft_version", + message="Draft scheme version is stale. Reload current draft state before applying mutation.", + details={ + "expected_scheme_version_id": expected_scheme_version_id, + "actual_scheme_version_id": version.scheme_version_id, + }, + ) return scheme, version diff --git a/backend/app/services/publish_readiness.py b/backend/app/services/publish_readiness.py index fcc18ca..104f74a 100644 --- a/backend/app/services/publish_readiness.py +++ b/backend/app/services/publish_readiness.py @@ -1,5 +1,8 @@ from __future__ import annotations +from fastapi import HTTPException, status + +from app.core.config import settings from app.repositories.scheme_seats import list_scheme_version_seats from app.repositories.scheme_version_pricing import ( find_effective_snapshot_price_rule, @@ -13,21 +16,18 @@ async def build_publish_readiness( *, scheme_id: str, scheme_version_id: str, - require_full_pricing_coverage: bool, ) -> dict: validation = await build_scheme_validation_report( scheme_id=scheme_id, scheme_version_id=scheme_version_id, ) - seats = await list_scheme_version_seats(scheme_version_id) snapshot_categories = await list_scheme_version_snapshot_categories(scheme_version_id) snapshot_rules = await list_scheme_version_snapshot_rules(scheme_version_id) - snapshot_available = len(snapshot_categories) > 0 or len(snapshot_rules) > 0 - priced_seats = 0 unpriced_seats = 0 + snapshot_available = bool(snapshot_categories or snapshot_rules) for seat in seats: if not seat.seat_id: @@ -46,23 +46,25 @@ async def build_publish_readiness( sector_id=seat.sector_id, ) priced_seats += 1 + except HTTPException as exc: + if exc.status_code != status.HTTP_404_NOT_FOUND: + raise + unpriced_seats += 1 except Exception: unpriced_seats += 1 total_seats = len(seats) - coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 0.0 - - validation_publishable = bool(validation["summary"]["is_publishable"]) + coverage_percent = round((priced_seats / total_seats) * 100, 2) if total_seats else 100.0 full_pricing_coverage = unpriced_seats == 0 - pricing_gate_passed = full_pricing_coverage if require_full_pricing_coverage else True - - is_ready_to_publish = ( - validation_publishable - and snapshot_available - and pricing_gate_passed + pricing_gate_passed = snapshot_available and ( + full_pricing_coverage if settings.publish_require_full_pricing_coverage else True ) + validation_publishable = bool(validation["summary"]["is_publishable"]) return { + "scheme_id": scheme_id, + "scheme_version_id": scheme_version_id, + "status": "draft", "validation_summary": validation["summary"], "pricing_coverage": { "total_seats": total_seats, @@ -78,9 +80,9 @@ async def build_publish_readiness( "readiness": { "validation_publishable": validation_publishable, "snapshot_available": snapshot_available, - "require_full_pricing_coverage": require_full_pricing_coverage, + "require_full_pricing_coverage": settings.publish_require_full_pricing_coverage, "full_pricing_coverage": full_pricing_coverage, "pricing_gate_passed": pricing_gate_passed, - "is_ready_to_publish": is_ready_to_publish, + "is_ready_to_publish": validation_publishable and pricing_gate_passed, }, } diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index 1d72015..50a2b64 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -1,9 +1,8 @@ -from app.core.config import settings from app.repositories.audit import create_audit_event from app.repositories.scheme_version_pricing import replace_scheme_version_pricing_snapshot from app.repositories.scheme_versions import get_current_scheme_version from app.repositories.schemes import get_scheme_record_by_scheme_id, publish_scheme -from app.services.api_errors import raise_publish_not_ready +from app.services.api_errors import raise_conflict from app.services.publish_readiness import build_publish_readiness @@ -19,8 +18,9 @@ async def publish_current_draft_scheme( ) if scheme.status != "draft" or version.status != "draft": - raise_publish_not_ready( - reason="Current scheme version is not publishable because it is not in draft state", + raise_conflict( + code="publish_not_ready", + message="Current scheme version is not publishable because it is not in draft state.", details={ "scheme_status": scheme.status, "scheme_version_status": version.status, @@ -29,31 +29,32 @@ async def publish_current_draft_scheme( ) if expected_scheme_version_id and expected_scheme_version_id != version.scheme_version_id: - raise_publish_not_ready( - reason="Draft scheme version is stale. Reload current draft state before publishing.", + raise_conflict( + code="publish_not_ready", + message="Draft scheme version is stale. Reload current draft state before publishing.", details={ "expected_scheme_version_id": expected_scheme_version_id, "actual_scheme_version_id": version.scheme_version_id, }, ) + readiness = await build_publish_readiness( + scheme_id=scheme.scheme_id, + scheme_version_id=version.scheme_version_id, + ) + + if not readiness["readiness"]["is_ready_to_publish"]: + raise_conflict( + code="publish_not_ready", + message="Draft scheme does not satisfy publish readiness requirements.", + details={"readiness": readiness["readiness"]}, + ) + snapshot = await replace_scheme_version_pricing_snapshot( scheme_id=scheme.scheme_id, scheme_version_id=version.scheme_version_id, ) - readiness = await build_publish_readiness( - scheme_id=scheme.scheme_id, - scheme_version_id=version.scheme_version_id, - require_full_pricing_coverage=settings.publish_require_full_pricing_coverage, - ) - - if not readiness["readiness"]["is_ready_to_publish"]: - raise_publish_not_ready( - reason="Scheme is not ready to publish", - details=readiness, - ) - published_row = await publish_scheme(scheme.scheme_id) await create_audit_event( @@ -66,7 +67,6 @@ async def publish_current_draft_scheme( "status": published_row.status, "pricing_snapshot": snapshot, "scheme_version_id": version.scheme_version_id, - "publish_readiness": readiness, }, ) @@ -77,5 +77,5 @@ async def publish_current_draft_scheme( "current_version_number": published_row.current_version_number, "published_at": published_row.published_at.isoformat() if published_row.published_at else None, "pricing_snapshot": snapshot, - "publish_readiness": readiness, + "validation_summary": readiness["validation_summary"], } diff --git a/backend/docs/api-map.md b/backend/docs/api-map.md index 10efbc3..5c3da68 100644 --- a/backend/docs/api-map.md +++ b/backend/docs/api-map.md @@ -56,6 +56,7 @@ ## app/api/routes/publish.py - POST /api/v1/schemes/{scheme_id}/draft/pricing/snapshot +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness - 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 @@ -63,10 +64,6 @@ ## app/api/routes/editor.py - GET /api/v1/schemes/{scheme_id}/draft/structure - GET /api/v1/schemes/{scheme_id}/draft/compare-preview -- GET /api/v1/schemes/{scheme_id}/draft/validation -- 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} @@ -76,6 +73,10 @@ - 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 +- GET /api/v1/schemes/{scheme_id}/draft/validation +- 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} ## app/api/routes/admin.py - GET /api/v1/admin/schemes/{scheme_id}/current/artifacts @@ -89,6 +90,3 @@ - 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. - -## app/api/routes/publish.py -- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness diff --git a/backend/docs/smoke-regression.md b/backend/docs/smoke-regression.md index b709098..a711d3b 100644 --- a/backend/docs/smoke-regression.md +++ b/backend/docs/smoke-regression.md @@ -31,67 +31,35 @@ export SCHEME_ID="82086336d385427f9d56244f9e1dd772" - GET /api/v1/schemes/{scheme_id}/current -> 200 - GET /api/v1/schemes/{scheme_id}/versions -> 200 -Validate: -- scheme_id is stable -- current version exists -- version list contains current version -- status and counts are consistent - ## 3. 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 -Validate: -- total counts are non-negative -- known sample scheme returns expected object lists -- seats contain seat_id / sector_id / group_id contract where applicable - ## 4. 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 -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 - -## 5. Pricing read / write contract +## 5. Pricing read model - GET /api/v1/schemes/{scheme_id}/pricing -> 200 +- GET /api/v1/schemes/{scheme_id}/pricing/coverage -> 200 +- GET /api/v1/schemes/{scheme_id}/pricing/unpriced-seats -> 200 +- GET /api/v1/schemes/{scheme_id}/pricing/explain/{seat_id} -> 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 known seat -- POST /api/v1/schemes/{scheme_id}/pricing/categories?expected_scheme_version_id={draft_version_id} -> 200 for valid draft -- POST /api/v1/schemes/{scheme_id}/pricing/categories?expected_scheme_version_id=deadbeef... -> 409 for stale draft +- GET /api/v1/schemes/{scheme_id}/test/seats/{seat_id} -> 200 for known priced and unpriced seats -Validate: -- pricing bundle contains categories and rules arrays -- effective seat price resolves according to domain priority -- test seat preview explains selectable / has_price state and returns reason_code / reason_message -- pricing write responses are stable and typed -- stale pricing mutation returns `detail.code = stale_draft_version` +## 6. Draft publish / readiness -## 6. Draft publish preview - -- GET /api/v1/schemes/{scheme_id}/draft/validation -> 200 - GET /api/v1/schemes/{scheme_id}/publish/validation -> 200 +- GET /api/v1/schemes/{scheme_id}/draft/publish-readiness -> 200 - 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 - -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?expected_scheme_version_id=deadbeef... -> 409 typed stale conflict ## 7. Admin / ops @@ -100,25 +68,7 @@ Validate: - 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 - -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 - -## 8. Audit trail - -- GET /api/v1/schemes/{scheme_id}/audit -> 200 - -Validate: -- recent publish preview / pricing / version events are present when corresponding operations were run -- audit total is non-negative -- event payloads stay JSON-serializable - -## 9. Fail criteria +## 8. Fail criteria Regression is considered failed if any of the following happen: @@ -126,56 +76,6 @@ Regression is considered failed if any of the following happen: - any stable read endpoint returns 500 - passthrough display endpoint fails on known-good sample - publish preview refresh or cached read returns 500 -- pricing bundle contract changes unexpectedly -- pricing write contract regresses or stops returning typed payloads -- stale draft guard stops returning 409 on pricing mutations -- admin audit/cleanup endpoints fail on healthy environment -- artifact retention grows without bound for repeated preview refresh on same variant - -## 10. Operator note - -Run this checklist after: -- schema changes -- pricing schema/repository refactors -- artifact lifecycle changes -- display pipeline changes -- route reorganization -- startup/import/config changes - - -## 3.1. Draft editor read model - -- GET /api/v1/schemes/{scheme_id}/draft/structure -> 200 when current version is draft -- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id} -> 200 for known seat record -- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id} -> 200 for known sector record -- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id} -> 200 for known group record - -Validate: -- returned record belongs to current draft scheme_version_id -- single-entity endpoints match items visible in draft structure -- missing draft record returns 404, not 500 - - -Additional draft read-side stale checks: -- GET /api/v1/schemes/{scheme_id}/draft/structure?expected_scheme_version_id={current_version_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/compare-preview?expected_scheme_version_id={current_version_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/seats/records/{seat_record_id}?expected_scheme_version_id={current_version_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/sectors/records/{sector_record_id}?expected_scheme_version_id={current_version_id} -> 200 -- GET /api/v1/schemes/{scheme_id}/draft/groups/records/{group_record_id}?expected_scheme_version_id={current_version_id} -> 200 -- same endpoints with stale expected_scheme_version_id -> 409 - - -## 11. Typed error contract - -Validate representative failures return JSON detail objects with: -- code -- message - -Recommended checks: -- stale_draft_version -> 409 -- stale_current_version -> 409 -- duplicate_sector_id / duplicate_group_id -> 422 -- unknown_sector_id / unknown_group_id -> 422 -- invalid_amount -> 422 -- publish_validation_failed -> 409 - +- readiness endpoint contract breaks unexpectedly +- pricing diagnostics endpoints fail on healthy environment +- publish stale guard is not typed and deterministic