The return-review step predicted operational consequences independently in
the frontend, and got it wrong: damage or a technical warning was described
as routing to "maintenance" when the actual domain rule (returns.py) routes
it to "blocked", and the no-contradiction case was described as becoming
"available" when the vehicle actually always goes to "cleaning" first
(only reaching "maintenance" if the service threshold was crossed).
Extract the evaluation returns.py already performed inline into a pure
evaluate_return() function with no writes -- resulting status (with an
explanation), odometer regression, would-create-quality-issue,
next-booking-risk -- and share it between a new non-mutating
POST /bookings/{ref}/return-preview endpoint and the existing commit path,
so preview and commit can never drift apart again. The result screen also
now distinguishes local commit success from n8n delivery (still queued/
unconfirmed) instead of implying both succeeded, and links to any created
quality issue for Operations Manager.
308 lines
11 KiB
Python
308 lines
11 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
|