import threading import uuid from datetime import UTC, datetime, timedelta from fastapi.testclient import TestClient from sqlalchemy import select from app.core.db import SessionLocal from app.main import app from app.models.audit import AuditEvent from app.models.booking import Booking from app.models.customer import Customer from app.models.outbox import OutboxEvent 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 _set_next_service_km(vehicle_ref: str, threshold: int) -> None: db = SessionLocal() try: vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle.next_service_km = threshold db.commit() finally: db.close() def _add_reserved_booking(vehicle_ref: str, *, hours_from_now: float) -> str: db = SessionLocal() try: vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) customer = db.scalar(select(Customer)) starts_at = datetime.now(UTC) + timedelta(hours=hours_from_now) public_ref = f"BK-TEST-{uuid.uuid4().hex[:8].upper()}" booking = Booking( public_ref=public_ref, customer_id=customer.id, vehicle_id=vehicle.id, starts_at=starts_at, ends_at=starts_at + timedelta(days=2), status="reserved", requirements_complete=True, ) db.add(booking) db.commit() return public_ref finally: db.close() def _counts() -> tuple[int, int]: db = SessionLocal() try: return ( len(db.scalars(select(AuditEvent)).all()), len(db.scalars(select(OutboxEvent)).all()), ) finally: db.close() def test_preview_performs_no_writes_and_matches_commit(ops_client): booking_ref = _activate_booking("MO-006", start_odometer_km=30000) vehicle_before = ops_client.get("/api/v1/vehicles/MO-006").json() new_reading = vehicle_before["odometer_km"] + 25 body = _return_body(end_odometer_km=new_reading) audit_before, outbox_before = _counts() preview = ops_client.post(f"/api/v1/bookings/{booking_ref}/return-preview", json=body) assert preview.status_code == 200 preview_body = preview.json() audit_after, outbox_after = _counts() assert (audit_after, outbox_after) == (audit_before, outbox_before) booking_mid = ops_client.get(f"/api/v1/bookings/{booking_ref}").json() assert booking_mid["status"] == "active" # preview did not mutate the booking vehicle_mid = ops_client.get("/api/v1/vehicles/MO-006").json() assert vehicle_mid["odometer_km"] == vehicle_before["odometer_km"] assert preview_body["odometer_regression"] is False assert preview_body["resulting_odometer_km"] == new_reading assert preview_body["canonical_odometer_km"] == vehicle_before["odometer_km"] commit = ops_client.post( f"/api/v1/bookings/{booking_ref}/return", json=body, headers={"Idempotency-Key": "test-preview-matches-commit-001"}, ) assert commit.status_code == 201 commit_body = commit.json() assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"] assert commit_body["odometer_regression"] == preview_body["odometer_regression"] assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"] assert commit_body["correlation_id"] assert len(commit_body["correlation_id"]) == 36 def test_preview_detects_odometer_regression(ops_client): booking_ref = _activate_booking("MO-007", start_odometer_km=15000) vehicle_before = ops_client.get("/api/v1/vehicles/MO-007").json() low_reading = vehicle_before["odometer_km"] - 100 preview = ops_client.post( f"/api/v1/bookings/{booking_ref}/return-preview", json=_return_body(end_odometer_km=low_reading), ) assert preview.status_code == 200 body = preview.json() assert body["odometer_regression"] is True assert body["would_create_quality_issue"] is True assert "odometer_regression" in body["attention_reasons"] assert body["resulting_odometer_km"] == vehicle_before["odometer_km"] vehicle_after = ops_client.get("/api/v1/vehicles/MO-007").json() assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"] def test_preview_detects_service_due(ops_client): booking_ref = _activate_booking("MO-009", start_odometer_km=18000) vehicle_before = ops_client.get("/api/v1/vehicles/MO-009").json() _set_next_service_km("MO-009", vehicle_before["odometer_km"] + 50) preview = ops_client.post( f"/api/v1/bookings/{booking_ref}/return-preview", json=_return_body(end_odometer_km=vehicle_before["odometer_km"] + 100), ) assert preview.status_code == 200 body = preview.json() assert body["resulting_vehicle_status"] == "maintenance" assert "service threshold" in body["status_reason"] def test_preview_detects_next_booking_risk(ops_client): booking_ref = _activate_booking("MO-011", start_odometer_km=19000) _add_reserved_booking("MO-011", hours_from_now=2) preview = ops_client.post( f"/api/v1/bookings/{booking_ref}/return-preview", json=_return_body(end_odometer_km=19500), ) assert preview.status_code == 200 risk = preview.json()["next_booking_risk"] assert risk is not None assert risk["at_risk"] is True # less than 4 hours away def test_preview_requires_active_booking(ops_client): booking_ref = _activate_booking("MO-014", start_odometer_km=21000) ops_client.post( f"/api/v1/bookings/{booking_ref}/return", json=_return_body(end_odometer_km=21500), headers={"Idempotency-Key": "test-preview-requires-active-001"}, ) preview = ops_client.post( f"/api/v1/bookings/{booking_ref}/return-preview", json=_return_body(end_odometer_km=22000), ) assert preview.status_code == 409 assert preview.json()["error"]["code"] == "INVALID_BOOKING_STATE" 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 def test_return_public_refs_are_collision_resistant() -> None: from app.services.returns import _new_inspection_ref refs = {_new_inspection_ref() for _ in range(1000)} assert len(refs) == 1000 assert all(ref.startswith("INSP-") and len(ref) == 15 for ref in refs)