Files
MobilityOps/backend/tests/test_return.py
T
NuklearRabbit 0091c57c7f 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).
2026-08-01 21:49:46 +02:00

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