ForcePilot/backend/test/integration/api/channels/test_session_router.py
Kris df116a069c test: 补全全量接口参数校验与权限控制测试用例
新增并完善多模块接口测试用例,覆盖:
1. 权限校验:认证缺失、普通用户越权访问场景
2. 参数合法性校验:空值、格式错误、边界值、枚举约束
3. 响应字段增强:补充业务字段断言与分页回传校验
4. 异常场景:不存在资源、超限参数、无效格式等400/404/422场景
2026-07-11 21:41:45 +08:00

573 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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