Transactional return command with idempotency, row-lock concurrency control, odometer-regression handling, vehicle status derivation, outbox event, audit trail. Result-summary UI on booking detail. 26 backend tests passing, ruff clean. Verified end-to-end via browser against S1 demo scenario; fixed two real defects found only through browser testing (UI state loss on status transition, unflushed UUID default).
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
import threading
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from app.core.db import SessionLocal
|
|
from app.main import app
|
|
from app.models.booking import Booking
|
|
from app.models.vehicle import Vehicle
|
|
|
|
|
|
def _return_body(**overrides):
|
|
body = {
|
|
"end_odometer_km": 54200,
|
|
"fuel_level_percent": 60,
|
|
"cleanliness_ok": True,
|
|
"damage_reported": False,
|
|
"technical_warning": False,
|
|
"notes": "Handed back on time.",
|
|
}
|
|
body.update(overrides)
|
|
return body
|
|
|
|
|
|
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
|
"""Flip one returned booking for the given vehicle back to 'active' for a fresh test fixture."""
|
|
db = SessionLocal()
|
|
try:
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
|
booking = db.scalar(
|
|
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
|
|
)
|
|
booking.status = "active"
|
|
booking.start_odometer_km = start_odometer_km
|
|
booking.end_odometer_km = None
|
|
db.commit()
|
|
return booking.public_ref
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_register_return_success_updates_canonical_odometer(ops_client):
|
|
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
|
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
|
|
new_reading = vehicle_before["odometer_km"] + 10
|
|
|
|
response = ops_client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=new_reading),
|
|
headers={"Idempotency-Key": "test-return-success-001"},
|
|
)
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["resulting_vehicle_status"] in ("cleaning", "maintenance")
|
|
assert body["odometer_regression"] is False
|
|
assert body["quality_issue_ref"] is None
|
|
|
|
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
|
assert booking["status"] == "returned"
|
|
assert booking["end_odometer_km"] == new_reading
|
|
|
|
vehicle = ops_client.get("/api/v1/vehicles/MO-003").json()
|
|
assert vehicle["odometer_km"] == new_reading
|
|
assert vehicle["operational_status"] in ("cleaning", "maintenance")
|
|
|
|
|
|
def test_register_return_matches_s1_demo_scenario(ops_client):
|
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-024").json()
|
|
low_reading = vehicle_before["odometer_km"] - 500
|
|
|
|
response = ops_client.post(
|
|
"/api/v1/bookings/BK-DEMO-RETURN/return",
|
|
json=_return_body(end_odometer_km=low_reading),
|
|
headers={"Idempotency-Key": "test-return-s1-001"},
|
|
)
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["odometer_regression"] is True
|
|
assert body["quality_issue_ref"] is not None
|
|
|
|
vehicle = ops_client.get("/api/v1/vehicles/MO-024").json()
|
|
assert vehicle["odometer_km"] == vehicle_before["odometer_km"] # canonical odometer unchanged
|
|
booking = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
|
assert booking["status"] == "returned"
|
|
assert booking["end_odometer_km"] == low_reading # submitted reading is still recorded
|
|
|
|
|
|
def test_register_return_damage_blocks_vehicle(employee_client):
|
|
booking_ref = _activate_booking("MO-005", start_odometer_km=22000)
|
|
response = employee_client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=22500, damage_reported=True),
|
|
headers={"Idempotency-Key": "test-return-damage-001"},
|
|
)
|
|
assert response.status_code == 201
|
|
assert response.json()["resulting_vehicle_status"] == "blocked"
|
|
|
|
|
|
def test_register_return_replays_on_same_idempotency_key(ops_client):
|
|
booking_ref = _activate_booking("MO-008", start_odometer_km=24000)
|
|
key = "test-return-replay-001"
|
|
first = ops_client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=24500),
|
|
headers={"Idempotency-Key": key},
|
|
)
|
|
second = ops_client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=24500),
|
|
headers={"Idempotency-Key": key},
|
|
)
|
|
assert first.status_code == 201
|
|
assert second.status_code == 201
|
|
assert first.json() == second.json()
|
|
|
|
|
|
def test_register_return_rejects_already_returned_booking(ops_client):
|
|
booking_ref = _activate_booking("MO-010", start_odometer_km=25000)
|
|
ops_client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=25500),
|
|
headers={"Idempotency-Key": "test-return-double-001"},
|
|
)
|
|
second = ops_client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=25999),
|
|
headers={"Idempotency-Key": "test-return-double-002"},
|
|
)
|
|
assert second.status_code == 409
|
|
assert second.json()["error"]["code"] == "INVALID_BOOKING_STATE"
|
|
|
|
|
|
def test_register_return_requires_idempotency_key(ops_client):
|
|
booking_ref = _activate_booking("MO-012", start_odometer_km=26500)
|
|
response = ops_client.post(f"/api/v1/bookings/{booking_ref}/return", json=_return_body())
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_concurrent_returns_only_one_succeeds():
|
|
booking_ref = _activate_booking("MO-013", start_odometer_km=27000)
|
|
results: list[int] = []
|
|
|
|
def submit(key: str) -> None:
|
|
client = TestClient(app)
|
|
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
|
resp = client.post(
|
|
f"/api/v1/bookings/{booking_ref}/return",
|
|
json=_return_body(end_odometer_km=27500),
|
|
headers={"Idempotency-Key": key},
|
|
)
|
|
results.append(resp.status_code)
|
|
|
|
threads = [threading.Thread(target=submit, args=(f"concurrent-key-{i}",)) for i in range(3)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert results.count(201) == 1
|
|
assert results.count(409) == 2
|