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