Publish curated MobilityOps source snapshot
This commit is contained in:
commit
7adc072f5e
317 files changed
+48851
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.db import Base, SessionLocal, engine
|
||||
from app.main import app
|
||||
from app.seed_loader import reset_and_seed
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _seeded_database():
|
||||
Base.metadata.create_all(engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def login(client: TestClient, role: str) -> TestClient:
|
||||
response = client.post("/api/v1/demo/login", json={"role": role})
|
||||
assert response.status_code == 200
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ops_client(client: TestClient) -> TestClient:
|
||||
return login(client, "operations_manager")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def employee_client(client: TestClient) -> TestClient:
|
||||
return login(client, "rental_employee")
|
||||
@@ -0,0 +1,83 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.booking import Booking
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
def test_demo_login_is_audited(ops_client):
|
||||
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
|
||||
assert response.status_code == 200
|
||||
events = response.json()
|
||||
assert len(events) >= 1
|
||||
assert events[0]["action"] == "demo_login"
|
||||
|
||||
|
||||
def test_audit_requires_authentication(client):
|
||||
response = client.get("/api/v1/audit")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_audit_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/audit")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
|
||||
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body["items"]) <= 25
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 25
|
||||
assert body["total"] >= len(body["items"])
|
||||
assert body["total_pages"] >= 1
|
||||
|
||||
|
||||
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||
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_return_registered_audit_event_exposes_before_after_and_link(ops_client):
|
||||
booking_ref = _activate_booking("MO-015", start_odometer_km=17000)
|
||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-015").json()
|
||||
ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json={
|
||||
"end_odometer_km": vehicle_before["odometer_km"] + 10,
|
||||
"fuel_level_percent": 50,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
headers={"Idempotency-Key": "test-audit-before-after-001"},
|
||||
)
|
||||
|
||||
key = "test-audit-before-after-001"
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "return_registered"}).json()
|
||||
event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
|
||||
assert event["before"] == {"status": "active"}
|
||||
assert event["after"]["status"] == "returned"
|
||||
assert event["entity_ref"] == booking_ref
|
||||
assert event["entity_link"] == f"/bookings/{booking_ref}"
|
||||
|
||||
vehicle_events = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={"action": "vehicle_status_changed", "correlation_id": event["correlation_id"]},
|
||||
).json()
|
||||
assert len(vehicle_events) == 1
|
||||
assert vehicle_events[0]["entity_ref"] == "MO-015"
|
||||
assert vehicle_events[0]["entity_link"] == "/vehicles/MO-015"
|
||||
assert vehicle_events[0]["before"]["odometer_km"] == vehicle_before["odometer_km"]
|
||||
@@ -0,0 +1,306 @@
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.db import SessionLocal, engine
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
|
||||
def test_unauthenticated_dashboard_is_rejected(client):
|
||||
response = client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["code"] == "401"
|
||||
|
||||
|
||||
def test_oidc_status_is_disabled_without_configuration(client):
|
||||
assert client.get("/api/v1/auth/oidc/status").json() == {
|
||||
"enabled": False,
|
||||
"provider_name": None,
|
||||
}
|
||||
assert client.get("/api/v1/auth/oidc/login").status_code == 404
|
||||
|
||||
|
||||
def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
|
||||
import app.api.routers.auth as auth_router
|
||||
|
||||
class FakeClient:
|
||||
async def authorize_access_token(self, _request):
|
||||
return {
|
||||
"userinfo": {
|
||||
"sub": "external-subject-1",
|
||||
"email": "oidc.user@example.test",
|
||||
"email_verified": True,
|
||||
"name": "OIDC User",
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOAuth:
|
||||
def create_client(self, _name):
|
||||
return FakeClient()
|
||||
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||
|
||||
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"].endswith("/dashboard")
|
||||
session = client.get("/api/v1/auth/session")
|
||||
assert session.status_code == 200
|
||||
assert session.json()["display_name"] == "OIDC User"
|
||||
assert session.json()["role"] == "rental_employee"
|
||||
|
||||
|
||||
def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch):
|
||||
import app.api.routers.auth as auth_router
|
||||
|
||||
class FakeClient:
|
||||
async def authorize_access_token(self, _request):
|
||||
return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}}
|
||||
|
||||
class FakeOAuth:
|
||||
def create_client(self, _name):
|
||||
return FakeClient()
|
||||
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||
|
||||
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||
assert response.status_code == 401
|
||||
assert client.get("/api/v1/auth/session").status_code == 401
|
||||
|
||||
|
||||
def test_demo_login_grants_access(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_rental_employee_cannot_reset_demo(employee_client):
|
||||
response = employee_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_operations_manager_can_reset_demo(ops_client):
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["counts"]["vehicles"] == 50
|
||||
assert body["anchor_date"]
|
||||
assert body["seeded_at"]
|
||||
assert body["scenario_integrity"]["all_ready"] is True
|
||||
assert body["scenario_integrity"]["not_ready"] == []
|
||||
|
||||
|
||||
def test_demo_reset_rejects_concurrent_rebuild(ops_client):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
assert demo_router._reset_guard.acquire(blocking=False)
|
||||
try:
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
finally:
|
||||
demo_router._reset_guard.release()
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_demo_reset_database_lock_blocks_another_api_replica(ops_client):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
# Use a raw connection: an ordinary Session intentionally participates in the
|
||||
# shared mutation barrier and would make an exclusive reset wait before it can test
|
||||
# this separate non-blocking replica lock.
|
||||
with engine.connect() as lock_connection:
|
||||
lock_connection.scalar(select(func.pg_advisory_lock(demo_router._RESET_ADVISORY_LOCK_ID)))
|
||||
try:
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
finally:
|
||||
lock_connection.scalar(
|
||||
select(func.pg_advisory_unlock(demo_router._RESET_ADVISORY_LOCK_ID))
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_demo_reset_waits_for_active_mutation_and_then_replaces_it_atomically():
|
||||
started = threading.Event()
|
||||
completed = threading.Event()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def reset_in_other_session() -> None:
|
||||
started.set()
|
||||
try:
|
||||
with SessionLocal() as reset_db:
|
||||
reset_and_seed(reset_db)
|
||||
except BaseException as exc: # pragma: no cover - assertion reports thread failures
|
||||
errors.append(exc)
|
||||
finally:
|
||||
completed.set()
|
||||
|
||||
with SessionLocal() as mutation_db:
|
||||
vehicle = mutation_db.scalar(
|
||||
select(Vehicle).where(Vehicle.public_ref == "MO-001").with_for_update()
|
||||
)
|
||||
vehicle.location = "Concurrent mutation marker"
|
||||
mutation_db.flush()
|
||||
worker = threading.Thread(target=reset_in_other_session, daemon=True)
|
||||
worker.start()
|
||||
assert started.wait(timeout=1)
|
||||
assert not completed.wait(timeout=0.2)
|
||||
mutation_db.commit()
|
||||
|
||||
worker.join(timeout=10)
|
||||
assert not worker.is_alive(), "reset deadlocked behind the active mutation"
|
||||
assert errors == []
|
||||
with SessionLocal() as db:
|
||||
restored = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-001"))
|
||||
assert restored.location != "Concurrent mutation marker"
|
||||
|
||||
|
||||
def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60)
|
||||
with SessionLocal() as db:
|
||||
cooldown_event = record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label="Cooldown test",
|
||||
action="demo_reset",
|
||||
entity_type="system",
|
||||
)
|
||||
db.commit()
|
||||
cooldown_event_id = cooldown_event.id
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 429
|
||||
assert int(response.headers["retry-after"]) >= 1
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.id == cooldown_event_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_demo_reset_rolls_back_every_change_when_integrity_check_fails(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 0)
|
||||
with SessionLocal() as db:
|
||||
probe = record_audit_event(
|
||||
db,
|
||||
actor_type="system",
|
||||
actor_label="Atomic reset test",
|
||||
action="reset_atomicity_probe",
|
||||
entity_type="system",
|
||||
)
|
||||
db.commit()
|
||||
probe_id = probe.id
|
||||
|
||||
def fail_integrity_check(_db):
|
||||
raise RuntimeError("Injected integrity-check failure")
|
||||
|
||||
monkeypatch.setattr(demo_router, "scenario_integrity_report", fail_integrity_check)
|
||||
with pytest.raises(RuntimeError, match="Injected integrity-check failure"):
|
||||
ops_client.post("/api/v1/demo/reset")
|
||||
|
||||
with SessionLocal() as db:
|
||||
assert db.scalar(select(AuditEvent).where(AuditEvent.id == probe_id)) is not None
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.id == probe_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False)
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 403
|
||||
|
||||
# Restore real demo data: this test intentionally disabled reset, so a following test
|
||||
# module must not inherit a database left mid-mutation by an earlier test.
|
||||
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True)
|
||||
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||
|
||||
|
||||
def test_session_endpoint_requires_authentication(client):
|
||||
response = client.get("/api/v1/demo/session")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_session_endpoint_confirms_logged_in_user(ops_client):
|
||||
response = ops_client.get("/api/v1/demo/session")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["role"] == "operations_manager"
|
||||
assert body["public_ref"] == "USR-OPS"
|
||||
|
||||
|
||||
def test_logout_invalidates_session(ops_client):
|
||||
confirmed = ops_client.get("/api/v1/demo/session")
|
||||
assert confirmed.status_code == 200
|
||||
|
||||
logout = ops_client.post("/api/v1/demo/logout")
|
||||
assert logout.status_code == 200
|
||||
|
||||
after = ops_client.get("/api/v1/demo/session")
|
||||
assert after.status_code == 401
|
||||
|
||||
|
||||
def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
|
||||
from app.core.config import get_settings
|
||||
|
||||
cookie_name = get_settings().session_cookie_name
|
||||
stolen_token = ops_client.cookies.get(cookie_name)
|
||||
assert stolen_token
|
||||
|
||||
logout = ops_client.post("/api/v1/auth/logout")
|
||||
assert logout.status_code == 200
|
||||
|
||||
# Simulate the browser cookie race (or a copied cookie): server-side revocation is
|
||||
# authoritative and must reject the original signed token independently of deletion.
|
||||
ops_client.cookies.set(cookie_name, stolen_token)
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 401
|
||||
|
||||
# Remove the deliberately injected hostless cookie before exercising a normal browser
|
||||
# login. Otherwise httpx sends it alongside the real testserver cookie, which is not a
|
||||
# state a browser can create for the same origin/path pair.
|
||||
ops_client.cookies.clear()
|
||||
|
||||
# A fresh login in the same second receives a distinct signed token and remains valid.
|
||||
fresh_login = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||
assert fresh_login.status_code == 200
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 200
|
||||
|
||||
|
||||
def test_logout_without_a_session_is_safe(client):
|
||||
response = client.post("/api/v1/demo/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_demo_reset_preserves_integration_telemetry(ops_client, client):
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
probe = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary",
|
||||
headers={
|
||||
"X-Service-Token": settings.mcp_hub_service_token,
|
||||
"X-Client-Id": "itworx-mcp-hub:mobilityops:reset-probe",
|
||||
},
|
||||
)
|
||||
assert probe.status_code == 200
|
||||
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||
|
||||
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
|
||||
events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert any(
|
||||
event["actor_label"] == "itworx-mcp-hub"
|
||||
and event["metadata"].get("reported_client_id", "").endswith(":reset-probe")
|
||||
for event in events
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_list_bookings_filters_by_vehicle(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"})
|
||||
assert response.status_code == 200
|
||||
bookings = response.json()
|
||||
assert len(bookings) >= 1
|
||||
assert all(b["vehicle_ref"] == "MO-024" for b in bookings)
|
||||
|
||||
|
||||
def test_list_bookings_supports_bounded_search_pages(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/bookings",
|
||||
params={"query": "BK-", "page": 1, "page_size": 25},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 25
|
||||
assert body["total"] > 25
|
||||
assert body["total_pages"] > 1
|
||||
assert len(body["items"]) == 25
|
||||
|
||||
|
||||
def test_list_bookings_filters_operational_window_and_location(ops_client):
|
||||
booking = ops_client.get("/api/v1/bookings").json()[0]
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{booking['vehicle_ref']}").json()
|
||||
starts_at = datetime.fromisoformat(booking["starts_at"])
|
||||
response = ops_client.get(
|
||||
"/api/v1/bookings",
|
||||
params={
|
||||
"starts_from": (starts_at - timedelta(minutes=1)).isoformat(),
|
||||
"starts_to": (starts_at + timedelta(minutes=1)).isoformat(),
|
||||
"location": vehicle["location"],
|
||||
"sort": "starts_asc",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert booking["public_ref"] in {item["public_ref"] for item in response.json()}
|
||||
|
||||
|
||||
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
|
||||
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||
conflict = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": existing["vehicle_ref"],
|
||||
"starts_at": existing["starts_at"],
|
||||
"ends_at": existing["ends_at"],
|
||||
},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[
|
||||
0
|
||||
]["public_ref"]
|
||||
created = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": available_vehicle,
|
||||
"starts_at": "2030-09-01T10:00:00Z",
|
||||
"ends_at": "2030-09-02T12:00:00Z",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["status"] == "reserved"
|
||||
assert body["vehicle_ref"] == available_vehicle
|
||||
assert body["requirements_complete"] is False
|
||||
|
||||
|
||||
def test_booking_requirements_are_explicit_and_audited(ops_client):
|
||||
window = {"starts_at": "2031-09-01T10:00:00Z", "ends_at": "2031-09-02T12:00:00Z"}
|
||||
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
|
||||
).json()
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": 100000,
|
||||
"fuel_level_percent": 90,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
assert checkout.status_code == 409
|
||||
|
||||
whitespace_only = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": " "},
|
||||
)
|
||||
assert whitespace_only.status_code == 422
|
||||
assert (
|
||||
ops_client.get(f"/api/v1/bookings/{booking['public_ref']}").json()["requirements_complete"]
|
||||
is False
|
||||
)
|
||||
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["requirements_complete"] is True
|
||||
audit = ops_client.get("/api/v1/audit", params={"action": "booking_requirements_completed"})
|
||||
assert audit.status_code == 200
|
||||
assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json())
|
||||
|
||||
|
||||
def test_booking_creation_cannot_preconfirm_requirements(ops_client):
|
||||
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
|
||||
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
|
||||
|
||||
created = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
assert created.json()["requirements_complete"] is False
|
||||
audit = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={"action": "booking_requirements_completed"},
|
||||
)
|
||||
assert all(item["entity_ref"] != created.json()["public_ref"] for item in audit.json())
|
||||
|
||||
|
||||
def test_customer_search_returns_canonical_customers(ops_client):
|
||||
response = ops_client.get("/api/v1/customers", params={"query": "CUS-"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()
|
||||
assert all(item["public_ref"].startswith("CUS-") for item in response.json())
|
||||
|
||||
|
||||
def test_booking_availability_excludes_overlapping_vehicle(ops_client):
|
||||
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||
response = ops_client.get(
|
||||
"/api/v1/bookings/availability",
|
||||
params={"starts_at": existing["starts_at"], "ends_at": existing["ends_at"]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert existing["vehicle_ref"] not in {item["public_ref"] for item in response.json()}
|
||||
|
||||
|
||||
def test_reserved_booking_can_be_cancelled_once(ops_client):
|
||||
window = {"starts_at": "2032-09-01T10:00:00Z", "ends_at": "2032-09-02T12:00:00Z"}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
assert available
|
||||
create_response = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": available[0]["public_ref"],
|
||||
**window,
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()
|
||||
response = ops_client.post(
|
||||
f"/api/v1/bookings/{created['public_ref']}/cancel",
|
||||
json={"reason": "Customer changed plans"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "cancelled"
|
||||
repeated = ops_client.post(
|
||||
f"/api/v1/bookings/{created['public_ref']}/cancel",
|
||||
json={"reason": "Customer changed plans"},
|
||||
)
|
||||
assert repeated.status_code == 409
|
||||
|
||||
|
||||
def test_reserved_booking_can_be_rescheduled_with_overlap_protection(ops_client):
|
||||
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
|
||||
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={"customer_ref": "CUS-0001", "vehicle_ref": existing["vehicle_ref"], **window},
|
||||
).json()
|
||||
updated = ops_client.patch(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/schedule",
|
||||
json={
|
||||
"starts_at": "2033-09-03T10:00:00Z",
|
||||
"ends_at": "2033-09-04T12:00:00Z",
|
||||
"reason": "Customer requested a later collection",
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["starts_at"].startswith("2033-09-03T10:00:00")
|
||||
|
||||
conflict = ops_client.patch(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/schedule",
|
||||
json={
|
||||
"starts_at": existing["starts_at"],
|
||||
"ends_at": existing["ends_at"],
|
||||
"reason": "Conflicting test move",
|
||||
},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
|
||||
|
||||
def test_concurrent_bookings_only_reserve_vehicle_once():
|
||||
results: list[int] = []
|
||||
seed_client = TestClient(app)
|
||||
seed_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||
window = {"starts_at": "2040-09-01T10:00:00Z", "ends_at": "2040-09-02T12:00:00Z"}
|
||||
available = seed_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
assert available
|
||||
vehicle_ref = available[0]["public_ref"]
|
||||
|
||||
def submit() -> None:
|
||||
client = TestClient(app)
|
||||
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||
response = client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle_ref,
|
||||
**window,
|
||||
},
|
||||
)
|
||||
results.append(response.status_code)
|
||||
|
||||
threads = [threading.Thread(target=submit) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert results.count(201) == 1
|
||||
assert results.count(409) == 1
|
||||
|
||||
|
||||
def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
|
||||
window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
vehicle_option = next(item for item in available if item["operational_status"] == "available")
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
response = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": vehicle["odometer_km"],
|
||||
"fuel_level_percent": 95,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["activated"] is True
|
||||
assert response.json()["booking_status"] == "active"
|
||||
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert updated_vehicle["operational_status"] == "rented"
|
||||
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
|
||||
checkout_audit = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={"action": "booking_checkout_recorded"},
|
||||
).json()
|
||||
booking_event = next(
|
||||
item for item in checkout_audit if item["entity_ref"] == booking["public_ref"]
|
||||
)
|
||||
vehicle_audit = ops_client.get(
|
||||
"/api/v1/audit",
|
||||
params={
|
||||
"action": "vehicle_status_changed",
|
||||
"correlation_id": booking_event["correlation_id"],
|
||||
},
|
||||
).json()
|
||||
assert len(vehicle_audit) == 1
|
||||
assert vehicle_audit[0]["entity_ref"] == vehicle["public_ref"]
|
||||
assert vehicle_audit[0]["before"]["operational_status"] == "available"
|
||||
assert vehicle_audit[0]["after"]["operational_status"] == "rented"
|
||||
|
||||
|
||||
def test_checkout_odometer_regression_keeps_canonical_and_opens_bounded_quality_issue(
|
||||
ops_client,
|
||||
):
|
||||
window = {"starts_at": "2052-09-01T10:00:00Z", "ends_at": "2052-09-02T12:00:00Z"}
|
||||
existing_issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
vehicle_option = next(
|
||||
item
|
||||
for item in available
|
||||
if item["operational_status"] == "available" and item["public_ref"] not in unavailable_refs
|
||||
)
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||
assert vehicle["odometer_km"] > 0
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0003",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": vehicle["odometer_km"] - 1,
|
||||
"fuel_level_percent": 90,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert checkout.status_code == 200
|
||||
assert checkout.json()["booking_status"] == "blocked"
|
||||
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert updated_vehicle["odometer_km"] == vehicle["odometer_km"]
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
|
||||
assert issue["evidence"]["source_type"] == "checkout"
|
||||
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||
detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||
assert any(snapshot["entity_type"] == "inspection" for snapshot in detail["related_snapshots"])
|
||||
invalid_correction = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={
|
||||
"decision": "correct_reading",
|
||||
"booking_ref": booking["public_ref"],
|
||||
"corrected_odometer_km": vehicle["odometer_km"],
|
||||
},
|
||||
)
|
||||
assert invalid_correction.status_code == 422
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical"},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
assert retained.json()["status"] == "resolved"
|
||||
|
||||
|
||||
def test_get_booking_detail(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "active"
|
||||
assert body["vehicle_ref"] == "MO-024"
|
||||
|
||||
|
||||
def test_get_booking_404_for_unknown_ref(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings/BK-UNKNOWN")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
def test_dashboard_metrics_are_persisted_counts(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
metrics = body["metrics"]
|
||||
total = (
|
||||
metrics["available"]
|
||||
+ metrics["rented"]
|
||||
+ metrics["cleaning"]
|
||||
+ metrics["maintenance"]
|
||||
+ metrics["blocked"]
|
||||
)
|
||||
assert total == 50
|
||||
assert metrics["open_quality_issues"] >= 1
|
||||
assert metrics["pending_or_failed_workflows"] >= 1
|
||||
|
||||
|
||||
def test_dashboard_metrics_change_when_persisted_fleet_data_changes(ops_client):
|
||||
before = ops_client.get("/api/v1/dashboard").json()["metrics"]
|
||||
db = SessionLocal()
|
||||
probe = Vehicle(
|
||||
public_ref="MO-METRIC-PROBE",
|
||||
make="Synthetic",
|
||||
model="Metric probe",
|
||||
model_year=2026,
|
||||
registration_number="TEST-METRIC-PROBE",
|
||||
location="Test fixture",
|
||||
operational_status="blocked",
|
||||
odometer_km=0,
|
||||
next_service_km=1,
|
||||
active=True,
|
||||
version=1,
|
||||
)
|
||||
try:
|
||||
db.add(probe)
|
||||
db.commit()
|
||||
|
||||
after_response = ops_client.get("/api/v1/dashboard")
|
||||
assert after_response.status_code == 200
|
||||
after = after_response.json()["metrics"]
|
||||
assert after["blocked"] == before["blocked"] + 1
|
||||
fleet_statuses = ("available", "rented", "cleaning", "maintenance", "blocked")
|
||||
assert (
|
||||
sum(after[status] for status in fleet_statuses)
|
||||
== sum(before[status] for status in fleet_statuses) + 1
|
||||
)
|
||||
finally:
|
||||
persisted_probe = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-METRIC-PROBE"))
|
||||
if persisted_probe is not None:
|
||||
db.delete(persisted_probe)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_dashboard_attention_items_link_to_records(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
body = response.json()
|
||||
assert len(body["attention_items"]) > 0
|
||||
for item in body["attention_items"]:
|
||||
assert item["link_ref"]
|
||||
assert item["severity"] in ("low", "medium", "high")
|
||||
|
||||
|
||||
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
|
||||
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
|
||||
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
|
||||
The API must never emit prose here -- only stable signal codes + params, exactly
|
||||
like the data-quality issue detail page, for the frontend to localize."""
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
body = response.json()
|
||||
assert len(body["attention_items"]) > 0
|
||||
for item in body["attention_items"]:
|
||||
assert "detail" not in item
|
||||
assert len(item["evidence_signals"]) > 0
|
||||
for signal in item["evidence_signals"]:
|
||||
assert signal["code"]
|
||||
assert signal["code"] != "Synthetic deterministic seed issue"
|
||||
|
||||
|
||||
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
body = response.json()
|
||||
assert len(body["recent_automation"]) == 5
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("statement", "constraint_name"),
|
||||
(
|
||||
(
|
||||
"UPDATE vehicles SET odometer_km = -1 WHERE public_ref = 'MO-001'",
|
||||
"ck_vehicles_odometer",
|
||||
),
|
||||
(
|
||||
"UPDATE bookings SET ends_at = starts_at WHERE public_ref = 'BK-DEMO-RETURN'",
|
||||
"ck_bookings_time_window",
|
||||
),
|
||||
(
|
||||
"UPDATE data_quality_issues SET status = 'invented' "
|
||||
"WHERE public_ref = 'DQ-DEMO-DUPLICATE'",
|
||||
"ck_data_quality_status",
|
||||
),
|
||||
("UPDATE outbox_events SET attempts = -1", "ck_outbox_attempts"),
|
||||
),
|
||||
)
|
||||
def test_database_rejects_invalid_domain_state(statement: str, constraint_name: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
with pytest.raises(IntegrityError) as caught:
|
||||
db.execute(text(statement))
|
||||
db.commit()
|
||||
db.rollback()
|
||||
assert constraint_name in str(caught.value)
|
||||
@@ -0,0 +1,158 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services import demo_manifest
|
||||
from app.services.knowledge import KnowledgeHealth
|
||||
|
||||
|
||||
def test_demo_manifest_is_public(client):
|
||||
# No login call at all -- the demo-entry screen and badge need this before any
|
||||
# session exists.
|
||||
response = client.get("/api/v1/demo/manifest")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_demo_manifest_shape(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
assert body["organization_name"] == "Northstar Mobility"
|
||||
assert body["demo_mode"] is True
|
||||
assert body["synthetic_data"] is True
|
||||
assert body["allow_reset"] is True
|
||||
assert body["timezone"] == "Europe/Brussels"
|
||||
assert body["guide_available"] is True
|
||||
assert set(body["required_roles"]) == {"operations_manager", "rental_employee"}
|
||||
assert body["last_reset_at"] is not None
|
||||
assert body["anchor_date"] is not None
|
||||
|
||||
scenario_ids = {s["id"] for s in body["scenarios"]}
|
||||
assert scenario_ids == {
|
||||
"return-anomaly",
|
||||
"duplicate-customer",
|
||||
"booking-overlap",
|
||||
"automation-retry",
|
||||
"knowledge-question",
|
||||
}
|
||||
integration_keys = {i["key"] for i in body["integrations"]}
|
||||
assert integration_keys == {"n8n", "ragcore", "mcp_hub"}
|
||||
|
||||
|
||||
def test_demo_manifest_scenarios_ready_after_fresh_reset(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenarios = {s["id"]: s for s in body["scenarios"]}
|
||||
for scenario_id, scenario in scenarios.items():
|
||||
assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset"
|
||||
assert scenario["blocked_reason_code"] is None
|
||||
assert scenario["start_path"]
|
||||
|
||||
|
||||
def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "demoMode"
|
||||
|
||||
|
||||
def test_automation_scenario_requires_the_exact_prepared_failure():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == demo_manifest._FAILED_DEMO_EVENT_ID)
|
||||
)
|
||||
assert event is not None
|
||||
event.last_error_code = "connectionError"
|
||||
db.flush()
|
||||
|
||||
scenario = next(
|
||||
item for item in demo_manifest._scenarios(db) if item.id == "automation-retry"
|
||||
)
|
||||
assert scenario.ready is False
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_knowledge_scenario_rejects_available_provider_with_verified_empty_corpus(
|
||||
client, monkeypatch
|
||||
):
|
||||
class EmptyKnowledgeProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="Ready, but no indexed documents.",
|
||||
tenant="fleet-ops",
|
||||
workspace="mobilityops",
|
||||
collection="procedures",
|
||||
document_count=0,
|
||||
source_document_count=4,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(demo_manifest, "get_knowledge_provider", EmptyKnowledgeProvider)
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question")
|
||||
assert scenario["ready"] is False
|
||||
assert scenario["blocked_reason_code"] == "knowledgeUnavailable"
|
||||
ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "unavailable"
|
||||
|
||||
|
||||
def test_knowledge_scenario_rejects_local_sources_when_index_count_is_unverified(
|
||||
client, monkeypatch
|
||||
):
|
||||
class SourceAwareKnowledgeProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="Ready; index count endpoint is unavailable.",
|
||||
tenant="fleet-ops",
|
||||
workspace="mobilityops",
|
||||
collection="procedures",
|
||||
document_count=None,
|
||||
source_document_count=4,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="not_reported",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(demo_manifest, "get_knowledge_provider", SourceAwareKnowledgeProvider)
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question")
|
||||
assert scenario["ready"] is False
|
||||
assert scenario["blocked_reason_code"] == "knowledgeUnavailable"
|
||||
ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "unavailable"
|
||||
|
||||
|
||||
def test_knowledge_scenario_requires_provider_availability_even_with_documents():
|
||||
health = KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=False,
|
||||
detail="Provider is unreachable.",
|
||||
tenant="fleet-ops",
|
||||
workspace="mobilityops",
|
||||
collection="procedures",
|
||||
document_count=4,
|
||||
source_document_count=4,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
assert demo_manifest._knowledge_scenario_ready(health) is False
|
||||
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.booking import Booking
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.services import dispatcher
|
||||
|
||||
|
||||
def _make_pending_event(vehicle_ref: str) -> uuid.UUID:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
booking = db.scalar(select(Booking).where(Booking.status == "returned").limit(1))
|
||||
event = OutboxEvent(
|
||||
event_id=uuid.uuid4(),
|
||||
event_type="vehicle.returned.v1",
|
||||
aggregate_type="booking",
|
||||
aggregate_id=booking.id,
|
||||
payload_json={
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"aggregate": {
|
||||
"type": "booking",
|
||||
"id": str(booking.id),
|
||||
"public_ref": booking.public_ref,
|
||||
},
|
||||
"data": {
|
||||
"vehicle_ref": vehicle_ref,
|
||||
"inspection_ref": "INSP-TEST",
|
||||
"resulting_vehicle_status": "cleaning",
|
||||
"attention_reasons": [],
|
||||
},
|
||||
"aggregate_ref": booking.public_ref,
|
||||
},
|
||||
occurred_at=datetime.now(UTC),
|
||||
delivery_status="pending",
|
||||
attempts=0,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
return event.event_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get_event(event_id: uuid.UUID) -> OutboxEvent:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_claim_marks_events_delivering():
|
||||
event_id = _make_pending_event("MO-001")
|
||||
claimed = dispatcher._claim_due_events()
|
||||
assert event_id in claimed
|
||||
assert _get_event(event_id).delivery_status == "delivering"
|
||||
|
||||
|
||||
def test_deliver_one_success(monkeypatch):
|
||||
event_id = _make_pending_event("MO-002")
|
||||
execution_id = "n8n-execution-123"
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"ok": True,
|
||||
"event_id": str(event_id),
|
||||
"result": {"execution_id": execution_id},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "succeeded"
|
||||
assert event.attempts == 1
|
||||
assert event.external_run_id == execution_id
|
||||
assert event.last_error is None
|
||||
assert event.last_error_code is None
|
||||
|
||||
|
||||
def test_deliver_one_failure_schedules_retry(monkeypatch):
|
||||
event_id = _make_pending_event("MO-003")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.attempts == 1
|
||||
assert event.next_attempt_at is not None
|
||||
assert "simulated connection failure" in event.last_error
|
||||
assert event.last_error_code == "connectionError"
|
||||
|
||||
|
||||
def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
|
||||
# Reproduces a real failure mode found while live-validating the n8n webhook auth
|
||||
# fix: a workflow that errors internally before its "Respond to Webhook" node runs
|
||||
# can still answer with a 2xx status and an empty body. response.json() on that body
|
||||
# raises json.JSONDecodeError -- this must be treated as a retryable failure, not an
|
||||
# unhandled exception that leaves the event stuck in "delivering" forever.
|
||||
event_id = _make_pending_event("MO-005")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
def raise_json_error():
|
||||
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
||||
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None, json=raise_json_error, status_code=200
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.attempts == 1
|
||||
assert event.next_attempt_at is not None
|
||||
assert event.last_error_code == "malformedResponse"
|
||||
|
||||
|
||||
def test_deliver_one_rejects_json_object_without_explicit_success_ack(monkeypatch):
|
||||
event_id = _make_pending_event("MO-010")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.attempts == 1
|
||||
assert event.last_error_code == "malformedResponse"
|
||||
assert event.external_run_id is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [{}, {"execution_id": 123}, {"execution_id": " "}])
|
||||
def test_deliver_one_rejects_ack_without_valid_execution_id(monkeypatch, result):
|
||||
event_id = _make_pending_event("MO-EXEC-ID")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": result},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.last_error_code == "malformedResponse"
|
||||
assert event.external_run_id is None
|
||||
|
||||
|
||||
def test_deliver_one_rejects_success_ack_for_a_different_event(monkeypatch):
|
||||
event_id = _make_pending_event("MO-011")
|
||||
dispatcher._claim_due_events()
|
||||
other_event_id = uuid.uuid4()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"ok": True, "event_id": str(other_event_id)},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.attempts == 1
|
||||
assert event.last_error_code == "mismatchedEventId"
|
||||
assert event.external_run_id is None
|
||||
|
||||
|
||||
def test_late_delivery_outcome_cannot_overwrite_a_newer_lease(monkeypatch):
|
||||
event_id = _make_pending_event("MO-LEASE-RACE")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
# Simulate lease expiry/reclaim while the original worker is still in network
|
||||
# I/O. The later claimant owns a different token and its state must win.
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
|
||||
)
|
||||
event.payload_json = {
|
||||
**event.payload_json,
|
||||
"_delivery_claim_token": "newer-worker-token",
|
||||
}
|
||||
event.next_attempt_at = datetime.now(UTC) + timedelta(minutes=1)
|
||||
db.commit()
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"ok": True,
|
||||
"event_id": str(event_id),
|
||||
"result": {"execution_id": "stale-worker-execution"},
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "delivering"
|
||||
assert event.attempts == 0
|
||||
assert event.external_run_id is None
|
||||
assert event.payload_json["_delivery_claim_token"] == "newer-worker-token"
|
||||
|
||||
|
||||
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||
event_id = _make_pending_event("MO-004")
|
||||
settings = get_settings()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
raise dispatcher.httpx.ConnectError("still down")
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
|
||||
for _ in range(settings.n8n_max_attempts):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
event.next_attempt_at = None
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
claimed = dispatcher._claim_due_events(batch_size=1000)
|
||||
assert event_id in claimed
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "failed"
|
||||
assert event.attempts == settings.n8n_max_attempts
|
||||
|
||||
|
||||
def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch):
|
||||
# Regression test: seeded/legacy outbox rows may lack the full event envelope. Delivery
|
||||
# must resolve the claimed "delivering" row to pending/failed, never leave it stuck.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
booking = db.scalar(select(Booking).limit(1))
|
||||
event = OutboxEvent(
|
||||
event_id=uuid.uuid4(),
|
||||
event_type="vehicle.returned.v1",
|
||||
aggregate_type="booking",
|
||||
aggregate_id=booking.id,
|
||||
payload_json={"aggregate_ref": booking.public_ref}, # missing correlation_id/etc.
|
||||
occurred_at=datetime.now(UTC),
|
||||
delivery_status="pending",
|
||||
attempts=0,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
event_id = event.event_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
raise AssertionError("must not attempt delivery with a malformed payload")
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
|
||||
dispatcher._claim_due_events()
|
||||
dispatcher._deliver_one(event_id)
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status in ("pending", "failed")
|
||||
assert event.attempts == 1
|
||||
assert "Malformed outbox payload" in event.last_error
|
||||
assert event.last_error_code == "malformedPayload"
|
||||
|
||||
|
||||
def test_claim_sets_a_lease_deadline():
|
||||
event_id = _make_pending_event("MO-006")
|
||||
settings = get_settings()
|
||||
before = datetime.now(UTC)
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "delivering"
|
||||
assert event.next_attempt_at is not None
|
||||
lease = settings.n8n_delivery_lease_seconds
|
||||
assert event.next_attempt_at > before + timedelta(seconds=lease - 5)
|
||||
|
||||
|
||||
def test_reclaim_ignores_an_active_unexpired_lease():
|
||||
# A worker that is still within its lease window must not be disturbed -- this is
|
||||
# what prevents double delivery of an event another (still-alive) worker is handling.
|
||||
event_id = _make_pending_event("MO-007")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
reclaimed = dispatcher._reclaim_stale_deliveries()
|
||||
assert reclaimed == 0
|
||||
assert _get_event(event_id).delivery_status == "delivering"
|
||||
|
||||
|
||||
def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
|
||||
# Simulates a process crash: the row was claimed (delivering) but no outcome was ever
|
||||
# recorded, and its lease has since expired.
|
||||
event_id = _make_pending_event("MO-008")
|
||||
dispatcher._claim_due_events()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
event.attempts = 2
|
||||
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
reclaimed = dispatcher._reclaim_stale_deliveries()
|
||||
assert reclaimed == 1
|
||||
|
||||
event = _get_event(event_id)
|
||||
assert event.delivery_status == "pending"
|
||||
assert event.next_attempt_at is None
|
||||
assert event.attempts == 2
|
||||
assert "stale" in event.last_error.lower()
|
||||
assert event.last_error_code == "staleLeaseRecovered"
|
||||
|
||||
# The reclaimed event is now a normal pending event, immediately claimable again.
|
||||
claimed = dispatcher._claim_due_events()
|
||||
assert event_id in claimed
|
||||
|
||||
|
||||
def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
||||
event_id = _make_pending_event("MO-009")
|
||||
dispatcher._claim_due_events()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"ok": True,
|
||||
"event_id": str(event_id),
|
||||
"result": {"execution_id": "n8n-recovered-execution"},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
processed = dispatcher.run_dispatch_cycle()
|
||||
|
||||
assert processed >= 1
|
||||
assert _get_event(event_id).delivery_status == "succeeded"
|
||||
|
||||
|
||||
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||
event_id = _make_pending_event("MO-005")
|
||||
|
||||
def fake_post(url, json, headers, timeout):
|
||||
return SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"ok": True,
|
||||
"event_id": str(event_id),
|
||||
"result": {"execution_id": "n8n-cycle-execution"},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||
processed = dispatcher.run_dispatch_cycle()
|
||||
|
||||
assert processed >= 1
|
||||
assert _get_event(event_id).delivery_status == "succeeded"
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Regression tests for the hardening pass (security, robustness, data-quality edge cases)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.config import Settings, get_settings, insecure_default_secrets
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.errors import AppError
|
||||
from app.core.observability import UNMATCHED_ROUTE_LABEL
|
||||
from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.data_quality import defer_issue, run_scan
|
||||
from tests.test_return import _activate_booking, _return_body
|
||||
|
||||
|
||||
def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled():
|
||||
defaults = Settings(_env_file=None)
|
||||
assert "app_secret" in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" in insecure_default_secrets(defaults)
|
||||
hardened = Settings(
|
||||
_env_file=None,
|
||||
app_secret="x" * 32,
|
||||
n8n_callback_token="c" * 32,
|
||||
mcp_hub_service_token="m" * 32,
|
||||
)
|
||||
assert insecure_default_secrets(hardened) == []
|
||||
|
||||
|
||||
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||
monkeypatch.setenv("MOBILITYOPS_ENV", "production")
|
||||
monkeypatch.setenv("APP_SECRET", "replace-in-production")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="placeholder secrets"):
|
||||
get_settings()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
# The cached settings the running app relies on must be intact afterwards.
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("public_url", "secure_cookie", "message"),
|
||||
[
|
||||
("http://fleetops.example.test", "true", "must use HTTPS"),
|
||||
("https://fleetops.example.test", "false", "must be true"),
|
||||
],
|
||||
)
|
||||
def test_production_refuses_cleartext_or_insecure_session_cookie(
|
||||
monkeypatch, public_url, secure_cookie, message
|
||||
):
|
||||
values = {
|
||||
"MOBILITYOPS_ENV": "production",
|
||||
"APP_SECRET": "a" * 32,
|
||||
"N8N_CALLBACK_TOKEN": "c" * 32,
|
||||
"MCP_HUB_SERVICE_TOKEN": "m" * 32,
|
||||
"MOBILITYOPS_PUBLIC_URL": public_url,
|
||||
"SESSION_COOKIE_SECURE": secure_cookie,
|
||||
}
|
||||
for name, value in values.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
get_settings()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||
limiter.record_failure("1.2.3.4")
|
||||
assert limiter.retry_after_seconds("1.2.3.4") > 0
|
||||
assert limiter.retry_after_seconds("5.6.7.8") == 0
|
||||
limiter.reset("1.2.3.4")
|
||||
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||
|
||||
|
||||
def test_sliding_window_limiter_counts_successes_and_isolates_keys():
|
||||
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") > 0
|
||||
assert limiter.consume("session-b") == 0
|
||||
|
||||
|
||||
def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.routers.auth import _client_key
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")],
|
||||
"client": ("172.20.0.3", 12345),
|
||||
}
|
||||
)
|
||||
assert _client_key(request) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_audit_export_accepts_naive_datetimes(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
params={"occurred_from": "2026-01-01T00:00:00", "occurred_to": "2026-01-15T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_audit_list_rejects_non_uuid_correlation_id(ops_client):
|
||||
response = ops_client.get("/api/v1/audit", params={"correlation_id": "not-a-uuid"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_unmatched_routes_do_not_create_metric_series(client):
|
||||
probe = f"/api/v1/does-not-exist-{uuid.uuid4().hex}"
|
||||
assert client.get(probe).status_code == 404
|
||||
metrics = client.get("/metrics").text
|
||||
assert probe not in metrics
|
||||
assert UNMATCHED_ROUTE_LABEL in metrics
|
||||
|
||||
|
||||
def test_bookings_page_for_unknown_vehicle_keeps_page_shape(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-NOPE", "page": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"page_size": 25,
|
||||
"total": 0,
|
||||
"total_pages": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_return_idempotency_key_rejects_different_body(ops_client):
|
||||
booking_ref = _activate_booking("MO-011", start_odometer_km=30000)
|
||||
key = "test-return-fingerprint-001"
|
||||
first = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=30500),
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
replay = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=30500),
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert replay.status_code == 201
|
||||
mismatch = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=30999),
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert mismatch.status_code == 409
|
||||
assert mismatch.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
|
||||
|
||||
|
||||
def test_return_callback_rejects_malformed_correlation_id(client):
|
||||
event_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"event_id": event_id, "follow_up": "cleaning", "correlation_id": "nope"},
|
||||
headers={
|
||||
"Idempotency-Key": event_id,
|
||||
"X-Service-Token": get_settings().n8n_callback_token,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_blocked_checkout_booking_can_be_cancelled(ops_client):
|
||||
window = {"starts_at": "2051-03-01T10:00:00Z", "ends_at": "2051-03-02T12:00:00Z"}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
vehicle_option = next(item for item in available if item["operational_status"] == "available")
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0002",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
confirmed = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||
json={"confirmation": "Licence and rental conditions checked"},
|
||||
)
|
||||
assert confirmed.status_code == 200
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": vehicle["odometer_km"],
|
||||
"fuel_level_percent": 80,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": True,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
assert checkout.status_code == 200
|
||||
assert checkout.json()["booking_status"] == "blocked"
|
||||
cancelled = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/cancel",
|
||||
json={"reason": "Vehicle damaged at departure inspection"},
|
||||
)
|
||||
assert cancelled.status_code == 200
|
||||
assert cancelled.json()["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-ANON-SCAN",
|
||||
first_name="Anoniem",
|
||||
last_name="Klant",
|
||||
email=None,
|
||||
phone=None,
|
||||
postal_code=None,
|
||||
city=None,
|
||||
date_of_birth=None,
|
||||
anonymized_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
customer_id = customer.id
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
run_scan(db)
|
||||
db.commit()
|
||||
flagged = db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.entity_type == "customer",
|
||||
DataQualityIssue.entity_id == customer_id,
|
||||
)
|
||||
)
|
||||
assert flagged is None
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == customer_id))
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition():
|
||||
barrier = Barrier(2)
|
||||
|
||||
def scan() -> None:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
run_scan(db)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
list(executor.map(lambda _index: scan(), range(2)))
|
||||
|
||||
with SessionLocal() as db:
|
||||
duplicates = db.execute(
|
||||
select(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
func.count(DataQualityIssue.id),
|
||||
)
|
||||
.where(DataQualityIssue.status == "open")
|
||||
.group_by(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
)
|
||||
.having(func.count(DataQualityIssue.id) > 1)
|
||||
).all()
|
||||
assert duplicates == []
|
||||
|
||||
|
||||
def test_concurrent_issue_resolution_records_exactly_one_decision():
|
||||
issue_id = uuid.uuid4()
|
||||
issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}"
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
DataQualityIssue(
|
||||
id=issue_id,
|
||||
public_ref=issue_ref,
|
||||
rule_type="missing_required_field",
|
||||
entity_type="customer",
|
||||
entity_id=uuid.uuid4(),
|
||||
severity="low",
|
||||
status="open",
|
||||
evidence_json={},
|
||||
proposed_action_json={},
|
||||
detected_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
actor = CurrentUser(
|
||||
public_ref="USR-RACE", display_name="Race Manager", role="operations_manager"
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def resolve() -> str:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
try:
|
||||
return defer_issue(db, issue_ref, actor).status
|
||||
except AppError as exc:
|
||||
return exc.code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(lambda _index: resolve(), range(2)))
|
||||
assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"]
|
||||
with SessionLocal() as db:
|
||||
decisions = db.scalar(
|
||||
select(func.count(AuditEvent.id)).where(
|
||||
AuditEvent.entity_id == issue_id,
|
||||
AuditEvent.action == "data_quality_issue_deferred",
|
||||
)
|
||||
)
|
||||
assert decisions == 1
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id))
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id))
|
||||
db.commit()
|
||||
@@ -0,0 +1,41 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health() -> None:
|
||||
response = TestClient(app).get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "service": "mobilityops-api"}
|
||||
|
||||
|
||||
def test_liveness_is_process_only() -> None:
|
||||
response = TestClient(app).get("/health/live")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_readiness_checks_the_canonical_database() -> None:
|
||||
response = TestClient(app).get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ready",
|
||||
"service": "mobilityops-api",
|
||||
"database": "up",
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_degrades_when_database_is_unavailable(monkeypatch) -> None:
|
||||
import app.main as main_module
|
||||
|
||||
class BrokenSession:
|
||||
def __enter__(self):
|
||||
raise ConnectionError("database unavailable")
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main_module, "SessionLocal", BrokenSession)
|
||||
response = TestClient(app).get("/health/ready")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["database"] == "down"
|
||||
@@ -0,0 +1,292 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services import integration_status
|
||||
|
||||
|
||||
def _reseed() -> None:
|
||||
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
|
||||
|
||||
The suite shares one session-scoped database and earlier files legitimately mutate
|
||||
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
|
||||
rather than depend on file ordering.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_integration_status_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_integration_status_requires_authentication(client):
|
||||
response = client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
||||
_reseed()
|
||||
response = ops_client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
n8n = body["n8n"]
|
||||
assert n8n["dispatch_enabled"] is True
|
||||
assert n8n["succeeded"] >= 1
|
||||
# The seeded failure stays visible and counted...
|
||||
assert n8n["failed"] >= 1
|
||||
assert n8n["demo_scenario_failed"] == 1
|
||||
# ...but it is a prepared prop, so it is not an unexpected failure and must not
|
||||
# move the integration off "operational". A staged failure that degrades the
|
||||
# health badge tells a viewer something untrue about the automation.
|
||||
assert n8n["unexpected_failed"] == 0
|
||||
assert n8n["state"] == "operational"
|
||||
assert n8n["latest_success_at"] is not None
|
||||
# "latest failure" is a health signal, so the staged one never sets it; it is
|
||||
# reported separately instead.
|
||||
assert n8n["latest_failure_at"] is None
|
||||
assert n8n["latest_demo_scenario_at"] is not None
|
||||
|
||||
mcp_hub = body["mcp_hub"]
|
||||
assert mcp_hub["registration_enabled"] is False
|
||||
assert mcp_hub["state"] == "not_configured"
|
||||
|
||||
|
||||
def test_a_real_failure_still_degrades_the_integration(ops_client):
|
||||
"""The demo carve-out must be narrow: a failure that is not the prepared scenario
|
||||
still degrades n8n, otherwise this change would hide real breakage."""
|
||||
_reseed()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
real_failure = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
|
||||
)
|
||||
assert real_failure is not None
|
||||
restore = (real_failure.delivery_status, real_failure.last_error_code)
|
||||
real_failure.delivery_status = "failed"
|
||||
real_failure.last_error_code = "connectionError"
|
||||
db.commit()
|
||||
|
||||
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
assert n8n["unexpected_failed"] == 1
|
||||
assert n8n["state"] == "degraded"
|
||||
assert n8n["latest_failure_at"] is not None
|
||||
finally:
|
||||
real_failure.delivery_status, real_failure.last_error_code = restore
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_prepared_demo_failure_is_reset_back_by_a_demo_reset(ops_client):
|
||||
"""A demo reset must recreate the intended 19 succeeded + 1 prepared failure, so the
|
||||
scenario can be shown again after it has been retried away."""
|
||||
_reseed()
|
||||
|
||||
n8n = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
assert n8n["succeeded"] == 19
|
||||
assert n8n["failed"] == 1
|
||||
assert n8n["demo_scenario_failed"] == 1
|
||||
assert n8n["unexpected_failed"] == 0
|
||||
assert n8n["state"] == "operational"
|
||||
|
||||
|
||||
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
|
||||
_reseed()
|
||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||
for run in failed:
|
||||
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
|
||||
assert retried.status_code == 200
|
||||
|
||||
response = ops_client.get("/api/v1/integrations/status")
|
||||
body = response.json()["n8n"]
|
||||
assert body["failed"] == 0
|
||||
assert body["state"] == "operational"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delivery_status", ["pending", "delivering"])
|
||||
def test_queued_work_without_any_success_is_not_reported_operational(delivery_status):
|
||||
_reseed()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for event in db.scalars(select(OutboxEvent)).all():
|
||||
event.delivery_status = delivery_status
|
||||
event.last_error = None
|
||||
event.last_error_code = None
|
||||
db.execute(
|
||||
delete(AuditEvent).where(
|
||||
AuditEvent.action.in_(
|
||||
{
|
||||
"data_quality_scan_run",
|
||||
"n8n_procedures_synced",
|
||||
"n8n_workflow_failure_registered",
|
||||
"n8n_workflow_heartbeat",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
status = integration_status.derive_n8n_status(db)
|
||||
|
||||
assert status.succeeded == 0
|
||||
assert getattr(status, delivery_status) > 0
|
||||
assert status.state == "no_evidence"
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_historical_success_is_not_green_when_webhook_is_unconfigured(monkeypatch):
|
||||
_reseed()
|
||||
with SessionLocal() as db:
|
||||
monkeypatch.setattr(integration_status.settings, "n8n_dispatch_enabled", True)
|
||||
monkeypatch.setattr(integration_status.settings, "n8n_webhook_url", "")
|
||||
status = integration_status.derive_n8n_status(db)
|
||||
assert status.configured is False
|
||||
assert status.succeeded > 0
|
||||
assert status.state == "unavailable"
|
||||
|
||||
|
||||
def test_null_coded_real_failure_sets_latest_failure_timestamp():
|
||||
_reseed()
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
|
||||
)
|
||||
event.delivery_status = "failed"
|
||||
event.last_error_code = None
|
||||
db.flush()
|
||||
status = integration_status.derive_n8n_status(db)
|
||||
assert status.unexpected_failed == 1
|
||||
assert status.latest_failure_at is not None
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_unreachable_hub_invalidates_historical_operational_claim(monkeypatch):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="service",
|
||||
actor_id=None,
|
||||
actor_label="itworx-mcp-hub",
|
||||
action="mcp_tool_request",
|
||||
entity_type="integration",
|
||||
entity_id=None,
|
||||
correlation_id=uuid.uuid4(),
|
||||
before_json=None,
|
||||
after_json=None,
|
||||
metadata_json={"tool": "operations_summary"},
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
monkeypatch.setattr(integration_status.settings, "mcp_hub_registration_enabled", True)
|
||||
monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: False)
|
||||
|
||||
unreachable = integration_status.derive_mcp_hub_status(db)
|
||||
|
||||
assert unreachable.total_calls > 0
|
||||
assert unreachable.hub_reachable is False
|
||||
assert unreachable.state == "no_evidence"
|
||||
|
||||
monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: True)
|
||||
reachable = integration_status.derive_mcp_hub_status(db)
|
||||
assert reachable.state == "operational"
|
||||
finally:
|
||||
db.rollback()
|
||||
db.close()
|
||||
|
||||
|
||||
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
|
||||
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
assert body["expected_workflow_count"] == 4
|
||||
names = {w["name"] for w in body["workflows"]}
|
||||
assert names == {
|
||||
"Fleet Ops — Vehicle Return Orchestration",
|
||||
"Fleet Ops — Scheduled Data Quality Scan",
|
||||
"Fleet Ops — RAGcore Procedure Sync",
|
||||
"Fleet Ops — Workflow Error Handler",
|
||||
}
|
||||
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
|
||||
# All 4 canonical workflows are built (all 6 nodes saved live). This fresh test run
|
||||
# has reported no real sync result yet, so -- like the other three workflows before
|
||||
# their own first real signal -- there is no run evidence yet either.
|
||||
assert ragcore_sync["built"] is True
|
||||
assert ragcore_sync["last_seen_at"] is None
|
||||
assert ragcore_sync["state"] == "no_evidence"
|
||||
|
||||
|
||||
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
scan_workflow = next(
|
||||
w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||
)
|
||||
assert scan_workflow["last_seen_at"] is None
|
||||
|
||||
scan = client.post(
|
||||
"/api/v1/integrations/n8n/scheduled-scan",
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert scan.status_code == 200
|
||||
|
||||
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
scan_workflow = next(
|
||||
w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||
)
|
||||
assert scan_workflow["last_seen_at"] is not None
|
||||
assert after["known_workflow_count"] > before["known_workflow_count"]
|
||||
|
||||
|
||||
def test_integration_status_reflects_error_handler_registrations(client, ops_client):
|
||||
import uuid
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
|
||||
execution_id = str(uuid.uuid4())
|
||||
report = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json={
|
||||
"workflow_id": "mobilityops-return-processing",
|
||||
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||
"execution_id": execution_id,
|
||||
"failed_at": "2026-08-04T10:15:00Z",
|
||||
"error_category": "httpError",
|
||||
"error_summary": "Simulated failure for status test",
|
||||
"trigger_context": "webhook",
|
||||
"attempt": 1,
|
||||
},
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert report.status_code == 200
|
||||
|
||||
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
assert (
|
||||
after["error_handler"]["total_failures_registered"]
|
||||
== before["error_handler"]["total_failures_registered"] + 1
|
||||
)
|
||||
assert after["error_handler"]["latest_failure_workflow"] == (
|
||||
"Fleet Ops — Vehicle Return Orchestration"
|
||||
)
|
||||
handler_workflow = next(
|
||||
w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler")
|
||||
)
|
||||
assert handler_workflow["last_seen_at"] is not None
|
||||
@@ -0,0 +1,454 @@
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
def _callback_headers(event_id: str, token: str | None = None):
|
||||
settings = get_settings()
|
||||
return {
|
||||
"Idempotency-Key": event_id,
|
||||
"X-Service-Token": token if token is not None else settings.n8n_callback_token,
|
||||
}
|
||||
|
||||
|
||||
def test_callback_rejects_wrong_service_token(client):
|
||||
event_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id, token="wrong-token"),
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_callback_unknown_event_returns_404(client):
|
||||
event_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_callback_is_idempotent_by_event_id(client, ops_client):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.booking import Booking
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
booking = db.scalar(select(Booking).limit(1))
|
||||
correlation_id = uuid.uuid4()
|
||||
event = OutboxEvent(
|
||||
event_id=uuid.uuid4(),
|
||||
event_type="vehicle.returned.v1",
|
||||
aggregate_type="booking",
|
||||
aggregate_id=booking.id,
|
||||
payload_json={
|
||||
"correlation_id": str(correlation_id),
|
||||
"aggregate": {
|
||||
"type": "booking",
|
||||
"id": str(booking.id),
|
||||
"public_ref": booking.public_ref,
|
||||
},
|
||||
"data": {},
|
||||
"aggregate_ref": booking.public_ref,
|
||||
},
|
||||
occurred_at=db.execute(select(Booking.starts_at).limit(1)).scalar(),
|
||||
delivery_status="delivering",
|
||||
attempts=1,
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
event_id = str(event.event_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
first = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(correlation_id),
|
||||
"follow_up": "cleaning",
|
||||
"summary": "test",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
second = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(correlation_id),
|
||||
"follow_up": "cleaning",
|
||||
"summary": "test",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_return_followup_recorded"}
|
||||
).json()
|
||||
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["correlation_id"] == str(correlation_id)
|
||||
|
||||
|
||||
def test_callback_rejects_crossed_event_or_correlation(client):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(select(OutboxEvent).limit(1))
|
||||
event_id = str(event.event_id)
|
||||
correlation_id = event.payload_json["correlation_id"]
|
||||
|
||||
crossed_event = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": str(uuid.uuid4()),
|
||||
"correlation_id": correlation_id,
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert crossed_event.status_code == 409
|
||||
assert crossed_event.json()["error"]["code"] == "CALLBACK_EVENT_MISMATCH"
|
||||
|
||||
crossed_correlation = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={
|
||||
"event_id": event_id,
|
||||
"correlation_id": str(uuid.uuid4()),
|
||||
"follow_up": "cleaning",
|
||||
},
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
assert crossed_correlation.status_code == 409
|
||||
assert crossed_correlation.json()["error"]["code"] == "CALLBACK_CORRELATION_MISMATCH"
|
||||
|
||||
|
||||
def test_callback_concurrent_retries_record_one_audit(client, ops_client):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
with SessionLocal() as db:
|
||||
event = db.scalar(select(OutboxEvent).limit(1))
|
||||
event_id = str(event.event_id)
|
||||
correlation_id = event.payload_json["correlation_id"]
|
||||
body = {
|
||||
"event_id": event_id,
|
||||
"correlation_id": correlation_id,
|
||||
"follow_up": "cleaning",
|
||||
}
|
||||
|
||||
def post_callback(_index: int):
|
||||
return client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json=body,
|
||||
headers=_callback_headers(event_id),
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
responses = list(pool.map(post_callback, range(2)))
|
||||
assert [response.status_code for response in responses] == [200, 200]
|
||||
matching = [
|
||||
event
|
||||
for event in ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_return_followup_recorded"}
|
||||
).json()
|
||||
if event["metadata"]["event_id"] == event_id
|
||||
]
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
def test_scheduled_scan_rejects_wrong_service_token(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/scheduled-scan",
|
||||
headers={"X-Service-Token": "wrong-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_scheduled_scan_requires_service_token_header(client):
|
||||
response = client.post("/api/v1/integrations/n8n/scheduled-scan")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_scheduled_scan_runs_and_returns_counts_by_rule(client, ops_client):
|
||||
settings = get_settings()
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/scheduled-scan",
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"created": {}} # already-seeded conditions, nothing new
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
||||
).json()
|
||||
service_triggered = [e for e in audit_events if e["actor_type"] == "service"]
|
||||
assert len(service_triggered) >= 1
|
||||
assert service_triggered[0]["actor_label"] == "n8n scheduled scan"
|
||||
|
||||
|
||||
def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
first = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
||||
second = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["created"] == {}
|
||||
|
||||
|
||||
def _workflow_error_body(execution_id: str, **overrides):
|
||||
body = {
|
||||
"workflow_id": "mobilityops-return-processing",
|
||||
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||
"execution_id": execution_id,
|
||||
"failed_at": "2026-08-04T10:15:00Z",
|
||||
"error_category": "httpError",
|
||||
"error_summary": "Callback request failed with status 500",
|
||||
"trigger_context": "webhook",
|
||||
"correlation_id": None,
|
||||
"attempt": 1,
|
||||
"retry_action": "n8n will retry automatically",
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def test_workflow_error_rejects_wrong_service_token(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(str(uuid.uuid4())),
|
||||
headers={"X-Service-Token": "wrong-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_workflow_error_rejects_unknown_category(client):
|
||||
settings = get_settings()
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
execution_id = str(uuid.uuid4())
|
||||
body = _workflow_error_body(execution_id)
|
||||
|
||||
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "registered"
|
||||
assert second.status_code == 200
|
||||
assert second.json()["status"] == "already_registered"
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||
).json()
|
||||
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["after"]["error_category"] == "httpError"
|
||||
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
||||
|
||||
|
||||
def test_workflow_error_preserves_correlation_and_rejects_malformed(client, ops_client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
execution_id = str(uuid.uuid4())
|
||||
correlation_id = str(uuid.uuid4())
|
||||
accepted = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(execution_id, correlation_id=correlation_id),
|
||||
headers=headers,
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
matching = [
|
||||
event
|
||||
for event in ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||
).json()
|
||||
if event["metadata"]["execution_id"] == execution_id
|
||||
]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["correlation_id"] == correlation_id
|
||||
|
||||
malformed_execution = str(uuid.uuid4())
|
||||
malformed = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(malformed_execution, correlation_id="not-a-uuid"),
|
||||
headers=headers,
|
||||
)
|
||||
assert malformed.status_code == 422
|
||||
assert all(
|
||||
event["metadata"]["execution_id"] != malformed_execution
|
||||
for event in ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||
).json()
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_error_bounds_summary_length(client):
|
||||
settings = get_settings()
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/workflow-error",
|
||||
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_procedures_rejects_wrong_service_token(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_procedures_lists_every_language_with_stable_ids(client):
|
||||
settings = get_settings()
|
||||
response = client.get(
|
||||
"/api/v1/integrations/n8n/procedures",
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
documents = response.json()["documents"]
|
||||
assert len(documents) > 0
|
||||
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
|
||||
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
|
||||
assert len(checkout_docs) == 3 # one per language
|
||||
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
|
||||
# Same document_id, different language, must not collide on id.
|
||||
assert len({d["id"] for d in checkout_docs}) == 3
|
||||
|
||||
second_response = client.get(
|
||||
"/api/v1/integrations/n8n/procedures",
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
second_ids = {d["id"] for d in second_response.json()["documents"]}
|
||||
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
|
||||
|
||||
|
||||
def test_procedures_sync_result_rejects_wrong_service_token(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
|
||||
headers={"X-Service-Token": "wrong-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
execution_id = str(uuid.uuid4())
|
||||
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
|
||||
|
||||
first = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||
)
|
||||
second = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "registered"
|
||||
assert second.status_code == 200
|
||||
assert second.json()["status"] == "already_registered"
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
|
||||
).json()
|
||||
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["after"] == {"synced": 33, "failed": 1}
|
||||
|
||||
# The workflow's own callback is its evidence -- same pattern the scheduled scan and
|
||||
# error handler already use -- so this real report must now show up as run evidence
|
||||
# in the integration status, not stay hardcoded to "no evidence yet".
|
||||
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
|
||||
assert ragcore_sync["last_seen_at"] is not None
|
||||
|
||||
|
||||
def test_failed_or_partial_procedure_sync_is_never_reported_healthy(client, ops_client):
|
||||
settings = get_settings()
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
for synced, failed in ((0, 33), (32, 1)):
|
||||
execution_id = str(uuid.uuid4())
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||
json={"execution_id": execution_id, "synced": synced, "failed": failed},
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
workflow = next(w for w in status["workflows"] if "RAGcore" in w["name"])
|
||||
assert workflow["state"] == "failed"
|
||||
assert workflow["last_status"] == "failed"
|
||||
assert workflow["last_execution_id"] == execution_id
|
||||
assert status["state"] == "degraded"
|
||||
|
||||
|
||||
def test_workflow_heartbeat_is_idempotent_and_drives_live_status(client, ops_client):
|
||||
settings = get_settings()
|
||||
execution_id = str(uuid.uuid4())
|
||||
body = {
|
||||
"workflow_id": "mobilityops-scheduled-quality-scan",
|
||||
"workflow_name": "Fleet Ops — Scheduled Data Quality Scan",
|
||||
"execution_id": execution_id,
|
||||
"status": "succeeded",
|
||||
}
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
first = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers)
|
||||
second = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "registered"
|
||||
assert second.json()["status"] == "already_registered"
|
||||
|
||||
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
workflow = next(w for w in status["workflows"] if w["name"] == body["workflow_name"])
|
||||
assert workflow["state"] == "healthy"
|
||||
assert workflow["last_status"] == "succeeded"
|
||||
assert workflow["last_execution_id"] == execution_id
|
||||
|
||||
|
||||
def test_workflow_heartbeat_rejects_unknown_workflow(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/heartbeat",
|
||||
json={
|
||||
"workflow_id": "unknown",
|
||||
"workflow_name": "Unknown workflow",
|
||||
"execution_id": "test-unknown-001",
|
||||
"status": "succeeded",
|
||||
},
|
||||
headers={"X-Service-Token": get_settings().n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
from app.core.config import get_settings
|
||||
from app.main import app
|
||||
|
||||
MCP_ROUTE_ALLOWLIST = {
|
||||
("GET", "/api/v1/integrations/mcp/operations-summary"),
|
||||
("GET", "/api/v1/integrations/mcp/attention-vehicles"),
|
||||
("GET", "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"),
|
||||
("POST", "/api/v1/integrations/mcp/search-knowledge"),
|
||||
}
|
||||
|
||||
|
||||
def _headers(token: str | None = None, client_id: str = "test-mcp-client"):
|
||||
settings = get_settings()
|
||||
return {
|
||||
"X-Service-Token": token if token is not None else settings.mcp_hub_service_token,
|
||||
"X-Client-Id": f"itworx-mcp-hub:mobilityops:{client_id}",
|
||||
}
|
||||
|
||||
|
||||
def test_operations_summary_requires_service_token(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary", headers=_headers(token="wrong")
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_operations_summary_rejects_spoofed_client_identity(client):
|
||||
settings = get_settings()
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary",
|
||||
headers={"X-Service-Token": settings.mcp_hub_service_token, "X-Client-Id": "spoofed"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_operations_summary_rejects_cross_tenant_request(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary",
|
||||
headers={**_headers(), "X-Tenant-Id": "another-tenant"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_operations_summary_returns_metrics(client):
|
||||
response = client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "metrics" in body
|
||||
assert body["metrics"]["available"] >= 0
|
||||
assert response.headers["x-correlation-id"]
|
||||
assert response.headers["x-tenant-id"] == get_settings().ragcore_tenant
|
||||
|
||||
|
||||
def test_attention_vehicles_filters_by_severity(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/attention-vehicles",
|
||||
params={"minimum_severity": "high", "limit": 50},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
assert all(r["severity"] == "high" for r in rows)
|
||||
|
||||
|
||||
def test_attention_vehicles_respects_limit(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/attention-vehicles",
|
||||
params={"minimum_severity": "low", "limit": 2},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) <= 2
|
||||
|
||||
|
||||
def test_vehicle_details_known_ref(client):
|
||||
response = client.get("/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["public_ref"] == "MO-016"
|
||||
assert "registration_number" not in body # narrow read-only view, not the full record
|
||||
|
||||
|
||||
def test_vehicle_details_unknown_ref_is_404(client):
|
||||
response = client.get("/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers())
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_search_knowledge_grounded_and_respects_max_sources(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/mcp/search-knowledge",
|
||||
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["evidence_state"] == "grounded"
|
||||
assert len(body["sources"]) == 1
|
||||
assert int(response.headers["x-sources-available"]) >= 1
|
||||
assert response.headers["x-sources-returned"] == "1"
|
||||
|
||||
|
||||
def test_mcp_tool_requests_are_audited(client, ops_client):
|
||||
client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers(client_id="probe-1"))
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert len(events) >= 1
|
||||
assert events[0]["actor_type"] == "service"
|
||||
assert events[0]["actor_label"] == "itworx-mcp-hub"
|
||||
assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1")
|
||||
|
||||
|
||||
def test_search_knowledge_respects_requested_locale(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/mcp/search-knowledge",
|
||||
json={
|
||||
"question": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||
"max_sources": 1,
|
||||
"locale": "nl-BE",
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["evidence_state"] == "grounded"
|
||||
|
||||
|
||||
def test_search_knowledge_preserves_inbound_correlation_id(client, ops_client):
|
||||
inbound = "11111111-1111-1111-1111-111111111111"
|
||||
response = client.post(
|
||||
"/api/v1/integrations/mcp/search-knowledge",
|
||||
json={"question": "What must I do when a vehicle returns with damage?", "max_sources": 1},
|
||||
headers={**_headers(client_id="correlation-probe"), "X-Correlation-Id": inbound},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["correlation_id"] == inbound
|
||||
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
matching = [e for e in events if e["correlation_id"] == inbound]
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary",
|
||||
headers=_headers(client_id="no-correlation-probe"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
matching = [
|
||||
e
|
||||
for e in events
|
||||
if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe")
|
||||
]
|
||||
assert len(matching) >= 1
|
||||
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
|
||||
|
||||
|
||||
def test_no_write_endpoints_exist_under_mcp_namespace(client):
|
||||
for method, path in [
|
||||
("post", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||
("put", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||
("delete", "/api/v1/integrations/mcp/vehicles/MO-016"),
|
||||
]:
|
||||
response = getattr(client, method)(path, headers=_headers())
|
||||
assert response.status_code in (404, 405)
|
||||
|
||||
|
||||
def test_mcp_namespace_matches_exact_route_allowlist_and_rejects_lookalikes(client):
|
||||
implemented = {
|
||||
(method.upper(), path)
|
||||
for path, operations in app.openapi()["paths"].items()
|
||||
if path.startswith("/api/v1/integrations/mcp/")
|
||||
for method in operations
|
||||
if method in {"get", "post", "put", "patch", "delete"}
|
||||
}
|
||||
assert implemented == MCP_ROUTE_ALLOWLIST
|
||||
|
||||
lookalikes = [
|
||||
("get", "/api/v1/integrations/mcp-extra/operations-summary"),
|
||||
("get", "/api/v1/integrations/mcp/operations-summary-extra"),
|
||||
("get", "/api/v1/integrations/mcp/prefix/attention-vehicles"),
|
||||
("get", "/api/v1/integrations/mcp/attention-vehicles/suffix"),
|
||||
("post", "/api/v1/integrations/mcp/search-knowledge-extra"),
|
||||
("post", "/api/v1/integrations/mcp/prefix/search-knowledge"),
|
||||
]
|
||||
for method, path in lookalikes:
|
||||
response = client.request(method, path, headers=_headers(), json={})
|
||||
assert response.status_code == 404, path
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Guard against model/migration drift.
|
||||
|
||||
The functional suite builds its schema with ``Base.metadata.create_all`` for speed, so a
|
||||
column or index added to a model but never written into an Alembic migration would only
|
||||
surface on the first real deployment. This test runs the migration chain from an empty
|
||||
database and asserts that Alembic's autogenerate sees nothing left to do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic.autogenerate import compare_metadata
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from alembic import command
|
||||
from app.core.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRATCH_DB = "mobilityops_migration_check"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def migrated_database_url() -> str:
|
||||
settings = get_settings()
|
||||
base_url = make_url(settings.database_url)
|
||||
admin_engine = create_engine(
|
||||
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
|
||||
)
|
||||
with admin_engine.connect() as conn:
|
||||
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}"'))
|
||||
conn.execute(text(f'CREATE DATABASE "{SCRATCH_DB}"'))
|
||||
scratch_url = base_url.set(database=SCRATCH_DB).render_as_string(hide_password=False)
|
||||
try:
|
||||
yield scratch_url
|
||||
finally:
|
||||
admin_engine.dispose()
|
||||
admin_engine = create_engine(
|
||||
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
|
||||
)
|
||||
with admin_engine.connect() as conn:
|
||||
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)'))
|
||||
admin_engine.dispose()
|
||||
|
||||
|
||||
def _alembic_config(database_url: str) -> Config:
|
||||
config = Config(str(BACKEND_DIR / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
def test_migrations_upgrade_from_empty_and_match_models(migrated_database_url: str) -> None:
|
||||
config = _alembic_config(migrated_database_url)
|
||||
# env.py reads DATABASE_URL from settings; override it for the scratch database.
|
||||
import os
|
||||
|
||||
previous = os.environ.get("DATABASE_URL")
|
||||
os.environ["DATABASE_URL"] = migrated_database_url
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
command.upgrade(config, "head")
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = previous
|
||||
get_settings.cache_clear()
|
||||
|
||||
engine = create_engine(migrated_database_url)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
context = MigrationContext.configure(
|
||||
conn, opts={"compare_type": True, "compare_server_default": False}
|
||||
)
|
||||
diff = compare_metadata(context, Base.metadata)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
assert diff == [], (
|
||||
"Models and Alembic migrations have drifted; write a migration for:\n"
|
||||
+ "\n".join(repr(entry) for entry in diff)
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.core.observability import JsonFormatter
|
||||
|
||||
|
||||
def test_request_correlation_id_is_echoed_and_used_in_errors(client):
|
||||
correlation_id = str(uuid.uuid4())
|
||||
response = client.get(
|
||||
"/api/v1/dashboard",
|
||||
headers={"X-Correlation-Id": correlation_id},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.headers["X-Correlation-Id"] == correlation_id
|
||||
assert response.json()["error"]["correlation_id"] == correlation_id
|
||||
|
||||
|
||||
def test_invalid_request_correlation_id_is_replaced(client):
|
||||
response = client.get("/health/live", headers={"X-Correlation-Id": "not-a-uuid"})
|
||||
assert response.status_code == 200
|
||||
assert uuid.UUID(response.headers["X-Correlation-Id"])
|
||||
|
||||
|
||||
def test_metrics_expose_http_database_and_outbox_state(client):
|
||||
client.get("/health/ready")
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "mobilityops_http_requests_total" in response.text
|
||||
assert "mobilityops_database_ready 1.0" in response.text
|
||||
assert 'mobilityops_outbox_events{scenario="synthetic",status="failed"}' in response.text
|
||||
assert "mobilityops_knowledge_provider_requests_total" in response.text
|
||||
assert "mobilityops_knowledge_retrieval_score" in response.text
|
||||
|
||||
|
||||
def test_metrics_token_is_enforced_when_configured(client, monkeypatch):
|
||||
import app.api.routers.observability as router
|
||||
|
||||
monkeypatch.setattr(router.settings, "metrics_bearer_token", "metrics-secret")
|
||||
assert client.get("/metrics").status_code == 401
|
||||
assert (
|
||||
client.get("/metrics", headers={"Authorization": "Bearer metrics-secret"}).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
def test_json_formatter_emits_machine_readable_fields():
|
||||
record = logging.LogRecord("mobilityops.test", logging.INFO, __file__, 1, "ready", (), None)
|
||||
record.status_code = 200
|
||||
payload = json.loads(JsonFormatter().format(record))
|
||||
assert payload["message"] == "ready"
|
||||
assert payload["status_code"] == 200
|
||||
assert payload["timestamp"].endswith("+00:00")
|
||||
@@ -0,0 +1,36 @@
|
||||
from app.api.routers import auth as auth_router
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def test_password_login_is_available_only_outside_demo_mode(ops_client, monkeypatch):
|
||||
user = User(
|
||||
public_ref="USR-REAL",
|
||||
email="manager@example.test",
|
||||
password_hash=hash_password("correct-horse-battery-staple"),
|
||||
display_name="Real Manager",
|
||||
role="operations_manager",
|
||||
active=True,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
db.add(user)
|
||||
db.commit()
|
||||
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
|
||||
|
||||
response = ops_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "manager@example.test", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["public_ref"] == "USR-REAL"
|
||||
assert ops_client.get("/api/v1/auth/session").json()["display_name"] == "Real Manager"
|
||||
|
||||
|
||||
def test_password_login_rejects_invalid_credentials(ops_client, monkeypatch):
|
||||
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
|
||||
response = ops_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "unknown@example.test", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,114 @@
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
|
||||
|
||||
def test_privacy_retention_uses_persisted_counts(ops_client):
|
||||
response = ops_client.get("/api/v1/privacy/retention")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["customers_total"] >= 180
|
||||
assert body["minimum_booking_retention_days"] == 30
|
||||
assert body["audit_retention_days"] == 2555
|
||||
|
||||
|
||||
def test_privacy_retention_requires_manager(employee_client):
|
||||
assert employee_client.get("/api/v1/privacy/retention").status_code == 403
|
||||
|
||||
|
||||
def test_customer_export_is_downloadable_and_audited_without_mutation(ops_client):
|
||||
response = ops_client.get("/api/v1/privacy/customers/CUS-0001/export")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="CUS-0001-privacy.json"'
|
||||
assert response.json()["customer"]["public_ref"] == "CUS-0001"
|
||||
assert isinstance(response.json()["bookings"], list)
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "privacy_customer_exported"}).json()
|
||||
assert any(event["metadata"]["customer_ref"] == "CUS-0001" for event in events)
|
||||
|
||||
|
||||
def test_active_customer_cannot_be_anonymized(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-0042/anonymize",
|
||||
json={"confirmation": "CUS-0042", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-PRIVACY",
|
||||
first_name="Private",
|
||||
last_name="Person",
|
||||
email="private.person@example.test",
|
||||
phone="+32000000000",
|
||||
postal_code="1000",
|
||||
city="Brussels",
|
||||
date_of_birth=None,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
customer_id = customer.id
|
||||
try:
|
||||
mismatch = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
|
||||
json={"confirmation": "CUS-WRONG", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert mismatch.status_code == 422
|
||||
response = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
|
||||
json={"confirmation": "CUS-PRIVACY", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "anonymized"
|
||||
search = ops_client.get("/api/v1/customers", params={"query": "CUS-PRIVACY"})
|
||||
assert search.status_code == 200
|
||||
assert search.json() == []
|
||||
window = {
|
||||
"starts_at": "2090-01-01T10:00:00Z",
|
||||
"ends_at": "2090-01-02T10:00:00Z",
|
||||
}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
assert available
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-PRIVACY",
|
||||
"vehicle_ref": available[0]["public_ref"],
|
||||
**window,
|
||||
},
|
||||
)
|
||||
assert booking.status_code == 422
|
||||
with SessionLocal() as db:
|
||||
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
|
||||
assert customer is not None
|
||||
assert customer.email is None
|
||||
assert customer.phone is None
|
||||
assert customer.first_name == "Anoniem"
|
||||
event = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "privacy_customer_anonymized")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
assert event is not None
|
||||
serialized = f"{event.before_json}{event.after_json}{event.metadata_json}"
|
||||
assert "private.person@example.test" not in serialized
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_audit_csv_export_is_bounded_and_audited(ops_client):
|
||||
response = ops_client.get("/api/v1/audit/export.csv")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/csv")
|
||||
assert response.text.startswith("id,occurred_at,actor_type")
|
||||
invalid = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
params={"occurred_from": "2025-01-01T00:00:00Z", "occurred_to": "2026-01-01T00:00:00Z"},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
@@ -0,0 +1,317 @@
|
||||
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)
|
||||
@@ -0,0 +1,75 @@
|
||||
def test_search_requires_authentication(client):
|
||||
response = client.get("/api/v1/search", params={"q": "MO-001"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_search_finds_a_vehicle_by_reference(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "MO-001"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
match = next((r for r in body["results"] if r["type"] == "vehicle"), None)
|
||||
assert match is not None
|
||||
assert match["label"] == "MO-001"
|
||||
assert match["link"] == "/vehicles/MO-001"
|
||||
# The backend must never send localizable prose -- only a stable code plus raw
|
||||
# data params, so the frontend can render it in the operator's selected language.
|
||||
assert match["detail_code"] == "vehicleSummary"
|
||||
assert set(match["detail_params"]) == {"make", "model", "location"}
|
||||
|
||||
|
||||
def test_search_finds_a_booking_by_reference(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "BK-DEMO-RETURN"})
|
||||
assert response.status_code == 200
|
||||
match = next((r for r in response.json()["results"] if r["type"] == "booking"), None)
|
||||
assert match is not None
|
||||
assert match["link"] == "/bookings/BK-DEMO-RETURN"
|
||||
|
||||
|
||||
def test_search_finds_a_data_quality_issue_for_operations_manager(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||
assert response.status_code == 200
|
||||
match = next((r for r in response.json()["results"] if r["type"] == "data_quality_issue"), None)
|
||||
assert match is not None
|
||||
assert match["link"] == "/data-quality/DQ-DEMO-OVERLAP"
|
||||
|
||||
|
||||
def test_search_never_returns_data_quality_issues_for_rental_employee(employee_client):
|
||||
response = employee_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||
assert response.status_code == 200
|
||||
assert all(r["type"] != "data_quality_issue" for r in response.json()["results"])
|
||||
|
||||
|
||||
def test_search_section_result_visible_to_operations_manager(ops_client):
|
||||
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||
match = next(
|
||||
(r for r in result["results"] if r["type"] == "section" and r["link"] == "/audit"), None
|
||||
)
|
||||
assert match is not None
|
||||
# Section results must ship a stable id, not English prose -- the frontend looks up
|
||||
# navigation:items.<id> and search:sections.<id>.detail in the selected locale.
|
||||
assert match["label"] == "audit"
|
||||
assert match["detail_code"] == "audit"
|
||||
|
||||
|
||||
def test_search_section_matches_dutch_and_french_terms(ops_client):
|
||||
nl_result = ops_client.get("/api/v1/search", params={"q": "wagenpark"}).json()
|
||||
assert any(r["type"] == "section" and r["link"] == "/vehicles" for r in nl_result["results"])
|
||||
fr_result = ops_client.get("/api/v1/search", params={"q": "réservation"}).json()
|
||||
assert any(r["type"] == "section" and r["link"] == "/bookings" for r in fr_result["results"])
|
||||
|
||||
|
||||
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
||||
result = employee_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||
assert all(r["link"] != "/audit" for r in result["results"])
|
||||
|
||||
|
||||
def test_search_never_returns_customer_results(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "CUS-0012"})
|
||||
assert response.status_code == 200
|
||||
assert all(r["type"] != "customer" for r in response.json()["results"])
|
||||
|
||||
|
||||
def test_search_no_match_returns_empty_results(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "zzz-no-such-thing-zzz"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
@@ -0,0 +1,296 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
||||
|
||||
|
||||
def test_seed_counts_match_deterministic_dataset():
|
||||
# Other test modules mutate shared demo state (returns, resets), so this test
|
||||
# re-seeds immediately before asserting counts rather than trusting whatever
|
||||
# order pytest happened to run modules in.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||
# 246 original plus 8 (BK-T-001..008) added so "Today's movements" reads as a
|
||||
# real day of traffic rather than the same fixed 4 rows on every reset.
|
||||
assert db.scalar(select(func.count()).select_from(Booking)) == 254
|
||||
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
|
||||
# real open issue) plus a deterministic set discovered by the post-seed scan. The
|
||||
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
|
||||
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
||||
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||
# previous hand-rolled scanner never checked for.
|
||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
|
||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_demo_scenarios_present():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
|
||||
assert booking is not None
|
||||
assert booking.status == "active"
|
||||
|
||||
duplicate_customer = db.scalar(select(Customer).where(Customer.public_ref == "CUS-0178"))
|
||||
assert duplicate_customer is not None
|
||||
|
||||
duplicate_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
|
||||
)
|
||||
assert duplicate_issue is not None
|
||||
assert duplicate_issue.rule_type == "possible_duplicate_customer"
|
||||
|
||||
failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.delivery_status == "failed"))
|
||||
assert failed_run is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _by_ref(db, model, ref):
|
||||
return db.scalar(select(model).where(model.public_ref == ref))
|
||||
|
||||
|
||||
def test_seed_scenario_s1_odometer_regression_return():
|
||||
"""S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a
|
||||
below-canonical odometer reading, using the vehicle's own current odometer."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||
assert booking is not None and vehicle is not None
|
||||
assert booking.vehicle_id == vehicle.id
|
||||
assert booking.status == "active"
|
||||
assert booking.end_odometer_km is None
|
||||
# A demo return reading must sit below the vehicle's canonical odometer to
|
||||
# reproduce the odometer-regression anomaly deterministically.
|
||||
assert vehicle.odometer_km > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s2_duplicate_customer_pair():
|
||||
"""S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
primary = _by_ref(db, Customer, "CUS-0012")
|
||||
duplicate = _by_ref(db, Customer, "CUS-0178")
|
||||
assert primary is not None and duplicate is not None
|
||||
assert primary.email == duplicate.email
|
||||
assert duplicate.merged_into_customer_id is None
|
||||
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE")
|
||||
assert issue is not None
|
||||
assert issue.rule_type == "possible_duplicate_customer"
|
||||
assert issue.status == "open"
|
||||
related = issue.evidence_json.get("related_refs", [])
|
||||
assert "CUS-0012" in related or "CUS-0178" in related
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s4_booking_overlap():
|
||||
"""S4: MO-016 carries two overlapping reservations plus a matching open issue."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
vehicle = _by_ref(db, Vehicle, "MO-016")
|
||||
booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A")
|
||||
booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B")
|
||||
assert vehicle is not None and booking_a is not None and booking_b is not None
|
||||
assert booking_a.vehicle_id == vehicle.id
|
||||
assert booking_b.vehicle_id == vehicle.id
|
||||
assert booking_a.starts_at < booking_b.ends_at
|
||||
assert booking_b.starts_at < booking_a.ends_at
|
||||
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP")
|
||||
assert issue is not None
|
||||
assert issue.rule_type == "booking_overlap"
|
||||
assert issue.status == "open"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_odometer_issues_expose_only_the_regressing_booking_as_correctable():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-0007")
|
||||
assert issue is not None
|
||||
assert issue.evidence_json["source_type"] == "return"
|
||||
assert issue.evidence_json["related_refs"] == [
|
||||
"INSP-0057",
|
||||
"BK-H-0007",
|
||||
"INSP-0007",
|
||||
]
|
||||
assert issue.evidence_json["correctable_booking_refs"] == ["BK-H-0007"]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s5_failed_workflow_run():
|
||||
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
|
||||
pending, so the background dispatcher never silently auto-heals it away."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
failed = db.scalar(
|
||||
select(OutboxEvent).where(
|
||||
OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020"
|
||||
)
|
||||
)
|
||||
assert failed is not None
|
||||
assert failed.delivery_status == "failed"
|
||||
assert failed.attempts >= 1
|
||||
assert failed.last_error
|
||||
# Coded as a prepared demo scenario, not as a real connectionError: the whole
|
||||
# point of this row is to demonstrate retry and audit, so nothing downstream
|
||||
# may read it as evidence that the n8n integration is unhealthy.
|
||||
assert failed.last_error_code == DEMO_SCENARIO_ERROR_CODE
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_dates_are_anchored_to_reset_moment():
|
||||
"""Every reset shifts seeded dates by (real today - authored anchor), so scenario
|
||||
bookings stay 'today'/'near-future' relative to whenever the reset actually ran,
|
||||
instead of decaying back to the fixed 2026-08-01 authoring date."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = reset_and_seed(db)
|
||||
today = datetime.now(UTC).date()
|
||||
assert result.anchor_date == today
|
||||
|
||||
shift = today - SEED_AUTHORED_ANCHOR
|
||||
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||
assert booking is not None
|
||||
# Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the
|
||||
# real reset date, not the frozen authoring date (unless shift is exactly zero).
|
||||
assert booking.ends_at.date() == today or shift.days == 0
|
||||
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "demo_data_seeded")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
assert marker is not None
|
||||
assert marker.metadata_json["anchor_date"] == today.isoformat()
|
||||
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_today_movements_are_a_credible_mix():
|
||||
"""A fresh reset must not land on a thin, always-identical 'Today's movements'
|
||||
dashboard section: a real day of fleet traffic (>=5 departures, >=5 returns, across
|
||||
more than 4 distinct vehicles) should fall on the reset day, mirroring the same
|
||||
status/date rule the dashboard router uses to build the today list."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
today = datetime.now(UTC).date()
|
||||
bookings = db.scalars(select(Booking)).all()
|
||||
departures = [
|
||||
b
|
||||
for b in bookings
|
||||
if b.starts_at.date() == today and b.status in ("reserved", "active")
|
||||
]
|
||||
returns = [
|
||||
b for b in bookings if b.ends_at.date() == today and b.status in ("active", "returned")
|
||||
]
|
||||
assert len(departures) >= 5
|
||||
assert len(returns) >= 5
|
||||
vehicles_involved = {b.vehicle_id for b in departures} | {b.vehicle_id for b in returns}
|
||||
assert len(vehicles_involved) > 4
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_every_blocked_vehicle_has_a_real_open_issue():
|
||||
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
|
||||
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
|
||||
vehicle seeded as 'blocked' must now have at least one real, currently open
|
||||
DataQualityIssue an operator can click through to."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
|
||||
assert len(blocked) > 0
|
||||
for vehicle in blocked:
|
||||
open_issue = db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_mo024_service_conflict_is_flagged():
|
||||
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
|
||||
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
|
||||
with nothing surfacing the contradiction. The shared vehicle-status evaluator
|
||||
already catches this (a real conflict, not a fabricated third status) -- this test
|
||||
exists so a future change can't silently regress it back to unexplained."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||
assert vehicle is not None
|
||||
assert vehicle.operational_status == "rented"
|
||||
assert vehicle.odometer_km >= vehicle.next_service_km
|
||||
|
||||
issue = db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.rule_type == "vehicle_status_conflict",
|
||||
)
|
||||
)
|
||||
assert issue is not None
|
||||
signals = issue.evidence_json.get("signals", [])
|
||||
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_evidence_has_no_placeholder_summary():
|
||||
"""Every seed-only data-quality issue used to carry the vacuous evidence
|
||||
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
|
||||
had no way to understand why it needed attention. Every issue must now carry a real
|
||||
summary and at least one localizable signal (code + params)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
issues = db.scalars(select(DataQualityIssue)).all()
|
||||
assert len(issues) > 0
|
||||
for issue in issues:
|
||||
summary = issue.evidence_json.get("summary", "")
|
||||
assert summary != "Synthetic deterministic seed issue", (
|
||||
f"{issue.public_ref} still has the meaningless placeholder summary"
|
||||
)
|
||||
signals = issue.evidence_json.get("signals", [])
|
||||
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY_ROOT = next(
|
||||
parent
|
||||
for parent in Path(__file__).resolve().parents
|
||||
if (parent / "seed" / "generate_seed.py").is_file()
|
||||
)
|
||||
SEED_DIRECTORY = REPOSITORY_ROOT / "seed"
|
||||
|
||||
|
||||
def _rows_by_ref(path: Path) -> dict[str, dict[str, str]]:
|
||||
with path.open(newline="", encoding="utf-8") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
assert all(None not in row for row in rows), f"malformed CSV row in {path.name}"
|
||||
return {row["public_ref"]: row for row in rows}
|
||||
|
||||
|
||||
def test_generator_reproduces_committed_seed_snapshot_byte_for_byte(tmp_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_DIRECTORY / "generate_seed.py"),
|
||||
"--anchor",
|
||||
"2026-08-01",
|
||||
"--seed",
|
||||
"20260801",
|
||||
"--out",
|
||||
str(tmp_path),
|
||||
],
|
||||
cwd=REPOSITORY_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
committed_files = sorted(SEED_DIRECTORY.glob("*.csv"))
|
||||
generated_files = sorted(tmp_path.glob("*.csv"))
|
||||
assert [path.name for path in generated_files] == [path.name for path in committed_files]
|
||||
for committed in committed_files:
|
||||
generated = tmp_path / committed.name
|
||||
assert generated.read_bytes() == committed.read_bytes(), (
|
||||
f"{committed.name} differs from the deterministic generated snapshot"
|
||||
)
|
||||
|
||||
bookings = _rows_by_ref(tmp_path / "bookings.csv")
|
||||
inspections = _rows_by_ref(tmp_path / "inspections.csv")
|
||||
issues = _rows_by_ref(tmp_path / "data_quality_issues.csv")
|
||||
|
||||
assert int(bookings["BK-H-0007"]["end_odometer_km"]) < int(
|
||||
bookings["BK-H-0057"]["end_odometer_km"]
|
||||
)
|
||||
assert int(bookings["BK-H-0010"]["end_odometer_km"]) < int(
|
||||
bookings["BK-H-0060"]["end_odometer_km"]
|
||||
)
|
||||
assert inspections["INSP-0007"]["odometer_km"] == bookings["BK-H-0007"]["end_odometer_km"]
|
||||
assert inspections["INSP-0010"]["odometer_km"] == bookings["BK-H-0010"]["end_odometer_km"]
|
||||
assert inspections["INSP-0001"]["completed_at"] == bookings["BK-H-0001"]["ends_at"]
|
||||
|
||||
expected_issue_refs = {f"DQ-{index:04d}" for index in range(5, 22)}
|
||||
assert expected_issue_refs <= issues.keys()
|
||||
assert all(
|
||||
issues[public_ref]["evidence"] != "Synthetic deterministic seed issue"
|
||||
for public_ref in expected_issue_refs
|
||||
)
|
||||
assert "INSP-0007" in issues["DQ-0007"]["evidence"]
|
||||
assert "INSP-0057" in issues["DQ-0007"]["evidence"]
|
||||
assert "INSP-0010" in issues["DQ-0010"]["evidence"]
|
||||
assert "INSP-0060" in issues["DQ-0010"]["evidence"]
|
||||
@@ -0,0 +1,44 @@
|
||||
def test_manager_can_create_and_update_user(ops_client):
|
||||
created = ops_client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"email": "planner@example.test",
|
||||
"display_name": "Fleet Planner",
|
||||
"role": "rental_employee",
|
||||
"password": "a-secure-demo-password",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
public_ref = created.json()["public_ref"]
|
||||
assert created.json()["active"] is True
|
||||
updated = ops_client.patch(
|
||||
f"/api/v1/users/{public_ref}",
|
||||
json={"display_name": "Senior Fleet Planner", "active": False},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["display_name"] == "Senior Fleet Planner"
|
||||
assert updated.json()["active"] is False
|
||||
assert any(user["public_ref"] == public_ref for user in ops_client.get("/api/v1/users").json())
|
||||
|
||||
|
||||
def test_employee_cannot_manage_users(employee_client):
|
||||
assert employee_client.get("/api/v1/users").status_code == 403
|
||||
assert (
|
||||
employee_client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"email": "unauthorised@example.test",
|
||||
"display_name": "Unauthorised User",
|
||||
"role": "rental_employee",
|
||||
"password": "a-secure-demo-password",
|
||||
},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
employee_client.patch(
|
||||
"/api/v1/users/USR-OPS",
|
||||
json={"display_name": "Unauthorised Change"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Pure unit tests for the shared vehicle-status evaluator -- no database needed, since
|
||||
evaluate_vehicle_status() only reasons over an already-gathered VehicleStatusFacts. See
|
||||
docs/fleet-ops-correction/vehicle-status-decision-table.md for the decision table these
|
||||
tests are asserting against."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.vehicle_status import (
|
||||
RECOMMENDATION_CODE_ACTIVE_RENTAL,
|
||||
RECOMMENDATION_CODE_BOOKING_CONFLICT,
|
||||
RECOMMENDATION_CODE_MANUAL_REVIEW,
|
||||
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||
RECOMMENDATION_CODE_RENTAL_ENDED,
|
||||
RECOMMENDATION_CODE_SERVICE_THRESHOLD,
|
||||
VehicleStatusFacts,
|
||||
compute_recommendation_token,
|
||||
evaluate_vehicle_status,
|
||||
)
|
||||
|
||||
|
||||
def _vehicle(status: str, *, version: int = 1):
|
||||
return SimpleNamespace(operational_status=status, version=version)
|
||||
|
||||
|
||||
def _facts(**overrides) -> VehicleStatusFacts:
|
||||
defaults = dict(
|
||||
active_booking_refs=[],
|
||||
overlapping_booking_pairs=[],
|
||||
service_threshold_reached=False,
|
||||
odometer_km=10_000,
|
||||
next_service_km=20_000,
|
||||
open_booking_overlap_issue_ref=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return VehicleStatusFacts(**defaults)
|
||||
|
||||
|
||||
def test_available_with_active_rental_recommends_rented():
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts(active_booking_refs=["BK-0001"]))
|
||||
assert result.recommended_status == "rented"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
|
||||
assert result.safe_to_apply is True
|
||||
assert result.manual_review_required is False
|
||||
|
||||
|
||||
def test_maintenance_with_active_rental_never_auto_recommends_rented():
|
||||
# The exact unsafe shortcut this task explicitly forbids: maintenance + an active
|
||||
# booking must NEVER be auto-resolved to "rented".
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("maintenance"), _facts(active_booking_refs=["BK-0001"])
|
||||
)
|
||||
assert result.recommended_status is None
|
||||
assert result.manual_review_required is True
|
||||
assert result.safe_to_apply is False
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_MANUAL_REVIEW
|
||||
|
||||
|
||||
def test_available_with_active_rental_and_service_threshold_requires_manual_review():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"),
|
||||
_facts(active_booking_refs=["BK-0001"], service_threshold_reached=True),
|
||||
)
|
||||
assert result.manual_review_required is True
|
||||
assert result.recommended_status is None
|
||||
|
||||
|
||||
def test_available_with_active_rental_and_booking_conflict_requires_manual_review():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"),
|
||||
_facts(active_booking_refs=["BK-0001"], open_booking_overlap_issue_ref="DQ-0001"),
|
||||
)
|
||||
assert result.manual_review_required is True
|
||||
assert result.recommended_status is None
|
||||
|
||||
|
||||
def test_rented_with_no_active_booking_recommends_available():
|
||||
result = evaluate_vehicle_status(_vehicle("rented"), _facts())
|
||||
assert result.recommended_status == "available"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_RENTAL_ENDED
|
||||
assert result.safe_to_apply is True
|
||||
|
||||
|
||||
def test_service_threshold_reached_recommends_maintenance():
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts(service_threshold_reached=True))
|
||||
assert result.recommended_status == "maintenance"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
|
||||
|
||||
|
||||
def test_booking_conflict_recommends_blocked_not_a_generic_high_severity_proxy():
|
||||
# The evaluator must react to a *real* booking-conflict fact, not "does some other
|
||||
# open high-severity issue happen to exist" (the forbidden proxy).
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"),
|
||||
_facts(overlapping_booking_pairs=[("BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B")]),
|
||||
)
|
||||
assert result.recommended_status == "blocked"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
|
||||
|
||||
|
||||
def test_open_booking_overlap_issue_alone_also_triggers_blocked():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"), _facts(open_booking_overlap_issue_ref="DQ-DEMO-OVERLAP")
|
||||
)
|
||||
assert result.recommended_status == "blocked"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
|
||||
|
||||
|
||||
def test_maintenance_with_no_active_rental_and_no_blockers_stays_manual():
|
||||
# No fact here confirms maintenance is actually finished, so the evaluator must not
|
||||
# auto-clear it to "available" -- that release remains an explicit, manual decision.
|
||||
result = evaluate_vehicle_status(_vehicle("maintenance"), _facts())
|
||||
assert result.recommended_status is None
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||
assert result.safe_to_apply is False
|
||||
|
||||
|
||||
def test_no_conflict_when_status_already_matches_facts():
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts())
|
||||
assert result.recommended_status is None
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||
assert result.safe_to_apply is False
|
||||
|
||||
result = evaluate_vehicle_status(_vehicle("rented"), _facts(active_booking_refs=["BK-1"]))
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||
|
||||
result = evaluate_vehicle_status(_vehicle("blocked"), _facts())
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("maintenance"), _facts(service_threshold_reached=True)
|
||||
)
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||
|
||||
|
||||
def test_recommendation_token_changes_when_facts_change():
|
||||
vehicle = _vehicle("available")
|
||||
facts_a = _facts()
|
||||
facts_b = _facts(service_threshold_reached=True)
|
||||
assert compute_recommendation_token(vehicle, facts_a) != compute_recommendation_token(
|
||||
vehicle, facts_b
|
||||
)
|
||||
|
||||
|
||||
def test_recommendation_token_is_stable_for_identical_facts():
|
||||
vehicle = _vehicle("available")
|
||||
facts = _facts(active_booking_refs=["BK-0001"])
|
||||
assert compute_recommendation_token(vehicle, facts) == compute_recommendation_token(
|
||||
vehicle, facts
|
||||
)
|
||||
|
||||
|
||||
def test_recommendation_token_changes_when_vehicle_version_changes():
|
||||
facts = _facts()
|
||||
assert compute_recommendation_token(
|
||||
_vehicle("available", version=1), facts
|
||||
) != compute_recommendation_token(_vehicle("available", version=2), facts)
|
||||
@@ -0,0 +1,144 @@
|
||||
def test_list_vehicles_filters_by_status(ops_client):
|
||||
response = ops_client.get("/api/v1/vehicles", params={"status": "maintenance"})
|
||||
assert response.status_code == 200
|
||||
vehicles = response.json()
|
||||
assert len(vehicles) > 0
|
||||
assert all(v["operational_status"] == "maintenance" for v in vehicles)
|
||||
|
||||
|
||||
def test_attention_only_filters_flagged_vehicles(ops_client):
|
||||
response = ops_client.get("/api/v1/vehicles", params={"attention_only": True})
|
||||
assert response.status_code == 200
|
||||
vehicles = response.json()
|
||||
assert len(vehicles) > 0
|
||||
assert all(v["attention"] for v in vehicles)
|
||||
assert all(v["attention_reason"] for v in vehicles)
|
||||
|
||||
|
||||
def test_vehicle_list_exposes_planning_and_service_context(ops_client):
|
||||
vehicles = ops_client.get("/api/v1/vehicles").json()
|
||||
assert vehicles
|
||||
assert all("service_remaining_km" in vehicle for vehicle in vehicles)
|
||||
assert all("next_booking_ref" in vehicle for vehicle in vehicles)
|
||||
location = vehicles[0]["location"]
|
||||
filtered = ops_client.get("/api/v1/vehicles", params={"location": location}).json()
|
||||
assert filtered
|
||||
assert all(vehicle["location"].casefold() == location.casefold() for vehicle in filtered)
|
||||
|
||||
|
||||
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/vehicles",
|
||||
params={"status": "maintenance", "page": 1, "page_size": 25},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body["items"]) <= 25
|
||||
assert all(v["operational_status"] == "maintenance" for v in body["items"])
|
||||
assert body["total"] >= len(body["items"])
|
||||
|
||||
|
||||
def test_vehicle_detail_includes_related_records(ops_client):
|
||||
response = ops_client.get("/api/v1/vehicles/MO-016")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["public_ref"] == "MO-016"
|
||||
assert len(body["quality_issues"]) >= 1
|
||||
|
||||
|
||||
def test_employee_vehicle_detail_never_exposes_quality_evidence(employee_client):
|
||||
response = employee_client.get("/api/v1/vehicles/MO-016")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["quality_issues"] == []
|
||||
|
||||
|
||||
def test_vehicle_detail_404_for_unknown_ref(ops_client):
|
||||
response = ops_client.get("/api/v1/vehicles/MO-999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
|
||||
vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[0]
|
||||
response = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance",
|
||||
json={
|
||||
"occurred_at": "2051-01-15T09:00:00Z",
|
||||
"odometer_km": vehicle["odometer_km"] + 100,
|
||||
"category": "periodic_service",
|
||||
"summary": "Periodic service completed and safety checks passed.",
|
||||
"next_service_km": vehicle["odometer_km"] + 20100,
|
||||
"mark_maintenance": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert detail["operational_status"] == "maintenance"
|
||||
assert any(
|
||||
item["public_ref"] == response.json()["public_ref"] for item in detail["maintenance"]
|
||||
)
|
||||
released = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/release",
|
||||
json={"reason": "Service completed and vehicle inspected"},
|
||||
)
|
||||
assert released.status_code == 200
|
||||
assert released.json()["operational_status"] == "available"
|
||||
|
||||
|
||||
def test_low_maintenance_reading_keeps_canonical_and_opens_quality_issue(ops_client):
|
||||
existing_issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
|
||||
vehicles = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()
|
||||
vehicle = next(
|
||||
candidate
|
||||
for candidate in vehicles
|
||||
if candidate["odometer_km"] > 0 and candidate["public_ref"] not in unavailable_refs
|
||||
)
|
||||
|
||||
response = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance",
|
||||
json={
|
||||
"occurred_at": "2053-01-15T12:00:00Z",
|
||||
"odometer_km": vehicle["odometer_km"] - 1,
|
||||
"category": "inspection",
|
||||
"summary": "Imported workshop reading requires verification.",
|
||||
"mark_maintenance": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert detail["odometer_km"] == vehicle["odometer_km"]
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "rule_type": "odometer_regression"},
|
||||
).json()
|
||||
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
|
||||
assert issue["evidence"]["source_type"] == "maintenance"
|
||||
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||
issue_detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||
assert any(
|
||||
snapshot["entity_type"] == "maintenance"
|
||||
and snapshot["public_ref"] == response.json()["public_ref"]
|
||||
for snapshot in issue_detail["related_snapshots"]
|
||||
)
|
||||
retained = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||
json={"decision": "retain_canonical"},
|
||||
)
|
||||
assert retained.status_code == 200
|
||||
|
||||
|
||||
def test_employee_cannot_record_maintenance(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/vehicles/MO-001/maintenance",
|
||||
json={
|
||||
"occurred_at": "2051-01-15T09:00:00Z",
|
||||
"odometer_km": 100,
|
||||
"category": "repair",
|
||||
"summary": "Unauthorised attempt",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,95 @@
|
||||
from app.core.db import SessionLocal
|
||||
from app.seed_loader import reset_and_seed
|
||||
|
||||
|
||||
def _reseed() -> None:
|
||||
"""Restore the canonical demo dataset (19 succeeded + 1 prepared failure).
|
||||
|
||||
The suite shares one session-scoped database and earlier files legitimately mutate
|
||||
the outbox, so any test that asserts on the *seeded* scenario has to re-establish it
|
||||
rather than depend on file ordering.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_list_workflows_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/workflows")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_list_workflows_includes_seeded_failed_run(ops_client):
|
||||
_reseed()
|
||||
response = ops_client.get("/api/v1/workflows", params={"status": "failed"})
|
||||
assert response.status_code == 200
|
||||
runs = response.json()
|
||||
assert len(runs) >= 1
|
||||
assert all(r["status"] == "failed" for r in runs)
|
||||
|
||||
|
||||
def test_retry_requires_failed_status(ops_client):
|
||||
succeeded = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
|
||||
target = succeeded[0]["event_id"]
|
||||
response = ops_client.post(f"/api/v1/workflows/{target}/retry")
|
||||
assert response.status_code == 409
|
||||
assert response.json()["error"]["code"] == "NOT_RETRYABLE"
|
||||
|
||||
|
||||
def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
||||
_reseed()
|
||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||
target = failed[0]["event_id"]
|
||||
|
||||
response = ops_client.post(f"/api/v1/workflows/{target}/retry")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "pending"
|
||||
|
||||
audit_events = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
|
||||
assert len(audit_events) >= 1
|
||||
|
||||
|
||||
def test_retry_requires_operations_manager(employee_client):
|
||||
response = employee_client.post("/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_seeded_failure_is_labelled_as_a_prepared_demo_scenario(ops_client):
|
||||
"""The one seeded failure must announce itself as staged. An unexplained red row in
|
||||
a demo reads as a broken product; a labelled one reads as the retry story it is."""
|
||||
_reseed()
|
||||
runs = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||
demo_runs = [run for run in runs if run["is_demo_scenario"]]
|
||||
assert len(demo_runs) == 1
|
||||
assert demo_runs[0]["last_error_code"] == "demoScenarioTimeout"
|
||||
assert demo_runs[0]["aggregate_ref"] == "BK-H-0020"
|
||||
|
||||
|
||||
def test_succeeded_runs_are_never_marked_as_a_demo_scenario(ops_client):
|
||||
runs = ops_client.get("/api/v1/workflows", params={"status": "succeeded"}).json()
|
||||
assert runs
|
||||
assert all(run["is_demo_scenario"] is False for run in runs)
|
||||
|
||||
|
||||
def test_retry_of_the_demo_scenario_is_audited_as_a_demo_scenario(ops_client):
|
||||
"""The retry is a real redelivery either way; the audit records which kind of
|
||||
failure it resolved so a staged retry is never mistaken for a production fix."""
|
||||
_reseed()
|
||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||
demo_run = next(run for run in failed if run["is_demo_scenario"])
|
||||
|
||||
response = ops_client.post(f"/api/v1/workflows/{demo_run['event_id']}/retry")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "pending"
|
||||
|
||||
audit = ops_client.get("/api/v1/audit", params={"action": "workflow_retry"}).json()
|
||||
entry = next(
|
||||
event
|
||||
for event in audit
|
||||
if (event.get("metadata") or event.get("metadata_json") or {}).get("event_id")
|
||||
== demo_run["event_id"]
|
||||
)
|
||||
metadata = entry.get("metadata") or entry.get("metadata_json") or {}
|
||||
assert metadata["demo_scenario"] is True
|
||||
Reference in new issue
Block a user