新增并完善多模块接口测试用例,覆盖: 1. 权限校验:认证缺失、普通用户越权访问场景 2. 参数合法性校验:空值、格式错误、边界值、枚举约束 3. 响应字段增强:补充业务字段断言与分页回传校验 4. 异常场景:不存在资源、超限参数、无效格式等400/404/422场景
573 lines
18 KiB
Python
573 lines
18 KiB
Python
"""Integration tests for channels session_router endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from .conftest import (
|
||
BASE_URL,
|
||
NON_EXISTENT_SESSION_ID,
|
||
)
|
||
|
||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||
|
||
SESSIONS_URL = f"{BASE_URL}/sessions"
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for list sessions ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_list_sessions_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(SESSIONS_URL)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_list_sessions_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(SESSIONS_URL, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_admin_can_list_sessions(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(SESSIONS_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 "items" in payload["data"]
|
||
|
||
|
||
# =============================================================================
|
||
# === Pagination validation for list sessions ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_list_sessions_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=0 violates ge=1
|
||
response = await test_client.get(
|
||
SESSIONS_URL,
|
||
params={"limit": 0},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_list_sessions_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=1001 violates le=1000
|
||
response = await test_client.get(
|
||
SESSIONS_URL,
|
||
params={"limit": 1001},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /sessions/events — SSE real-time event stream (BE-14) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_stream_session_events_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{SESSIONS_URL}/events")
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_stream_session_events_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{SESSIONS_URL}/events", headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_admin_can_open_session_events_stream(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — SSE 长连接,使用 stream 上下文仅读取响应头后立即关闭,
|
||
# 避免等待 15s 心跳或无限流。status_code 与 content-type 在响应头中
|
||
# 立即可用,无需消费 body。
|
||
async with test_client.stream("GET", f"{SESSIONS_URL}/events", headers=admin_headers) as response:
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
assert response.headers["content-type"].startswith("text/event-stream")
|
||
|
||
|
||
async def test_events_static_path_not_captured_as_session_id(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — /sessions/events 是静态路径,必须先于 /sessions/{session_id} 声明,
|
||
# 避免 "events" 被捕获为 session_id。若路由顺序错误,此请求会走
|
||
# getSession("events") 并返回 404,而非 SSE 端点的 200。
|
||
async with test_client.stream("GET", f"{SESSIONS_URL}/events", headers=admin_headers) as response:
|
||
# Assert — 命中 SSE 端点返回 200 + text/event-stream,证明静态路径
|
||
# 未被动态参数捕获
|
||
assert response.status_code == 200, response.text
|
||
assert response.headers["content-type"].startswith("text/event-stream")
|
||
|
||
|
||
# =============================================================================
|
||
# === batch-close ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_batch_close_sessions_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"session_ids": [NON_EXISTENT_SESSION_ID]}
|
||
# Act
|
||
response = await test_client.post(f"{SESSIONS_URL}/batch-close", json=body)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_batch_close_sessions_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"session_ids": [NON_EXISTENT_SESSION_ID]}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/batch-close",
|
||
json=body,
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_batch_close_sessions_rejects_empty_body(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — session_ids 与 filter 同时为空,dispatch 校验返回 400
|
||
body = {"session_ids": None, "filter": None}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/batch-close",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code in (400, 422), response.text
|
||
|
||
|
||
async def test_batch_close_sessions_with_non_existent_ids_returns_success(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — 不存在的 session_ids,部分成功语义下应返回 200
|
||
body = {"session_ids": [NON_EXISTENT_SESSION_ID], "max_count": 100, "reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/batch-close",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
|
||
|
||
async def test_batch_close_sessions_rejects_max_count_zero(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — max_count=0 低于下限 ge=1
|
||
body = {"session_ids": [NON_EXISTENT_SESSION_ID], "max_count": 0}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/batch-close",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_batch_close_sessions_rejects_max_count_over_max(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — max_count=1001 超过上限 le=1000
|
||
body = {"session_ids": [NON_EXISTENT_SESSION_ID], "max_count": 1001}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/batch-close",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === get session detail ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_session_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}")
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_get_session_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_get_session_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === merge session ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_merge_session_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"source_session_id": "conv_xxx", "reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/merge",
|
||
json=body,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_merge_session_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"source_session_id": "conv_xxx", "reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/merge",
|
||
json=body,
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_merge_session_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"source_session_id": "conv_xxx", "reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/merge",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
async def test_merge_session_rejects_empty_source_session_id(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — source_session_id="" 违反 min_length=1
|
||
body = {"source_session_id": "", "reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/merge",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === transfer session owner ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_transfer_session_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"new_owner_id": "owner_xxx"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/transfer",
|
||
json=body,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_transfer_session_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"new_owner_id": "owner_xxx"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/transfer",
|
||
json=body,
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_transfer_session_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"new_owner_id": "owner_xxx"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/transfer",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
async def test_transfer_session_rejects_empty_new_owner_id(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — new_owner_id="" 违反 min_length=1
|
||
body = {"new_owner_id": ""}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/transfer",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === close session ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_close_session_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/close",
|
||
json=body,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_close_session_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/close",
|
||
json=body,
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_close_session_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"reason": "pytest"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/close",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === list session messages ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_list_session_messages_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/messages")
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_list_session_messages_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/messages",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_list_session_messages_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/messages",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
async def test_list_session_messages_rejects_invalid_limit(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=0 violates ge=1
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/messages",
|
||
params={"limit": 0},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert — session 不存在或参数非法,至少不应是 200
|
||
assert response.status_code in (404, 422), response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === session stats ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_session_stats_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/stats")
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_get_session_stats_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/stats",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_get_session_stats_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/stats",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === bind-user / unbind-user / identity (P3) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_bind_session_user_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"user_uid": "non_existent_user_uid"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/bind-user",
|
||
json=body,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_bind_session_user_forbids_standard_user(
|
||
test_client: httpx.AsyncClient, standard_user
|
||
):
|
||
# Arrange
|
||
body = {"user_uid": "non_existent_user_uid"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/bind-user",
|
||
json=body,
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_bind_session_user_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"user_uid": "non_existent_user_uid"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/bind-user",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
async def test_unbind_session_user_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.delete(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/bind-user",
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_unbind_session_user_forbids_standard_user(
|
||
test_client: httpx.AsyncClient, standard_user
|
||
):
|
||
# Act
|
||
response = await test_client.delete(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/bind-user",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_unbind_session_user_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.delete(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/bind-user",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
async def test_get_session_identity_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/identity",
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_get_session_identity_forbids_standard_user(
|
||
test_client: httpx.AsyncClient, standard_user
|
||
):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/identity",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_get_session_identity_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{SESSIONS_URL}/{NON_EXISTENT_SESSION_ID}/identity",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|