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:
@@ -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
|
||||
Reference in New Issue
Block a user