316 lines
12 KiB
Python
316 lines
12 KiB
Python
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"]
|
|
|
|
|
|
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)
|