ForcePilot/backend/test/integration/api/channels/test_account_router.py
Kris f9f08221fc chore: 整理代码风格与优化细节
本次提交包含多类代码优化:
1. 修复多处单行代码换行格式,统一代码排版
2. 为外部系统模块新增快捷菜单配置
3. 优化前端API请求参数命名一致性
4. 补充媒体下载超限错误码与领域异常类
5. 完善仓储层更新逻辑,支持显式清空字段
6. 优化部分测试用例与工具函数代码结构
7. 为微信插件白名单缓存增加过期时间
2026-07-14 15:13:29 +08:00

633 lines
22 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 account_router endpoints."""
from __future__ import annotations
import httpx
import pytest
from .conftest import (
BASE_URL,
DEFAULT_CHANNEL_TYPE,
NON_EXISTENT_ACCOUNT_ID,
)
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
ACCOUNTS_URL = f"{BASE_URL}/accounts"
def _make_create_account_body(display_name: str = "pytest_account") -> dict:
"""Build a minimal CreateAccountRequest body."""
return {
"display_name": display_name,
"raw_config": {"app_id": "pytest_app", "app_secret": "pytest_secret"},
}
# =============================================================================
# === Auth three-tier for list accounts ===
# =============================================================================
async def test_list_accounts_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(ACCOUNTS_URL)
# Assert
assert response.status_code == 401, response.text
async def test_list_accounts_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(ACCOUNTS_URL, headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_admin_can_list_accounts(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(ACCOUNTS_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 "accounts" in payload["data"]
assert isinstance(payload["data"]["accounts"], list)
# =============================================================================
# === Pagination validation for list accounts ===
# =============================================================================
async def test_list_accounts_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
# Act — limit=0 violates ge=1
response = await test_client.get(
ACCOUNTS_URL,
params={"limit": 0},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_list_accounts_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
# Act — limit=1001 violates le=1000
response = await test_client.get(
ACCOUNTS_URL,
params={"limit": 1001},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === batch enable / disable ===
# =============================================================================
async def test_batch_enable_accounts_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"account_ids": [NON_EXISTENT_ACCOUNT_ID]}
# Act
response = await test_client.post(f"{ACCOUNTS_URL}/batch/enable", json=body)
# Assert
assert response.status_code == 401, response.text
async def test_batch_enable_accounts_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"account_ids": [NON_EXISTENT_ACCOUNT_ID]}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/enable",
json=body,
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403, response.text
async def test_batch_enable_accounts_rejects_empty_body(test_client: httpx.AsyncClient, admin_headers):
# Arrange — account_ids 与 filter 同时为空
body = {"account_ids": None, "filter": None}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/enable",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code in (400, 422), response.text
async def test_batch_disable_accounts_rejects_empty_body(test_client: httpx.AsyncClient, admin_headers):
# Arrange
body = {"account_ids": None, "filter": None}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/disable",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code in (400, 422), response.text
async def test_batch_enable_accounts_rejects_ids_without_channel_type(
test_client: httpx.AsyncClient,
admin_headers,
):
# Arrange — account_ids 模式下 channel_type 必填account_id 仅在
# (channel_type, account_id) 复合键下唯一,需显式限定渠道才能定位账户)
body = {"account_ids": [NON_EXISTENT_ACCOUNT_ID], "channel_type": None}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/enable",
json=body,
headers=admin_headers,
)
# Assert — 业务校验层拦截,返回 400 VALIDATION_ERROR
assert response.status_code == 400, response.text
async def test_batch_disable_accounts_rejects_ids_without_channel_type(
test_client: httpx.AsyncClient,
admin_headers,
):
# Arrange
body = {"account_ids": [NON_EXISTENT_ACCOUNT_ID], "channel_type": None}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/disable",
json=body,
headers=admin_headers,
)
# Assert — 业务校验层拦截,返回 400 VALIDATION_ERROR
assert response.status_code == 400, response.text
async def test_batch_enable_accounts_with_channel_type_passes_schema(
test_client: httpx.AsyncClient,
admin_headers,
):
# Arrange — account_ids + channel_type 通过 schema 校验,到达 handler
# 后因账户不存在返回 404证明 P0 修复channel_type 透传至 handler
body = {
"account_ids": [NON_EXISTENT_ACCOUNT_ID],
"channel_type": DEFAULT_CHANNEL_TYPE,
}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/enable",
json=body,
headers=admin_headers,
)
# Assert — 不再是 422 schema 错误,而是 404账户不存在或 200
# (批量操作部分失败语义),关键是不被 schema 拦截
assert response.status_code != 422, response.text
async def test_batch_disable_accounts_with_channel_type_passes_schema(
test_client: httpx.AsyncClient,
admin_headers,
):
# Arrange
body = {
"account_ids": [NON_EXISTENT_ACCOUNT_ID],
"channel_type": DEFAULT_CHANNEL_TYPE,
}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/disable",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code != 422, response.text
async def test_batch_disable_accounts_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"account_ids": [NON_EXISTENT_ACCOUNT_ID], "channel_type": DEFAULT_CHANNEL_TYPE}
# Act
response = await test_client.post(f"{ACCOUNTS_URL}/batch/disable", json=body)
# Assert
assert response.status_code == 401, response.text
async def test_batch_disable_accounts_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"account_ids": [NON_EXISTENT_ACCOUNT_ID], "channel_type": DEFAULT_CHANNEL_TYPE}
# Act
response = await test_client.post(
f"{ACCOUNTS_URL}/batch/disable",
json=body,
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403, response.text
# =============================================================================
# === create account under channel_type ===
# =============================================================================
async def test_create_account_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = _make_create_account_body()
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts",
json=body,
)
# Assert
assert response.status_code == 401, response.text
async def test_create_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = _make_create_account_body()
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts",
json=body,
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403, response.text
async def test_create_account_invalid_channel_type_returns_422(test_client: httpx.AsyncClient, admin_headers):
# Arrange — 适配器未注册时返回 422
body = _make_create_account_body()
# Act
response = await test_client.post(
f"{BASE_URL}/invalid_channel/accounts",
json=body,
headers=admin_headers,
)
# Assert — 不应是 401/403适配器不存在应返回 422
assert response.status_code == 422, response.text
async def test_create_account_with_invalid_body_returns_not_4xx(test_client: httpx.AsyncClient, admin_headers):
# Arrange — display_name 过长触发校验失败
body = {
"display_name": "x" * 200,
"raw_config": {"app_id": "pytest_app"},
}
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === get / update / delete / state / runtime / rotate / test / export / clone ===
# =============================================================================
async def test_get_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_update_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Arrange
body = {"display_name": "updated_name"}
# Act
response = await test_client.put(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_update_account_rejects_empty_body(test_client: httpx.AsyncClient, admin_headers):
# Arrange — display_name 与 raw_config 同时为空dispatch 校验返回 400
body = {"display_name": None, "raw_config": None}
# Act
response = await test_client.put(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code in (400, 422, 404), response.text
async def test_delete_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.delete(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_enable_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/enable",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_disable_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/disable",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_recover_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/recover",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_get_runtime_status_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/runtime-status",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_rotate_credentials_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/rotate-credentials",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_test_connection_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/test-connection",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === export account (with include_secrets authorization) ===
# =============================================================================
async def test_export_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/export",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_export_account_with_secrets_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Arrange — admin_headers 为 superadmininclude_secrets=True 的 superadmin
# 校验在 dispatch 阶段通过;账户不存在 → 404。
# 注include_secrets=True 拒绝非 superadmin 管理员的场景无法用 admin_headers
# 验证admin_headers 本身即 superadminstandard_user 会被 get_admin_user
# 守门拦截403无法抵达 include_secrets 校验。
# Act
response = await test_client.get(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/export",
params={"include_secrets": True},
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === clone account ===
# =============================================================================
async def test_clone_account_non_existent_returns_404(test_client: httpx.AsyncClient, admin_headers):
# Arrange
body = {"new_display_name": "cloned_account", "include_credentials": False}
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/clone",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
async def test_clone_account_rejects_invalid_body(test_client: httpx.AsyncClient, admin_headers):
# Arrange — new_display_name 过长触发校验失败
body = {"new_display_name": "x" * 200, "include_credentials": False}
# Act
response = await test_client.post(
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}/clone",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === Auth matrix for individual account operations ===
# =============================================================================
ACCOUNT_URL = f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/accounts/{NON_EXISTENT_ACCOUNT_ID}"
async def test_get_account_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(ACCOUNT_URL)
# Assert
assert response.status_code == 401, response.text
async def test_get_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(ACCOUNT_URL, headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_update_account_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"display_name": "updated_name"}
# Act
response = await test_client.put(ACCOUNT_URL, json=body)
# Assert
assert response.status_code == 401, response.text
async def test_update_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"display_name": "updated_name"}
# Act
response = await test_client.put(ACCOUNT_URL, json=body, headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_delete_account_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.delete(ACCOUNT_URL)
# Assert
assert response.status_code == 401, response.text
async def test_delete_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.delete(ACCOUNT_URL, headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_enable_account_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/enable")
# Assert
assert response.status_code == 401, response.text
async def test_enable_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/enable", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_disable_account_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/disable")
# Assert
assert response.status_code == 401, response.text
async def test_disable_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/disable", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_recover_account_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/recover")
# Assert
assert response.status_code == 401, response.text
async def test_recover_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/recover", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_get_runtime_status_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{ACCOUNT_URL}/runtime-status")
# Assert
assert response.status_code == 401, response.text
async def test_get_runtime_status_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{ACCOUNT_URL}/runtime-status", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_rotate_credentials_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/rotate-credentials")
# Assert
assert response.status_code == 401, response.text
async def test_rotate_credentials_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/rotate-credentials", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_test_connection_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/test-connection")
# Assert
assert response.status_code == 401, response.text
async def test_test_connection_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(f"{ACCOUNT_URL}/test-connection", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_export_account_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{ACCOUNT_URL}/export")
# Assert
assert response.status_code == 401, response.text
async def test_export_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{ACCOUNT_URL}/export", headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text
async def test_clone_account_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"new_display_name": "cloned_account", "include_credentials": False}
# Act
response = await test_client.post(f"{ACCOUNT_URL}/clone", json=body)
# Assert
assert response.status_code == 401, response.text
async def test_clone_account_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"new_display_name": "cloned_account", "include_credentials": False}
# Act
response = await test_client.post(f"{ACCOUNT_URL}/clone", json=body, headers=standard_user["headers"])
# Assert
assert response.status_code == 403, response.text