"""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=1001 violates le=1000 response = await test_client.get( f"{OUTBOX_URL}/messages", params={"limit": 1001}, 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 — router declares status_code=202 for async batch-retry response = await test_client.post( f"{OUTBOX_URL}/dead-letter/batch-retry", headers=admin_headers, ) # Assert assert response.status_code == 202, 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 # ============================================================================= # === Auth three-tier for GET /outbox/stats === # ============================================================================= async def test_get_outbox_stats_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/stats") # Assert assert response.status_code == 401 async def test_get_outbox_stats_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(f"{OUTBOX_URL}/stats", headers=standard_user["headers"]) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for GET /outbox/trend === # ============================================================================= async def test_get_outbox_trend_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/trend") # Assert assert response.status_code == 401 async def test_get_outbox_trend_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get( f"{OUTBOX_URL}/trend", params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END}, headers=standard_user["headers"], ) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for GET /outbox/dead-letter === # ============================================================================= async def test_list_dead_letter_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/dead-letter") # Assert assert response.status_code == 401 async def test_list_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(f"{OUTBOX_URL}/dead-letter", headers=standard_user["headers"]) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for GET /outbox/retry-policy === # ============================================================================= async def test_get_retry_policy_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/retry-policy") # Assert assert response.status_code == 401 async def test_get_retry_policy_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(f"{OUTBOX_URL}/retry-policy", headers=standard_user["headers"]) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for POST /outbox/dead-letter/batch-retry === # ============================================================================= async def test_batch_retry_dead_letter_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.post(f"{OUTBOX_URL}/dead-letter/batch-retry") # Assert assert response.status_code == 401 async def test_batch_retry_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.post( f"{OUTBOX_URL}/dead-letter/batch-retry", headers=standard_user["headers"], ) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for POST /outbox/dead-letter/batch-delete === # ============================================================================= async def test_batch_delete_dead_letter_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.post(f"{OUTBOX_URL}/dead-letter/batch-delete") # Assert assert response.status_code == 401 async def test_batch_delete_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.post( f"{OUTBOX_URL}/dead-letter/batch-delete", headers=standard_user["headers"], ) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for GET /outbox/dead-letter/export === # ============================================================================= async def test_export_dead_letter_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/dead-letter/export") # Assert assert response.status_code == 401 async def test_export_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(f"{OUTBOX_URL}/dead-letter/export", headers=standard_user["headers"]) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for PUT /outbox/retry-policy === # ============================================================================= async def test_update_retry_policy_requires_auth(test_client: httpx.AsyncClient): # 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) # Assert assert response.status_code == 401 async def test_update_retry_policy_requires_admin(test_client: httpx.AsyncClient, standard_user): # 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=standard_user["headers"]) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for GET /outbox/messages/{outbox_id} === # ============================================================================= async def test_get_outbox_message_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}") # Assert assert response.status_code == 401 async def test_get_outbox_message_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get( f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}", headers=standard_user["headers"], ) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for DELETE /outbox/dead-letter/{outbox_id} === # ============================================================================= async def test_delete_dead_letter_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.delete(f"{OUTBOX_URL}/dead-letter/{NON_EXISTENT_OUTBOX_ID}") # Assert assert response.status_code == 401 async def test_delete_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.delete( f"{OUTBOX_URL}/dead-letter/{NON_EXISTENT_OUTBOX_ID}", headers=standard_user["headers"], ) # Assert assert response.status_code == 403 # ============================================================================= # === Auth three-tier for POST /outbox/messages/{outbox_id}/retry === # ============================================================================= async def test_retry_outbox_message_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.post(f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}/retry") # Assert assert response.status_code == 401 async def test_retry_outbox_message_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.post( f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}/retry", headers=standard_user["headers"], ) # Assert assert response.status_code == 403 # ============================================================================= # === GET /outbox/trend — granularity / metric Literal validation (422) === # ============================================================================= async def test_outbox_trend_rejects_invalid_granularity(test_client: httpx.AsyncClient, admin_headers): # Act — granularity="invalid" violates Literal["minute", "hour", "day"] response = await test_client.get( f"{OUTBOX_URL}/trend", params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END, "granularity": "invalid"}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_outbox_trend_rejects_invalid_metric(test_client: httpx.AsyncClient, admin_headers): # Act — metric="invalid" violates Literal["queue_depth", "retry_count", "dead_count"] response = await test_client.get( f"{OUTBOX_URL}/trend", params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END, "metric": "invalid"}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text # ============================================================================= # === GET /outbox/dead-letter/export — format Literal validation (422) === # ============================================================================= async def test_export_dead_letter_rejects_invalid_format(test_client: httpx.AsyncClient, admin_headers): # Act — format="xml" violates Literal["json", "csv"] response = await test_client.get( f"{OUTBOX_URL}/dead-letter/export", params={"format": "xml"}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text # ============================================================================= # === PUT /outbox/retry-policy — retry_backoff_schedule validation (422) === # ============================================================================= async def test_update_retry_policy_rejects_empty_backoff_schedule(test_client: httpx.AsyncClient, admin_headers): # Act — retry_backoff_schedule=[] violates min_length=1 body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": []} 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_too_many_backoff_schedule(test_client: httpx.AsyncClient, admin_headers): # Act — 21 entries violates max_length=20 body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10] * 21} 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_non_positive_backoff_schedule( test_client: httpx.AsyncClient, admin_headers ): # Act — 0 in schedule violates field_validator (positive integers only) body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 0, 60]} response = await test_client.put(f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers) # Assert assert response.status_code == 422, response.text