Files
MobilityOps/backend/tests/test_integrations.py
T
NuklearRabbitandClaude Sonnet 5 bbdb4a9ae8 n8n: add Fleet Ops endpoint to receive workflow error reports
New POST /api/v1/integrations/n8n/workflow-error, service-token
authenticated, for the central "Fleet Ops — Workflow Error Handler"
n8n workflow to report a bounded, secret-free failure (workflow id/
name, execution id, safe error category, trigger context, correlation
id, attempt, retry action). Idempotent on execution_id via the same
audit-event precheck pattern used by /return-callback, so a
redelivered error report is not registered twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:40:36 +02:00

194 lines
6.7 KiB
Python

import uuid
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):
response = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning"},
headers=_callback_headers(str(uuid.uuid4()), token="wrong-token"),
)
assert response.status_code == 401
def test_callback_unknown_event_returns_404(client):
response = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning"},
headers=_callback_headers(str(uuid.uuid4())),
)
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))
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": {},
"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={"follow_up": "cleaning", "summary": "test"},
headers=_callback_headers(event_id),
)
second = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"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
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_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