Publish curated MobilityOps source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:03:33 +02:00
commit 7adc072f5e
317 files changed
+48851

No files matched your search

+38
View File
@@ -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")
+83
View File
@@ -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"]
+306
View File
@@ -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
)
+382
View File
@@ -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
+89
View File
@@ -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)
+158
View File
@@ -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
+400
View File
@@ -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"
+341
View File
@@ -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()
+41
View File
@@ -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"
+292
View File
@@ -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
+454
View File
@@ -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
+187
View File
@@ -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
+88
View File
@@ -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)
)
+53
View File
@@ -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")
+36
View File
@@ -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
+114
View File
@@ -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
+317
View File
@@ -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)
+75
View File
@@ -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"] == []
+296
View File
@@ -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()
+73
View File
@@ -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"]
+44
View File
@@ -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
)
+156
View File
@@ -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)
+144
View File
@@ -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
+95
View File
@@ -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