"""Integration tests for channels message_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_MESSAGE_ID, ) pytestmark = [pytest.mark.asyncio, pytest.mark.integration] MESSAGES_URL = f"{BASE_URL}/messages" # ============================================================================= # === Auth three-tier for GET /messages === # ============================================================================= async def test_list_messages_requires_auth(test_client: httpx.AsyncClient): # Act response = await test_client.get(MESSAGES_URL) # Assert assert response.status_code == 401 async def test_list_messages_requires_admin(test_client: httpx.AsyncClient, standard_user): # Act response = await test_client.get(MESSAGES_URL, headers=standard_user["headers"]) # Assert assert response.status_code == 403 async def test_admin_can_list_messages(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get(MESSAGES_URL, 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 /messages pagination validation === # ============================================================================= async def test_list_messages_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers): # Act — limit=0 violates ge=1 response = await test_client.get( MESSAGES_URL, params={"limit": 0}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_list_messages_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers): # Act — limit=201 violates le=200 response = await test_client.get( MESSAGES_URL, params={"limit": 201}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_admin_can_list_messages_with_time_range(test_client: httpx.AsyncClient, admin_headers): # Arrange params = { "channel_type": DEFAULT_CHANNEL_TYPE, "start_time": ISO_TIME_START, "end_time": ISO_TIME_END, "limit": 10, "offset": 0, } # Act response = await test_client.get(MESSAGES_URL, 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) assert isinstance(payload["data"]["items"], list) # ============================================================================= # === GET /messages/search === # ============================================================================= async def test_search_messages_requires_keyword(test_client: httpx.AsyncClient, admin_headers): # Act — keyword is required response = await test_client.get(f"{MESSAGES_URL}/search", headers=admin_headers) # Assert assert response.status_code == 422, response.text async def test_search_messages_rejects_short_keyword(test_client: httpx.AsyncClient, admin_headers): # Act — keyword shorter than min_length=2 response = await test_client.get( f"{MESSAGES_URL}/search", params={"keyword": "a"}, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_admin_can_search_messages(test_client: httpx.AsyncClient, admin_headers): # Arrange params = { "keyword": "pytest", "channel_type": DEFAULT_CHANNEL_TYPE, "limit": 5, "offset": 0, } # Act response = await test_client.get( f"{MESSAGES_URL}/search", 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) # ============================================================================= # === POST /messages/batch-recall === # ============================================================================= async def test_batch_recall_rejects_empty_message_ids(test_client: httpx.AsyncClient, admin_headers): # Arrange — message_ids min_length=1 body = {"message_ids": []} # Act response = await test_client.post( f"{MESSAGES_URL}/batch-recall", json=body, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_batch_recall_rejects_too_many_message_ids(test_client: httpx.AsyncClient, admin_headers): # Arrange — message_ids max_length=500 body = {"message_ids": [f"msg_{i}" for i in range(501)]} # Act response = await test_client.post( f"{MESSAGES_URL}/batch-recall", json=body, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text async def test_batch_recall_with_non_existent_ids_returns_partial_success( test_client: httpx.AsyncClient, admin_headers ): # Arrange — non-existent message_ids, partial success semantics body = {"message_ids": [NON_EXISTENT_MESSAGE_ID], "reason": "pytest recall"} # Act response = await test_client.post( f"{MESSAGES_URL}/batch-recall", json=body, headers=admin_headers, ) # Assert assert response.status_code == 200, response.text payload = response.json() assert payload["success"] is True # ============================================================================= # === GET /messages/{message_id} === # ============================================================================= async def test_get_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get( f"{MESSAGES_URL}/{NON_EXISTENT_MESSAGE_ID}", headers=admin_headers, ) # Assert assert response.status_code == 404, response.text async def test_get_message_status_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.get( f"{MESSAGES_URL}/{NON_EXISTENT_MESSAGE_ID}/status", headers=admin_headers, ) # Assert assert response.status_code == 404, response.text # ============================================================================= # === Static paths not captured by /{message_id} === # ============================================================================= async def test_search_path_not_captured_by_message_id(test_client: httpx.AsyncClient, admin_headers): # Act — /search must route to search endpoint, not /{message_id} response = await test_client.get( f"{MESSAGES_URL}/search", params={"keyword": "pytest"}, headers=admin_headers, ) # Assert assert response.status_code == 200, response.text async def test_batch_recall_path_not_captured_by_message_id(test_client: httpx.AsyncClient, admin_headers): # Act — /batch-recall must route to batch-recall endpoint response = await test_client.post( f"{MESSAGES_URL}/batch-recall", json={"message_ids": [NON_EXISTENT_MESSAGE_ID]}, headers=admin_headers, ) # Assert assert response.status_code == 200, response.text # ============================================================================= # === POST /{channel_type}/messages (admin send) === # ============================================================================= async def test_send_admin_message_requires_idempotency_key(test_client: httpx.AsyncClient, admin_headers): # Arrange — missing X-Idempotency-Key header body = { "target": "test_target", "content": {"text": "pytest message"}, "conversation_policy": "reuse-or-create", } # Act response = await test_client.post( f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages", json=body, headers=admin_headers, ) # Assert — FastAPI Header(...) 缺失返回 422(RequestValidationError) assert response.status_code == 422, response.text async def test_send_admin_message_with_non_existent_account_returns_404_or_400( test_client: httpx.AsyncClient, admin_headers ): # Arrange — idempotency key present but target references non-existent account. # target 必须为 ``channel_type:account_id:session_id`` 三段格式,否则 # _resolveTarget 返回 channel_type=None 直接进入 per-target failures 分支 # (返回 200),无法触发 NotFoundError(404)路径。 body = { "target": f"{DEFAULT_CHANNEL_TYPE}:nonexistent_account:session123", "content": {"text": "pytest message"}, "conversation_policy": "reuse-or-create", } headers = {**admin_headers, "X-Idempotency-Key": "test-key-send-non-existent"} # Act response = await test_client.post( f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages", json=body, headers=headers, ) # Assert — non-existent target / account yields 404 or 400 assert response.status_code in (400, 404), response.text # ============================================================================= # === POST /{channel_type}/messages/attachments === # ============================================================================= async def test_upload_attachment_requires_file(test_client: httpx.AsyncClient, admin_headers): # Arrange — missing file upload data = {"account_id": "test_account", "purpose": "test"} # Act response = await test_client.post( f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/attachments", data=data, headers=admin_headers, ) # Assert assert response.status_code == 422, response.text # ============================================================================= # === POST /{channel_type}/messages/{message_id}/recall === # ============================================================================= async def test_recall_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Act response = await test_client.post( f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/recall", headers=admin_headers, ) # Assert assert response.status_code == 404, response.text # ============================================================================= # === POST /{channel_type}/messages/{message_id}/resend === # ============================================================================= async def test_resend_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers): # Arrange body = {"target": "test_target", "reason": "pytest resend"} # Act response = await test_client.post( f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/resend", json=body, headers=admin_headers, ) # Assert assert response.status_code == 404, response.text