M2: implement vehicle return vertical slice

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).
This commit is contained in:
NuklearRabbit
2026-08-01 21:49:46 +02:00
parent 03c5b60235
commit 0091c57c7f
14 changed files with 763 additions and 10 deletions
+160
View File
@@ -0,0 +1,160 @@
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