"""Integration tests for channels outbox_router endpoints.""" from __future__ import annotations import httpx import pytest from .conftest import ( BASE_URL, DEFAULT_CHANNEL_TYPE, ISO_TIME_END, ISO_TIME_START, NON_EXISTENT_OUTBOX_ID, ) pytestmark = [pytest.mark.asyncio, pytest.mark.integration] OUTBOX_URL = f"{BASE_URL}/outbox" # ============================================================================= # === Auth three-tier for GET /outbox/messages === # ============================================================================= async def test_list_outbox_messages_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/messages") # Assert assert response.status_code == 401 async def test_list_outbox_messages_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(f"{OUTBOX_URL}/messages", headers=standard_user["headers"]) # Assert assert response.status_code == 403 async def test_admin_can_list_outbox_messages(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{OUTBOX_URL}/messages", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) assert isinstance(payload["data"]["items"], list) # ============================================================================= # === GET /outbox/messages pagination validation === # ============================================================================= async def test_list_outbox_messages_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers): # Act — limit=0 violates ge=1 response = await test_client.get( f"{OUTBOX_URL}/messages", params={"limit": 0}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_list_outbox_messages_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers): # Act — limit=201 violates le=200 response = await test_client.get( f"{OUTBOX_URL}/messages", params={"limit": 201}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text # ============================================================================= # === GET /outbox/stats === # ============================================================================= async def test_admin_can_get_outbox_stats(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{OUTBOX_URL}/stats", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) data = payload["data"] assert "top_errors" in data and isinstance(data["top_errors"], list) assert "avg_latency_ms" in data assert "oldest_pending_at" in data # ============================================================================= # === GET /outbox/trend === # ============================================================================= async def test_outbox_trend_requires_start_time(test_client: httpx.AsyncClient, admin_headers): # Act — start_time required response = await test_client.get( f"{OUTBOX_URL}/trend", params={"end_time": ISO_TIME_END}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_outbox_trend_requires_end_time(test_client: httpx.AsyncClient, admin_headers): # Act — end_time required response = await test_client.get( f"{OUTBOX_URL}/trend", params={"start_time": ISO_TIME_START}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_admin_can_get_outbox_trend(test_client: httpx.AsyncClient, admin_headers): # Arrange params = { "start_time": ISO_TIME_START, "end_time": ISO_TIME_END, "channel_type": DEFAULT_CHANNEL_TYPE, "granularity": "hour", "metric": "queue_depth", } # Act response = await test_client.get(f"{OUTBOX_URL}/trend", params=params, headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) # ============================================================================= # === GET /outbox/dead-letter === # ============================================================================= async def test_admin_can_list_dead_letter(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{OUTBOX_URL}/dead-letter", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) # ============================================================================= # === GET /outbox/retry-policy === # ============================================================================= async def test_admin_can_get_retry_policy(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{OUTBOX_URL}/retry-policy", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) # ============================================================================= # === POST /outbox/dead-letter/batch-retry === # ============================================================================= async def test_admin_can_batch_retry_dead_letter(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.post( f"{OUTBOX_URL}/dead-letter/batch-retry", headers=admin_headers, ) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) # ============================================================================= # === POST /outbox/dead-letter/batch-delete === # ============================================================================= async def test_admin_can_batch_delete_dead_letter(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.post( f"{OUTBOX_URL}/dead-letter/batch-delete", headers=admin_headers, ) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) # ============================================================================= # === GET /outbox/dead-letter/export === # ============================================================================= async def test_admin_can_export_dead_letter(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(f"{OUTBOX_URL}/dead-letter/export", headers=admin_headers) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) # ============================================================================= # === PUT /outbox/retry-policy validation === # ============================================================================= async def test_admin_can_update_retry_policy(test_client: httpx.AsyncClient, admin_headers): # Arrange body = { "max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60], } # Act response = await test_client.put( f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers, ) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True assert isinstance(payload["data"], dict) async def test_update_retry_policy_rejects_max_retry_zero(test_client: httpx.AsyncClient, admin_headers): # Act — max_retry=0 violates ge=1 body = {"max_retry": 0, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60]} response = await test_client.put( f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_update_retry_policy_rejects_max_retry_over_max(test_client: httpx.AsyncClient, admin_headers): # Act — max_retry=21 violates le=20 body = {"max_retry": 21, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60]} response = await test_client.put( f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_update_retry_policy_rejects_ttl_too_small(test_client: httpx.AsyncClient, admin_headers): # Act — ttl_seconds=59 violates ge=60 body = {"max_retry": 5, "ttl_seconds": 59, "retry_backoff_schedule": [10, 30, 60]} response = await test_client.put( f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_update_retry_policy_rejects_ttl_too_large(test_client: httpx.AsyncClient, admin_headers): # Act — ttl_seconds=2592001 violates le=2592000 body = {"max_retry": 5, "ttl_seconds": 2592001, "retry_backoff_schedule": [10, 30, 60]} response = await test_client.put( f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text # ============================================================================= # === GET /outbox/messages/{outbox_id} === # ============================================================================= async def test_get_outbox_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get( f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}", headers=admin_headers, ) # Assert assert response.status_code == 404, response.text # ============================================================================= # === DELETE /outbox/dead-letter/{outbox_id} === # ============================================================================= async def test_delete_dead_letter_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.delete( f"{OUTBOX_URL}/dead-letter/{NON_EXISTENT_OUTBOX_ID}", headers=admin_headers, ) # Assert assert response.status_code == 404, response.text # ============================================================================= # === POST /outbox/messages/{outbox_id}/retry === # ============================================================================= async def test_retry_outbox_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.post( f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}/retry", headers=admin_headers, ) # Assert assert response.status_code == 404, response.text # ============================================================================= # === Static paths not captured by /messages/{outbox_id} === # ============================================================================= async def test_stats_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers): # Act — /stats must route to stats endpoint, not /messages/{outbox_id} response = await test_client.get(f"{OUTBOX_URL}/stats", headers=admin_headers) # Assert assert response.status_code == 200, response.text async def test_trend_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers): # Act — /trend must route to trend endpoint response = await test_client.get( f"{OUTBOX_URL}/trend", params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END}, headers=admin_headers, ) # Assert assert response.status_code == 200, response.text async def test_dead_letter_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers): # Act — /dead-letter must route to dead-letter endpoint response = await test_client.get(f"{OUTBOX_URL}/dead-letter", headers=admin_headers) # Assert assert response.status_code == 200, response.text async def test_retry_policy_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers): # Act — /retry-policy must route to retry-policy endpoint response = await test_client.get(f"{OUTBOX_URL}/retry-policy", headers=admin_headers) # Assert assert response.status_code == 200, response.text