本次提交包含多类代码优化: 1. 修复多处单行代码换行格式,统一代码排版 2. 为外部系统模块新增快捷菜单配置 3. 优化前端API请求参数命名一致性 4. 补充媒体下载超限错误码与领域异常类 5. 完善仓储层更新逻辑,支持显式清空字段 6. 优化部分测试用例与工具函数代码结构 7. 为微信插件白名单缓存增加过期时间
416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""Integration tests for channels config_router endpoints.
|
||
|
||
覆盖配置管理域(CFG-02 / CFG-READ-01 / CFG-READ-MANY / CFG-UPDATE-01 /
|
||
CFG-ROLL-01 / CFG-HIST-01 / CFG-EXPORT / CFG-IMPORT / CFG-BATCH-UPDATE)的
|
||
鉴权矩阵、请求体校验与关键错误路径。所有动态 ``/{key}`` 路径用
|
||
``NON_EXISTENT_CONFIG_KEY`` 触发 400(VALIDATION_ERROR),静态路径
|
||
(schema / values / export / import / batch)通过提前声明规避动态捕获。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from .conftest import BASE_URL, NON_EXISTENT_CONFIG_KEY
|
||
|
||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||
|
||
CONFIG_URL = f"{BASE_URL}/config"
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for GET /config/schema ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_schema_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/schema")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_schema_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/schema", headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_admin_can_get_config_schema(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/schema", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], (dict, list))
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/export — superadmin only ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_export_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/export")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_export_config_requires_superadmin_for_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/export", headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_superadmin_can_export_config(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — admin_headers 实际为 superadmin(见 conftest.py + test_auth_router
|
||
# 中 _require_superadmin 校验),通过 get_superadmin_user 守门后导出成功。
|
||
# standard_user 被拒场景由 test_export_config_requires_superadmin_for_standard_user 覆盖。
|
||
response = await test_client.get(f"{CONFIG_URL}/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)
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /config/import — superadmin only ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_import_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"config_data": {"key": "value"}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_import_config_rejects_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"config_data": {"key": "value"}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_superadmin_can_import_config_dry_run(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — admin_headers 为 superadmin,通过 get_superadmin_user 守门;
|
||
# dry_run=True 仅校验统计不写入,非法 key 记入 failed_count,整体返回 200。
|
||
body = {"config_data": {"key": "value"}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", 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_import_config_rejects_empty_config_data(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — config_data min_length=1,空字典违反约束
|
||
body = {"config_data": {}, "dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_import_config_rejects_missing_config_data(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — config_data 必填字段缺失
|
||
body = {"dry_run": True}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/import", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === PUT /config/batch — validation ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_batch_update_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"updates": [{"key": "k", "value": "v"}]}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_batch_update_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"updates": [{"key": "k", "value": "v"}]}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json=body, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_batch_update_config_rejects_empty_updates(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — updates 为空违反 min_length=1
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json={"updates": []}, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_batch_update_config_rejects_too_many_updates(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — 51 条超过 max_length=50
|
||
body = {"updates": [{"key": f"k{i}", "value": "v"} for i in range(51)]}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/batch", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/values — batch read (CFG-READ-MANY) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_values_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params={"keys": ["k"]})
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_values_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params={"keys": ["k"]}, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_admin_can_get_config_values(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — 使用 CONFIG_SCHEMA 中已声明 key,整体返回 200
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params={"keys": ["dm_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, list))
|
||
|
||
|
||
async def test_get_config_values_rejects_empty_keys(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — keys min_length=1,空列表违反约束
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params={"keys": []}, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_get_config_values_rejects_too_many_keys(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — 51 个 key 超过 max_length=50(BATCH_UPDATE_LIMIT)
|
||
params = [("keys", f"k{i}") for i in range(51)]
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/values", params=params, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_get_config_values_rejects_missing_keys_param(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — keys 为必填 Query 参数,缺失触发 422
|
||
response = await test_client.get(f"{CONFIG_URL}/values", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/{key} — non-existent key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_get_config_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
# =============================================================================
|
||
# === PUT /config/{key} — non-existent key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_update_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"value": "new_value"}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_update_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"value": "new_value"}
|
||
# Act
|
||
response = await test_client.put(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_update_config_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"value": "new_value"}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
async def test_update_config_rejects_missing_value(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — value 必填字段缺失
|
||
body = {"scope": "global"}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_update_config_rejects_zero_expected_version(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — expected_version ge=1,0 违反约束
|
||
body = {"value": "new_value", "expected_version": 0}
|
||
# Act
|
||
response = await test_client.put(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}", json=body, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /config/{key}/rollback — non-existent key + validation ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_rollback_config_requires_auth(test_client: httpx.AsyncClient):
|
||
# Arrange
|
||
body = {"target_version": 1}
|
||
# Act
|
||
response = await test_client.post(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback", json=body)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_rollback_config_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange
|
||
body = {"target_version": 1}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback", json=body, headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_rollback_config_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
body = {"target_version": 1}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback", json=body, headers=admin_headers
|
||
)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
async def test_rollback_config_rejects_zero_target_version(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — target_version=0 违反 ge=1
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback",
|
||
json={"target_version": 0},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_rollback_config_rejects_missing_target_version(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — target_version 必填字段缺失
|
||
response = await test_client.post(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/rollback",
|
||
json={},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /config/{key}/history — non-existent key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_config_history_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/history")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_config_history_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/history", headers=standard_user["headers"]
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_get_config_history_returns_400_for_non_existent_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(f"{CONFIG_URL}/{NON_EXISTENT_CONFIG_KEY}/history", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
payload = response.json()
|
||
assert payload["error"]["code"] == "VALIDATION_ERROR"
|
||
|
||
|
||
# =============================================================================
|
||
# === Static path vs dynamic — /config/schema not captured as key ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_schema_static_path_not_captured_as_key(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — 静态路径 /config/schema 应被 get_config_schema 捕获,而非 get_config
|
||
response = await test_client.get(f"{CONFIG_URL}/schema", headers=admin_headers)
|
||
# Assert — schema 端点返回 200,而非 404(若被当作 key="schema" 则 404)
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|