import asyncio import json import pytest import pytest_asyncio from libs.events.base import EventPayload from libs.events.nats_bus import NATSEventBus from libs.schemas.events import DocumentIngestedEventData # Check if NATS is available async def is_nats_available(): import nats try: nc = await nats.connect("nats://localhost:4222") await nc.close() return True except Exception: return False @pytest_asyncio.fixture async def nats_bus(): """Create and start a NATS event bus for testing.""" if not await is_nats_available(): pytest.skip("NATS server not available at localhost:4222") bus = NATSEventBus( servers="nats://localhost:4222", stream_name="TEST_INTEGRATION_STREAM", consumer_group="test-integration-group", dlq_stream_name="TEST_INTEGRATION_DLQ", max_retries=2, ) await bus.start() # Clean up streams before test try: await bus.js.delete_stream("TEST_INTEGRATION_STREAM") await bus.js.delete_stream("TEST_INTEGRATION_DLQ") except Exception: pass # Re-create streams await bus._ensure_stream_exists() await bus.dlq.ensure_dlq_stream_exists() # Allow time for streams to propagate await asyncio.sleep(2) yield bus # Clean up after test try: await bus.js.delete_stream("TEST_INTEGRATION_STREAM") await bus.js.delete_stream("TEST_INTEGRATION_DLQ") except Exception: pass await bus.stop() @pytest.mark.integration @pytest.mark.asyncio async def test_publish_subscribe_flow(): """Test end-to-end publish and subscribe flow.""" # Instantiate bus directly to debug fixture issues bus = NATSEventBus( servers="nats://localhost:4222", stream_name="TEST_INTEGRATION_STREAM_DIRECT", consumer_group="test-integration-group-direct", dlq_stream_name="TEST_INTEGRATION_DLQ_DIRECT", max_retries=2, ) await bus.start() try: await bus.js.delete_stream("TEST_INTEGRATION_STREAM_DIRECT") except Exception: pass await bus._ensure_stream_exists() try: # Create event data data = DocumentIngestedEventData( doc_id="test-doc-123", filename="test.pdf", mime_type="application/pdf", size_bytes=1024, source="upload", kind="invoice", storage_path="s3://test-bucket/test.pdf", checksum_sha256="a" * 64, ) payload = EventPayload( data=data.model_dump(mode="json"), actor="test-user", tenant_id="test-tenant", trace_id="trace-123", schema_version="1.0", ) payload.event_id = "evt-123" # Setup subscriber received_event = asyncio.Future() async def handler(topic, event): if not received_event.done(): received_event.set_result(event) await bus.subscribe("doc.ingested", handler) # Publish event success = await bus.publish("doc.ingested", payload) assert success is True # Wait for reception try: result = await asyncio.wait_for(received_event, timeout=5.0) assert result.event_id == payload.event_id assert result.data["doc_id"] == "test-doc-123" except TimeoutError: pytest.fail("Event not received within timeout") finally: await bus.stop() @pytest.mark.integration @pytest.mark.asyncio async def test_dlq_routing(nats_bus): """Test that failed events are routed to DLQ after retries.""" # Create event data data = DocumentIngestedEventData( doc_id="test-doc-fail", filename="fail.pdf", mime_type="application/pdf", size_bytes=1024, source="upload", kind="invoice", storage_path="s3://test-bucket/fail.pdf", checksum_sha256="a" * 64, ) payload = EventPayload( data=data.model_dump(mode="json"), actor="test-user", tenant_id="test-tenant", trace_id="trace-fail", schema_version="1.0", ) # Setup failing handler failure_count = 0 async def failing_handler(topic, event): nonlocal failure_count failure_count += 1 raise ValueError("Simulated processing failure") await nats_bus.subscribe("doc.fail", failing_handler) # Publish event await nats_bus.publish("doc.fail", payload) # Wait for retries and DLQ routing await asyncio.sleep(2.0) # Wait for processing assert failure_count >= 2 # Consume from DLQ to verify dlq_sub = await nats_bus.js.pull_subscribe( subject="TEST_INTEGRATION_DLQ.doc.fail", durable="test-dlq-consumer" ) msgs = await dlq_sub.fetch(batch=1, timeout=5.0) assert len(msgs) == 1 dlq_msg = msgs[0] dlq_data = json.loads(dlq_msg.data.decode()) assert dlq_data["original_payload"]["event_id"] == payload.event_id assert dlq_data["error"]["type"] == "ValueError" assert dlq_data["error"]["message"] == "Simulated processing failure" await dlq_msg.ack() @pytest.mark.integration @pytest.mark.asyncio async def test_metrics_recording(nats_bus): """Test that metrics are recorded during event processing.""" from libs.events.metrics import event_consumed_total, event_published_total # Get initial values initial_published = event_published_total.labels(topic="doc.metrics")._value.get() initial_consumed = event_consumed_total.labels( topic="doc.metrics", consumer_group="test-integration-group" )._value.get() # Create and publish event data = DocumentIngestedEventData( doc_id="test-doc-metrics", filename="metrics.pdf", mime_type="application/pdf", size_bytes=1024, source="upload", kind="invoice", storage_path="s3://test-bucket/metrics.pdf", checksum_sha256="a" * 64, ) payload = EventPayload( data=data.model_dump(mode="json"), actor="test-user", tenant_id="test-tenant", trace_id="trace-metrics", schema_version="1.0", ) received_event = asyncio.Future() async def handler(topic, event): if not received_event.done(): received_event.set_result(event) await nats_bus.subscribe("doc.metrics", handler) await nats_bus.publish("doc.metrics", payload) await asyncio.wait_for(received_event, timeout=5.0) # Check metrics increased final_published = event_published_total.labels(topic="doc.metrics")._value.get() final_consumed = event_consumed_total.labels( topic="doc.metrics", consumer_group="test-integration-group" )._value.get() assert final_published > initial_published assert final_consumed > initial_consumed