M54: harden operations and demo resilience
This commit is contained in:
@@ -1,8 +1,25 @@
|
||||
from sqlalchemy import select
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from threading import Barrier
|
||||
|
||||
from sqlalchemy import delete, select, text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, RegisterReturnRequest, ResolveOdometerRegressionRequest
|
||||
from app.services.data_quality import (
|
||||
open_odometer_regression_issue,
|
||||
resolve_odometer_regression,
|
||||
)
|
||||
from app.services.returns import register_vehicle_return
|
||||
|
||||
|
||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
@@ -21,6 +38,30 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
db.close()
|
||||
|
||||
|
||||
def _cleanup_odometer_scenario(vehicle_id, customer_id) -> None:
|
||||
"""Remove one isolated DQ-03 scenario in foreign-key-safe order."""
|
||||
with SessionLocal() as db:
|
||||
booking_ids = db.scalars(select(Booking.id).where(Booking.vehicle_id == vehicle_id)).all()
|
||||
issue_ids = db.scalars(
|
||||
select(DataQualityIssue.id).where(DataQualityIssue.entity_id == vehicle_id)
|
||||
).all()
|
||||
audit_entity_ids = [vehicle_id, *booking_ids, *issue_ids]
|
||||
if audit_entity_ids:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id.in_(audit_entity_ids)))
|
||||
if booking_ids:
|
||||
db.execute(
|
||||
delete(IdempotencyRecord).where(IdempotencyRecord.booking_id.in_(booking_ids))
|
||||
)
|
||||
db.execute(delete(OutboxEvent).where(OutboxEvent.aggregate_id.in_(booking_ids)))
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
|
||||
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_list_includes_all_five_rule_types(ops_client):
|
||||
response = ops_client.get("/api/v1/data-quality/issues")
|
||||
assert response.status_code == 200
|
||||
@@ -43,6 +84,84 @@ def test_scan_is_idempotent_once_seeded(ops_client):
|
||||
assert response.json()["created"] == {}
|
||||
|
||||
|
||||
def test_scan_detects_imported_maintenance_odometer_regression(ops_client):
|
||||
with SessionLocal() as db:
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-SCAN",
|
||||
make="Synthetic",
|
||||
model="Scanner",
|
||||
model_year=2026,
|
||||
registration_number="DQ-SCAN-01",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.flush()
|
||||
vehicle_id = vehicle.id
|
||||
db.add_all(
|
||||
[
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-SCAN-A",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2045, 1, 1, tzinfo=UTC),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Synthetic scan baseline",
|
||||
),
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-SCAN-B",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2045, 2, 1, tzinfo=UTC),
|
||||
odometer_km=19_000,
|
||||
category="inspection",
|
||||
summary="Synthetic imported regression",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
try:
|
||||
response = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["created"]["odometer_regression"] >= 1
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
issue = next(item for item in issues if item["entity_ref"] == "MO-DQ-SCAN")
|
||||
assert issue["evidence"]["source_type"] == "maintenance"
|
||||
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||
|
||||
# A later imported regression must enrich the existing open issue instead of
|
||||
# being silently dropped by the generic check-then-return scanner path.
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-SCAN-C",
|
||||
vehicle_id=vehicle_id,
|
||||
occurred_at=datetime(2045, 3, 1, tzinfo=UTC),
|
||||
odometer_km=18_000,
|
||||
category="inspection",
|
||||
summary="Second synthetic imported regression",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
rescanned = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert rescanned.status_code == 200
|
||||
enriched = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||
later_refs = {signal["params"]["later_ref"] for signal in enriched["evidence"]["signals"]}
|
||||
assert {"MAINT-DQ-SCAN-B", "MAINT-DQ-SCAN-C"}.issubset(later_refs)
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_scan_requires_operations_manager(employee_client):
|
||||
response = employee_client.post("/api/v1/data-quality/scan")
|
||||
assert response.status_code == 403
|
||||
@@ -440,9 +559,8 @@ def test_resolve_odometer_regression_correction_below_canonical_is_rejected_then
|
||||
|
||||
|
||||
def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_client):
|
||||
# The seeded odometer_regression issues carry no related booking (CSV-only rows).
|
||||
# Create a fresh one with a real related booking via a live regression return, so
|
||||
# the "correct_reading" path has an actual booking_ref to target.
|
||||
# Create a live return regression to prove the complete correction chain updates
|
||||
# both persisted representations of that reading (booking + return inspection).
|
||||
booking_ref = _activate_booking("MO-018", start_odometer_km=12000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-018").json()
|
||||
low_reading = vehicle_before["odometer_km"] - 200
|
||||
@@ -459,6 +577,7 @@ def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_clien
|
||||
)
|
||||
assert returned.status_code == 201
|
||||
issue_ref = returned.json()["quality_issue_ref"]
|
||||
inspection_ref = returned.json()["inspection_ref"]
|
||||
assert issue_ref is not None
|
||||
|
||||
corrected = vehicle_before["odometer_km"] + 500
|
||||
@@ -477,6 +596,701 @@ def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_clien
|
||||
assert vehicle["odometer_km"] == corrected
|
||||
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
||||
assert booking["end_odometer_km"] == corrected
|
||||
with SessionLocal() as db:
|
||||
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == inspection_ref))
|
||||
assert inspection is not None
|
||||
assert inspection.odometer_km == corrected
|
||||
|
||||
|
||||
def test_correcting_one_of_two_regressions_keeps_the_other_open(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-MULTI",
|
||||
first_name="Synthetic",
|
||||
last_name="Multi",
|
||||
email="dq-multi@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-MULTI",
|
||||
make="Synthetic",
|
||||
model="Multi",
|
||||
model_year=2026,
|
||||
registration_number="DQ-MULTI",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=1_000,
|
||||
next_service_km=2_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
bookings: list[Booking] = []
|
||||
inspections: list[Inspection] = []
|
||||
for index, reading in enumerate((900, 800), start=1):
|
||||
booking = Booking(
|
||||
public_ref=f"BK-DQ-MULTI-{index}",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=datetime(2046, index, 1, tzinfo=UTC),
|
||||
ends_at=datetime(2046, index, 2, tzinfo=UTC),
|
||||
status="returned",
|
||||
start_odometer_km=reading - 10,
|
||||
end_odometer_km=reading,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
inspection = Inspection(
|
||||
public_ref=f"INSP-DQ-M-{index}",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=reading,
|
||||
completed_at=booking.ends_at,
|
||||
)
|
||||
db.add(inspection)
|
||||
db.flush()
|
||||
open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=inspection.public_ref,
|
||||
reading_km=reading,
|
||||
canonical_km=vehicle.odometer_km,
|
||||
source_type="return",
|
||||
related_refs=[booking.public_ref, inspection.public_ref],
|
||||
correctable_booking_refs=[booking.public_ref],
|
||||
public_ref="DQ-MULTI-SOURCE" if index == 1 else None,
|
||||
)
|
||||
bookings.append(booking)
|
||||
inspections.append(inspection)
|
||||
booking_refs = [booking.public_ref for booking in bookings]
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
first = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking_refs[0],
|
||||
"corrected_odometer_km": 1_100,
|
||||
},
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
assert first.json()["status"] == "open"
|
||||
assert first.json()["evidence"]["correctable_booking_refs"] == [booking_refs[1]]
|
||||
|
||||
second = ops_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking_refs[1],
|
||||
"corrected_odometer_km": 1_200,
|
||||
},
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json()["status"] == "resolved"
|
||||
with SessionLocal() as db:
|
||||
persisted = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.vehicle_id == vehicle_id)
|
||||
.order_by(Inspection.public_ref)
|
||||
).all()
|
||||
assert [item.odometer_km for item in persisted] == [1_100, 1_200]
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_retained_regression_is_suppressed_until_source_reading_changes(ops_client):
|
||||
with SessionLocal() as db:
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-RETAIN",
|
||||
make="Synthetic",
|
||||
model="Retain",
|
||||
model_year=2026,
|
||||
registration_number="DQ-RETAIN",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.flush()
|
||||
vehicle_id = vehicle.id
|
||||
db.add_all(
|
||||
[
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-RET-A",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2047, 1, 1, tzinfo=UTC),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Retain baseline",
|
||||
),
|
||||
MaintenanceRecord(
|
||||
public_ref="MAINT-DQ-RET-B",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=datetime(2047, 2, 1, tzinfo=UTC),
|
||||
odometer_km=19_000,
|
||||
category="inspection",
|
||||
summary="Retained source reading",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
try:
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
issue = next(
|
||||
item
|
||||
for item in ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
if item["entity_ref"] == "MO-DQ-RETAIN"
|
||||
)
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical", "note": "Verified source entry"},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
assert retained.json()["status"] == "resolved"
|
||||
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
with SessionLocal() as db:
|
||||
assert (
|
||||
db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
is None
|
||||
)
|
||||
changed = db.scalar(
|
||||
select(MaintenanceRecord).where(MaintenanceRecord.public_ref == "MAINT-DQ-RET-B")
|
||||
)
|
||||
changed.odometer_km = 18_999
|
||||
db.commit()
|
||||
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
reopened = next(
|
||||
item
|
||||
for item in ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
if item["entity_ref"] == "MO-DQ-RETAIN"
|
||||
)
|
||||
assert reopened["evidence"]["reopened_from"] == issue["public_ref"]
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_live_return_retained_as_canonical_is_not_reopened_by_scan(ops_client):
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-LIVE-RET",
|
||||
first_name="Synthetic",
|
||||
last_name="Retained return",
|
||||
email="dq-live-retain@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-LIVE-RET",
|
||||
make="Synthetic",
|
||||
model="Retained return",
|
||||
model_year=2026,
|
||||
registration_number="DQ-LIVE-RET",
|
||||
location="Brussels",
|
||||
operational_status="rented",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
booking = Booking(
|
||||
public_ref="BK-DQ-LIVE-RET",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=2),
|
||||
ends_at=now + timedelta(days=1),
|
||||
status="active",
|
||||
start_odometer_km=19_500,
|
||||
end_odometer_km=None,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
booking,
|
||||
MaintenanceRecord(
|
||||
public_ref="MNT-DQ-LIVE-RET",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=now - timedelta(days=3),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Synthetic canonical baseline",
|
||||
),
|
||||
]
|
||||
)
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
returned = ops_client.post(
|
||||
"/api/v1/bookings/BK-DQ-LIVE-RET/return",
|
||||
json={
|
||||
"end_odometer_km": 19_000,
|
||||
"fuel_level_percent": 50,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
headers={"Idempotency-Key": "test-dq-live-retain-001"},
|
||||
)
|
||||
assert returned.status_code == 201, returned.text
|
||||
issue_ref = returned.json()["quality_issue_ref"]
|
||||
inspection_ref = returned.json()["inspection_ref"]
|
||||
assert issue_ref is not None
|
||||
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical", "note": "Source reading verified as wrong."},
|
||||
)
|
||||
assert retained.status_code == 200, retained.text
|
||||
assert retained.json()["status"] == "resolved"
|
||||
assert retained.json()["evidence"]["retained_odometer_fingerprints"] == [
|
||||
{
|
||||
"source_type": "return",
|
||||
"later_ref": inspection_ref,
|
||||
"later_km": 19_000,
|
||||
}
|
||||
]
|
||||
|
||||
scanned = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert scanned.status_code == 200, scanned.text
|
||||
with SessionLocal() as db:
|
||||
issues = db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at)
|
||||
).all()
|
||||
assert [(issue.public_ref, issue.status) for issue in issues] == [
|
||||
(issue_ref, "resolved")
|
||||
]
|
||||
finally:
|
||||
_cleanup_odometer_scenario(vehicle_id, customer_id)
|
||||
|
||||
|
||||
def test_scanned_return_regression_can_correct_booking_all_inspections_and_vehicle(ops_client):
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-SCAN-RET",
|
||||
first_name="Synthetic",
|
||||
last_name="Scanned return",
|
||||
email="dq-scan-return@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-SCAN-RET",
|
||||
make="Synthetic",
|
||||
model="Scanned return",
|
||||
model_year=2026,
|
||||
registration_number="DQ-SCAN-RET",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
booking = Booking(
|
||||
public_ref="BK-DQ-SCAN-RET",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=3),
|
||||
ends_at=now - timedelta(hours=12),
|
||||
status="returned",
|
||||
start_odometer_km=19_500,
|
||||
end_odometer_km=18_500,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
MaintenanceRecord(
|
||||
public_ref="MNT-DQ-SCAN-RET",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=now - timedelta(days=4),
|
||||
odometer_km=20_000,
|
||||
category="inspection",
|
||||
summary="Synthetic canonical baseline",
|
||||
),
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-SCAN-R1",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=19_000,
|
||||
completed_at=now - timedelta(days=1),
|
||||
),
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-SCAN-R2",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=18_500,
|
||||
completed_at=now - timedelta(hours=12),
|
||||
),
|
||||
]
|
||||
)
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
scanned = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert scanned.status_code == 200, scanned.text
|
||||
issue = next(
|
||||
item
|
||||
for item in ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
if item["entity_ref"] == "MO-DQ-SCAN-RET"
|
||||
)
|
||||
assert issue["evidence"]["correctable_booking_refs"] == ["BK-DQ-SCAN-RET"]
|
||||
later_refs = {signal["params"]["later_ref"] for signal in issue["evidence"]["signals"]}
|
||||
assert later_refs == {"INSP-DQ-SCAN-R1", "INSP-DQ-SCAN-R2"}
|
||||
|
||||
corrected = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": "BK-DQ-SCAN-RET",
|
||||
"corrected_odometer_km": 20_500,
|
||||
},
|
||||
)
|
||||
assert corrected.status_code == 200, corrected.text
|
||||
assert corrected.json()["status"] == "resolved"
|
||||
|
||||
with SessionLocal() as db:
|
||||
persisted_vehicle = db.get(Vehicle, vehicle_id)
|
||||
persisted_booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == "BK-DQ-SCAN-RET")
|
||||
)
|
||||
assert persisted_booking is not None
|
||||
persisted_inspections = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.booking_id == persisted_booking.id, Inspection.type == "return")
|
||||
.order_by(Inspection.public_ref)
|
||||
).all()
|
||||
assert persisted_vehicle is not None
|
||||
assert persisted_vehicle.odometer_km == 20_500
|
||||
assert persisted_booking.end_odometer_km == 20_500
|
||||
assert [inspection.odometer_km for inspection in persisted_inspections] == [
|
||||
20_500,
|
||||
20_500,
|
||||
]
|
||||
finally:
|
||||
_cleanup_odometer_scenario(vehicle_id, customer_id)
|
||||
|
||||
|
||||
def test_resolver_and_concurrent_return_finish_without_deadlock_or_lost_evidence():
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-RACE",
|
||||
first_name="Synthetic",
|
||||
last_name="Race",
|
||||
email="dq-race@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-RACE",
|
||||
make="Synthetic",
|
||||
model="Race",
|
||||
model_year=2026,
|
||||
registration_number="DQ-RACE",
|
||||
location="Brussels",
|
||||
operational_status="rented",
|
||||
odometer_km=20_000,
|
||||
next_service_km=30_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
corrected_booking = Booking(
|
||||
public_ref="BK-DQ-RACE-OLD",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=4),
|
||||
ends_at=now - timedelta(days=3),
|
||||
status="returned",
|
||||
start_odometer_km=19_500,
|
||||
end_odometer_km=19_000,
|
||||
requirements_complete=True,
|
||||
)
|
||||
concurrent_booking = Booking(
|
||||
public_ref="BK-DQ-RACE-NEW",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=now - timedelta(days=1),
|
||||
ends_at=now + timedelta(days=1),
|
||||
status="active",
|
||||
start_odometer_km=19_000,
|
||||
end_odometer_km=None,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add_all([corrected_booking, concurrent_booking])
|
||||
db.flush()
|
||||
old_inspection = Inspection(
|
||||
public_ref="INSP-DQ-RACE-OLD",
|
||||
booking_id=corrected_booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=19_000,
|
||||
completed_at=corrected_booking.ends_at,
|
||||
)
|
||||
db.add(old_inspection)
|
||||
db.flush()
|
||||
issue = open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=old_inspection.public_ref,
|
||||
reading_km=19_000,
|
||||
canonical_km=20_000,
|
||||
source_type="return",
|
||||
related_refs=[corrected_booking.public_ref, old_inspection.public_ref],
|
||||
correctable_booking_refs=[corrected_booking.public_ref],
|
||||
public_ref="DQ-ODO-RACE",
|
||||
)
|
||||
assert issue is not None
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
|
||||
actor = CurrentUser(
|
||||
public_ref="USR-DQ-RACE",
|
||||
display_name="DQ Race Manager",
|
||||
role="operations_manager",
|
||||
)
|
||||
start = Barrier(2)
|
||||
|
||||
def correct_existing_reading() -> str:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SET LOCAL lock_timeout = '5s'"))
|
||||
start.wait(timeout=5)
|
||||
resolved = resolve_odometer_regression(
|
||||
db,
|
||||
"DQ-ODO-RACE",
|
||||
ResolveOdometerRegressionRequest(
|
||||
decision="correct_reading",
|
||||
booking_ref="BK-DQ-RACE-OLD",
|
||||
corrected_odometer_km=20_500,
|
||||
),
|
||||
actor,
|
||||
)
|
||||
return resolved.status
|
||||
|
||||
def return_other_booking() -> dict:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("SET LOCAL lock_timeout = '5s'"))
|
||||
start.wait(timeout=5)
|
||||
_status, response = register_vehicle_return(
|
||||
db,
|
||||
"BK-DQ-RACE-NEW",
|
||||
RegisterReturnRequest(
|
||||
end_odometer_km=18_000,
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
),
|
||||
"test-dq-resolve-return-race-001",
|
||||
actor,
|
||||
)
|
||||
return response
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
correction_future = executor.submit(correct_existing_reading)
|
||||
return_future = executor.submit(return_other_booking)
|
||||
correction_status = correction_future.result(timeout=15)
|
||||
return_result = return_future.result(timeout=15)
|
||||
|
||||
assert correction_status in {"open", "resolved"}
|
||||
assert return_result["quality_issue_ref"] is not None
|
||||
with SessionLocal() as db:
|
||||
persisted_vehicle = db.get(Vehicle, vehicle_id)
|
||||
bookings = {
|
||||
booking.public_ref: booking
|
||||
for booking in db.scalars(
|
||||
select(Booking).where(Booking.vehicle_id == vehicle_id)
|
||||
).all()
|
||||
}
|
||||
old_inspections = db.scalars(
|
||||
select(Inspection).where(Inspection.booking_id == bookings["BK-DQ-RACE-OLD"].id)
|
||||
).all()
|
||||
open_issues = db.scalars(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
).all()
|
||||
assert persisted_vehicle is not None
|
||||
assert persisted_vehicle.odometer_km == 20_500
|
||||
assert bookings["BK-DQ-RACE-OLD"].end_odometer_km == 20_500
|
||||
assert [inspection.odometer_km for inspection in old_inspections] == [20_500]
|
||||
assert bookings["BK-DQ-RACE-NEW"].status == "returned"
|
||||
assert bookings["BK-DQ-RACE-NEW"].end_odometer_km == 18_000
|
||||
assert len(open_issues) == 1
|
||||
assert open_issues[0].evidence_json["correctable_booking_refs"] == ["BK-DQ-RACE-NEW"]
|
||||
later_refs = {
|
||||
signal["params"]["later_ref"] for signal in open_issues[0].evidence_json["signals"]
|
||||
}
|
||||
assert later_refs == {return_result["inspection_ref"]}
|
||||
finally:
|
||||
_cleanup_odometer_scenario(vehicle_id, customer_id)
|
||||
|
||||
|
||||
def test_early_return_uses_inspection_time_without_duplicate_booking_regression(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-DQ-EARLY",
|
||||
first_name="Synthetic",
|
||||
last_name="Early",
|
||||
email="dq-early@example.test",
|
||||
)
|
||||
vehicle = Vehicle(
|
||||
public_ref="MO-DQ-EARLY",
|
||||
make="Synthetic",
|
||||
model="Early",
|
||||
model_year=2026,
|
||||
registration_number="DQ-EARLY",
|
||||
location="Brussels",
|
||||
operational_status="available",
|
||||
odometer_km=200,
|
||||
next_service_km=10_000,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
db.add_all([customer, vehicle])
|
||||
db.flush()
|
||||
early = Booking(
|
||||
public_ref="BK-DQ-EARLY-A",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=datetime(2048, 1, 1, tzinfo=UTC),
|
||||
ends_at=datetime(2048, 3, 1, tzinfo=UTC),
|
||||
status="returned",
|
||||
start_odometer_km=90,
|
||||
end_odometer_km=100,
|
||||
requirements_complete=True,
|
||||
)
|
||||
later = Booking(
|
||||
public_ref="BK-DQ-EARLY-B",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=datetime(2048, 1, 10, tzinfo=UTC),
|
||||
ends_at=datetime(2048, 2, 1, tzinfo=UTC),
|
||||
status="returned",
|
||||
start_odometer_km=100,
|
||||
end_odometer_km=200,
|
||||
requirements_complete=True,
|
||||
)
|
||||
db.add_all([early, later])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-EARLY-A",
|
||||
booking_id=early.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=100,
|
||||
completed_at=datetime(2048, 1, 2, tzinfo=UTC),
|
||||
),
|
||||
Inspection(
|
||||
public_ref="INSP-DQ-EARLY-B",
|
||||
booking_id=later.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=50,
|
||||
cleanliness_ok=True,
|
||||
damage_reported=False,
|
||||
technical_warning=False,
|
||||
odometer_km=200,
|
||||
completed_at=datetime(2048, 2, 1, tzinfo=UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
vehicle_id = vehicle.id
|
||||
customer_id = customer.id
|
||||
db.commit()
|
||||
try:
|
||||
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
|
||||
with SessionLocal() as db:
|
||||
assert (
|
||||
db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
)
|
||||
)
|
||||
is None
|
||||
)
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
|
||||
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
|
||||
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_manual_scan_records_audit_event(ops_client):
|
||||
|
||||
Reference in New Issue
Block a user