Files
leocrm/tests/test_outbox_phase5.py
T
Agent Zero 07a99975ec Phase 5: Outbox DLQ, Monitoring, Consumer-Registry
- Migration 0092: DLQ columns (error_message, failed_at) + consumer_inbox RLS fix
- outbox.py: DLQ logic, replay functions, stats, consumer registry
- app/routes/outbox.py: 5 API endpoints (stats, failed, replay, replay-all, consumer-registry)
- outbox_deliveries tracking per consumer handler
- 18/18 tests passing
2026-08-02 23:25:54 +02:00

423 lines
15 KiB
Python

"""Tests for Phase 5: Outbox DLQ, Monitoring, Consumer-Registry.
Covers:
- DLQ: failed events have error_message and failed_at set
- Replay: single and bulk replay resets failed events to pending
- Stats: get_outbox_stats returns correct counts per status
- Failed events listing: get_failed_events returns error details
- Consumer registry: get_consumer_registry returns event→handlers mapping
- outbox_deliveries: entries are written during event processing
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.core.db import set_tenant_context
from app.core.event_bus import get_event_bus
from app.core.outbox import (
enqueue_outbox_event,
get_consumer_registry,
get_failed_events,
get_outbox_stats,
process_outbox_batch,
replay_all_failed_events,
replay_failed_event,
)
# ── DLQ: error_message and failed_at ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_failed_event_has_error_message(
db_session: AsyncSession,
session_factory: async_sessionmaker[AsyncSession],
):
"""When a handler fails permanently, error_message and failed_at are set."""
tenant_id = uuid.uuid4()
async def _always_fails(payload: dict) -> None:
raise RuntimeError("Permanent failure for DLQ test")
bus = get_event_bus()
bus.subscribe("test.dlq.fail", _always_fails)
try:
# Insert event with attempts = max_attempts - 1 so next failure marks it failed
await db_session.execute(
text(
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts) "
"VALUES (:tid, 'test.dlq.fail', CAST(:payload AS JSONB), 'pending', 4, 5)"
),
{"tid": str(tenant_id), "payload": '{"k": "v"}'},
)
await db_session.flush()
await db_session.commit()
async with session_factory() as worker_session:
count = await process_outbox_batch(
worker_session, batch_size=10, tenant_ids=[tenant_id]
)
assert count == 0 # nothing was successfully published
await set_tenant_context(db_session, tenant_id)
rows = (
await db_session.execute(
text(
"SELECT status, error_message, failed_at "
"FROM event_outbox WHERE tenant_id = :tid"
),
{"tid": str(tenant_id)},
)
).fetchall()
assert rows[0][0] == "failed"
assert rows[0][1] is not None
assert "Permanent failure" in rows[0][1]
assert rows[0][2] is not None # failed_at
finally:
bus.unsubscribe("test.dlq.fail", _always_fails)
# ── Replay single failed event ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_replay_failed_event(db_session: AsyncSession):
"""replay_failed_event resets a failed event to pending."""
tenant_id = uuid.uuid4()
await db_session.execute(
text(
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts, error_message, failed_at) "
"VALUES (:tid, 'test.replay', CAST(:payload AS JSONB), 'failed', 5, 5, 'some error', now())"
),
{"tid": str(tenant_id), "payload": '{"k": "v"}'},
)
await db_session.flush()
await db_session.commit()
row = (
await db_session.execute(
text("SELECT id FROM event_outbox WHERE tenant_id = :tid"),
{"tid": str(tenant_id)},
)
).first()
event_id = row[0]
await set_tenant_context(db_session, tenant_id)
replayed = await replay_failed_event(db_session, event_id)
await db_session.commit()
assert replayed is True
rows = (
await db_session.execute(
text(
"SELECT status, attempts, error_message, next_retry_at "
"FROM event_outbox WHERE id = :eid"
),
{"eid": str(event_id)},
)
).fetchall()
assert rows[0][0] == "pending"
assert rows[0][1] == 0
assert rows[0][2] is None
assert rows[0][3] is None
@pytest.mark.asyncio
async def test_replay_failed_event_not_found(db_session: AsyncSession):
"""replay_failed_event returns False for non-existent or non-failed events."""
tenant_id = uuid.uuid4()
fake_id = uuid.uuid4()
await set_tenant_context(db_session, tenant_id)
replayed = await replay_failed_event(db_session, fake_id)
await db_session.commit()
assert replayed is False
# ── Replay all failed events for a tenant ─────────────────────────────────────
@pytest.mark.asyncio
async def test_replay_all_failed_events(db_session: AsyncSession):
"""replay_all_failed_events resets all failed events for a tenant."""
tenant_id = uuid.uuid4()
for i in range(3):
await db_session.execute(
text(
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts, error_message, failed_at) "
"VALUES (:tid, :event_name, CAST(:payload AS JSONB), 'failed', 5, 5, :error, now())"
),
{
"tid": str(tenant_id),
"event_name": f"test.replay_all.{i}",
"payload": '{"k": "v"}',
"error": f"error {i}",
},
)
await db_session.flush()
await db_session.commit()
await set_tenant_context(db_session, tenant_id)
count = await replay_all_failed_events(db_session, tenant_id)
await db_session.commit()
assert count == 3
rows = (
await db_session.execute(
text(
"SELECT status, attempts, error_message "
"FROM event_outbox WHERE tenant_id = :tid"
),
{"tid": str(tenant_id)},
)
).fetchall()
for row in rows:
assert row[0] == "pending"
assert row[1] == 0
assert row[2] is None
# ── Outbox stats ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_outbox_stats(db_session: AsyncSession):
"""get_outbox_stats returns correct counts per status."""
tenant_id = uuid.uuid4()
for status in ["pending", "pending", "published", "failed"]:
await db_session.execute(
text(
"INSERT INTO event_outbox (tenant_id, event_name, payload, status) "
"VALUES (:tid, 'test.stats', CAST(:payload AS JSONB), :status)"
),
{"tid": str(tenant_id), "payload": '{"k": "v"}', "status": status},
)
await db_session.flush()
await db_session.commit()
await set_tenant_context(db_session, tenant_id)
stats = await get_outbox_stats(db_session)
assert stats["counts"]["pending"] == 2
assert stats["counts"]["published"] == 1
assert stats["counts"]["failed"] == 1
assert stats["total"] == 4
assert stats["oldest_pending_age_seconds"] is not None
@pytest.mark.asyncio
async def test_get_outbox_stats_empty(db_session: AsyncSession):
"""get_outbox_stats returns zeros when no events exist."""
tenant_id = uuid.uuid4()
await set_tenant_context(db_session, tenant_id)
stats = await get_outbox_stats(db_session)
assert stats["total"] == 0
assert stats["oldest_pending_age_seconds"] is None
# ── Failed events listing ─────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_failed_events(db_session: AsyncSession):
"""get_failed_events returns failed events with error details."""
tenant_id = uuid.uuid4()
await db_session.execute(
text(
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts, error_message, failed_at) "
"VALUES (:tid, 'test.failed_list', CAST(:payload AS JSONB), 'failed', 5, 5, 'test error', now())"
),
{"tid": str(tenant_id), "payload": '{"k": "v"}'},
)
await db_session.flush()
await db_session.commit()
await set_tenant_context(db_session, tenant_id)
events = await get_failed_events(db_session, limit=10, offset=0)
assert len(events) == 1
assert events[0]["event_name"] == "test.failed_list"
assert events[0]["error_message"] == "test error"
assert events[0]["status"] == "failed"
assert events[0]["attempts"] == 5
@pytest.mark.asyncio
async def test_get_failed_events_pagination(db_session: AsyncSession):
"""get_failed_events respects limit and offset."""
tenant_id = uuid.uuid4()
for i in range(5):
await db_session.execute(
text(
"INSERT INTO event_outbox (tenant_id, event_name, payload, status, attempts, max_attempts, error_message, failed_at) "
"VALUES (:tid, :name, CAST(:payload AS JSONB), 'failed', 5, 5, :error, now())"
),
{
"tid": str(tenant_id),
"name": f"test.pag.{i}",
"payload": '{"k": "v"}',
"error": f"err {i}",
},
)
await db_session.flush()
await db_session.commit()
await set_tenant_context(db_session, tenant_id)
page1 = await get_failed_events(db_session, limit=2, offset=0)
page2 = await get_failed_events(db_session, limit=2, offset=2)
assert len(page1) == 2
assert len(page2) == 2
# ── Consumer registry ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_consumer_registry():
"""get_consumer_registry returns event_name → consumer_names mapping."""
bus = get_event_bus()
async def handler_alpha(payload: dict) -> None:
pass
async def handler_beta(payload: dict) -> None:
pass
bus.subscribe("test.registry.event", handler_alpha)
bus.subscribe("test.registry.event", handler_beta)
try:
registry = get_consumer_registry()
assert "test.registry.event" in registry
assert len(registry["test.registry.event"]) == 2
assert "handler_alpha" in registry["test.registry.event"]
assert "handler_beta" in registry["test.registry.event"]
finally:
bus.unsubscribe("test.registry.event", handler_alpha)
bus.unsubscribe("test.registry.event", handler_beta)
# ── outbox_deliveries written during processing ───────────────────────────────
@pytest.mark.asyncio
async def test_outbox_deliveries_written_on_success(
db_session: AsyncSession,
session_factory: async_sessionmaker[AsyncSession],
):
"""outbox_deliveries entries are written with status='delivered' on success."""
tenant_id = uuid.uuid4()
async def _success_handler(payload: dict) -> None:
pass
bus = get_event_bus()
bus.subscribe("test.deliveries.ok", _success_handler)
try:
await enqueue_outbox_event(
db_session, tenant_id, "test.deliveries.ok", {"key": "value"}
)
await db_session.flush()
await db_session.commit()
async with session_factory() as worker_session:
count = await process_outbox_batch(
worker_session, batch_size=10, tenant_ids=[tenant_id]
)
assert count == 1
await set_tenant_context(db_session, tenant_id)
rows = (
await db_session.execute(
text(
"SELECT d.consumer_name, d.status, d.last_error "
"FROM outbox_deliveries d "
"JOIN event_outbox e ON d.event_id = e.id "
"WHERE e.tenant_id = :tid"
),
{"tid": str(tenant_id)},
)
).fetchall()
assert len(rows) == 1
assert rows[0][0] == "_success_handler"
assert rows[0][1] == "delivered"
assert rows[0][2] is None # no error
finally:
bus.unsubscribe("test.deliveries.ok", _success_handler)
@pytest.mark.asyncio
async def test_outbox_deliveries_written_on_failure(
db_session: AsyncSession,
session_factory: async_sessionmaker[AsyncSession],
):
"""outbox_deliveries entries are written with status='failed' on handler error."""
tenant_id = uuid.uuid4()
async def _failing_handler(payload: dict) -> None:
raise RuntimeError("Delivery failure")
bus = get_event_bus()
bus.subscribe("test.deliveries.fail", _failing_handler)
try:
await enqueue_outbox_event(
db_session, tenant_id, "test.deliveries.fail", {"key": "value"}
)
await db_session.flush()
await db_session.commit()
async with session_factory() as worker_session:
count = await process_outbox_batch(
worker_session, batch_size=10, tenant_ids=[tenant_id]
)
assert count == 0 # not published
await set_tenant_context(db_session, tenant_id)
rows = (
await db_session.execute(
text(
"SELECT d.consumer_name, d.status, d.last_error "
"FROM outbox_deliveries d "
"JOIN event_outbox e ON d.event_id = e.id "
"WHERE e.tenant_id = :tid"
),
{"tid": str(tenant_id)},
)
).fetchall()
assert len(rows) == 1
assert rows[0][0] == "_failing_handler"
assert rows[0][1] == "failed"
assert rows[0][2] is not None
assert "Delivery failure" in rows[0][2]
finally:
bus.unsubscribe("test.deliveries.fail", _failing_handler)
# ── Route import smoke test ────────────────────────────────────────────────────
def test_route_import():
"""Verify that the outbox route module imports cleanly."""
from app.routes.outbox import router
assert router is not None
assert router.prefix == "/api/v1/outbox"
# Check all 5 endpoints are registered
paths = {route.path for route in router.routes}
assert "/api/v1/outbox/stats" in paths
assert "/api/v1/outbox/failed" in paths
assert "/api/v1/outbox/replay/{event_id}" in paths
assert "/api/v1/outbox/replay-all" in paths
assert "/api/v1/outbox/consumer-registry" in paths