项目文件夹

文件
2026-07-13 12:34:57 +08:00

699 行
25 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""
Web 层特征测试(characterization tests
固化 myboot web 层的「当前实际行为」,作为后续重构的兼容性闸门。
这些测试不评判行为是否正确——可疑之处照实断言并以注释标记,
重构时若行为有意变更,应同步更新对应测试。
覆盖范围:
- myboot/web/decorators.py 路由装饰器元数据
- myboot/core/decorators.py 路由装饰器与 @rest_controllerauto_configuration 实际消费的那套)
- myboot/core/auto_configuration.py 的 AST 扫描 route_type 前缀(issue #8 回归)与路由注册分支
- myboot/web/models.py BaseResponse 系列模型
- myboot/web/response.py ResponseWrapper / ApiResponse
- myboot/web/exceptions.py HTTP 异常体系
- myboot/exceptions.py 与 myboot/web/exceptions.py 的同名异常类二义性
"""
import asyncio
import textwrap
from datetime import datetime
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from myboot.core.auto_configuration import (
AutoConfigurationError,
AutoConfigurationManager,
)
from myboot.core.decorators import delete as core_delete
from myboot.core.decorators import get as core_get
from myboot.core.decorators import patch as core_patch
from myboot.core.decorators import post as core_post
from myboot.core.decorators import put as core_put
from myboot.core.decorators import rest_controller
from myboot.core.decorators import route as core_route
from myboot.web.decorators import (
async_route,
delete,
get,
patch,
post,
put,
route,
)
from myboot.web.exceptions import (
HTTP_STATUS_EXCEPTIONS,
BadRequestError,
ConflictError,
ForbiddenError,
HTTPException,
InternalServerError,
MethodNotAllowedError,
NotFoundError,
ServiceUnavailableError,
TooManyRequestsError,
UnauthorizedError,
UnprocessableEntityError,
create_http_exception,
)
from myboot.web.models import (
BaseResponse,
CreatedResponse,
DeletedResponse,
ErrorResponse,
SuccessResponse,
UpdatedResponse,
error_response,
success_response,
)
from myboot.web.response import ApiResponse, ResponseWrapper
def _make_manager() -> AutoConfigurationManager:
"""每个测试创建独立的 manager,避免共享 discovered_components 状态。
AutoConfigurationManager 的组件元数据是实例级状态(非模块级全局
registry),use_cache=False 确保不读写磁盘缓存。
"""
return AutoConfigurationManager(app_root=".", use_cache=False)
# ---------------------------------------------------------------------------
# 1. myboot/web/decorators.py 路由装饰器元数据
# ---------------------------------------------------------------------------
class TestWebRouteDecorators:
"""myboot.web.decorators 装饰器把元数据写入 func._route_metadata"""
def test_get_decorator_metadata(self):
@get("/users", tags=["user"])
def handler():
"""获取用户列表"""
return []
meta = handler._route_metadata
assert meta["path"] == "/users"
assert meta["methods"] == ["GET"]
assert meta["status_code"] == 200
assert meta["tags"] == ["user"]
# summary/description 未显式提供时回退到 __doc__
assert meta["summary"] == "获取用户列表"
assert meta["description"] == "获取用户列表"
assert meta["response_model"] is None
def test_default_status_codes_per_method(self):
# 当前行为:post 默认 201,delete 默认 204,get/put/patch 默认 200
cases = [
(get, ["GET"], 200),
(post, ["POST"], 201),
(put, ["PUT"], 200),
(delete, ["DELETE"], 204),
(patch, ["PATCH"], 200),
]
for decorator, methods, status_code in cases:
@decorator("/x")
def handler():
return None
meta = handler._route_metadata
assert meta["methods"] == methods
assert meta["status_code"] == status_code
def test_route_decorator_defaults_and_extra_kwargs(self):
@route("/multi", methods=["GET", "POST"], deprecated=True)
def handler():
return None
meta = handler._route_metadata
assert meta["methods"] == ["GET", "POST"]
# 额外 kwargs 被直接展开合并进元数据字典
assert meta["deprecated"] is True
@route("/default")
def handler2():
return None
# methods 缺省为 ["GET"]
assert handler2._route_metadata["methods"] == ["GET"]
def test_summary_falls_back_to_empty_string_without_docstring(self):
@get("/nodoc")
def handler():
return None
meta = handler._route_metadata
assert meta["summary"] == ""
assert meta["description"] == ""
assert meta["tags"] == []
def test_decorator_returns_wrapper_that_still_works(self):
# 当前行为:装饰器返回 @wraps 包装函数(非原函数),
# functools.wraps 复制 __dict__,因此包装函数上也带 _route_metadata
@get("/calc")
def add(a, b):
return a + b
assert add(1, 2) == 3
assert add.__name__ == "add"
assert add._route_metadata["path"] == "/calc"
@pytest.mark.asyncio
async def test_async_route_metadata_and_await(self):
@async_route("/async", methods=["POST"])
async def handler(value):
return value * 2
meta = handler._route_metadata
assert meta["path"] == "/async"
assert meta["methods"] == ["POST"]
# async_route 额外写入 'async': True 标记
assert meta["async"] is True
assert await handler(21) == 42
assert asyncio.iscoroutinefunction(handler)
# ---------------------------------------------------------------------------
# 1b. myboot/core/decorators.py 路由装饰器(auto_configuration 消费的元数据)
# ---------------------------------------------------------------------------
class TestCoreRouteDecorators:
"""myboot.core.decorators 装饰器把元数据写入 func.__myboot_route__"""
def test_core_get_sets_myboot_route_and_returns_original(self):
def raw(a):
return a
decorated = core_get("/ping", tags=["t"])(raw)
# 当前行为:core 装饰器不包装,直接返回原函数对象
assert decorated is raw
assert raw.__myboot_route__ == {
"path": "/ping",
"methods": ["GET"],
"kwargs": {"tags": ["t"]},
}
def test_core_http_method_decorators(self):
cases = [
(core_get, ["GET"]),
(core_post, ["POST"]),
(core_put, ["PUT"]),
(core_delete, ["DELETE"]),
(core_patch, ["PATCH"]),
]
for decorator, methods in cases:
@decorator("/x")
def handler():
return None
assert handler.__myboot_route__["methods"] == methods
def test_core_route_default_methods(self):
@core_route("/r")
def handler():
return None
assert handler.__myboot_route__["methods"] == ["GET"]
assert handler.__myboot_route__["kwargs"] == {}
def test_rest_controller_sets_class_attribute(self):
@rest_controller("/api/items/", version=1)
class ItemController:
pass
config = ItemController.__myboot_rest_controller__
# base_path 尾部 / 被 rstrip 去掉
assert config["base_path"] == "/api/items"
assert config["kwargs"] == {"version": 1}
# ---------------------------------------------------------------------------
# 2. route_type 前缀格式(issue #8 回归)与路由注册分支
# ---------------------------------------------------------------------------
class TestRouteTypeDiscovery:
"""AST 扫描产生的 type 字段格式:f'function_{dec}' / f'class_{dec}'"""
def test_ast_scan_route_type_prefixes(self, tmp_path):
# issue #8 回归:发现阶段的 type 必须以 function_ / class_ 开头
source = textwrap.dedent(
"""
from myboot.core.decorators import rest_controller, get, route
@rest_controller('/api/x')
class XController:
pass
@get('/ping')
def ping():
return 'pong'
@route('/r')
def plain_route():
return 'r'
"""
)
mod_file = tmp_path / "scanned_mod.py"
mod_file.write_text(source, encoding="utf-8")
manager = _make_manager()
manager._scan_file_ast(mod_file, "scanned_mod")
controllers = manager._component_metadata["rest_controllers"]
assert controllers == [
{
"module": "scanned_mod",
"class_name": "XController",
"type": "class_rest_controller",
}
]
routes = manager._component_metadata["routes"]
types = {item["func_name"]: item["type"] for item in routes}
assert types == {
"ping": "function_get",
"plain_route": "function_route",
}
for item in routes:
assert item["type"].startswith("function_") or item["type"].startswith(
"class_"
)
def test_function_route_type_is_registered(self):
@core_route("/fr", methods=["GET"])
def fr():
return "x"
calls = []
class FakeApp:
def add_route(self, **kwargs):
calls.append(kwargs)
manager = _make_manager()
manager.discovered_components["routes"] = [
{"type": "function_route", "function": fr, "module": "m"}
]
manager._auto_register_routes(FakeApp())
assert len(calls) == 1
assert calls[0]["path"] == "/fr"
assert calls[0]["handler"] is fr
assert calls[0]["methods"] == ["GET"]
def test_function_get_route_type_registers(self):
# 0.2.0 修复(issue #8 残留):_auto_register_routes 现以前缀匹配
# route_type,AST 扫描产生的 'function_get'/'function_post' 等
# 模块级函数路由不再被静默跳过。
@core_get("/fn")
def fn():
return "x"
calls = []
class FakeApp:
def add_route(self, **kwargs):
calls.append(kwargs)
manager = _make_manager()
manager.discovered_components["routes"] = [
{"type": "function_get", "function": fn, "module": "m"}
]
manager._auto_register_routes(FakeApp())
assert len(calls) == 1
assert calls[0]["path"] == "/fn"
assert calls[0]["methods"] == ["GET"]
def test_class_route_type_registers_methods_without_error(self):
# 0.2.0 修复:class 类型条目没有 'function' 键,旧版收尾 debug 日志
# 访问 route_info['function'] 抛 KeyError 导致注册必然失败;
# 现在方法路由注册成功且不再抛 AutoConfigurationError。
@core_route("/cls")
class ClassController:
@core_get("/m")
def m(self):
return 1
calls = []
class FakeApp:
def add_route(self, **kwargs):
calls.append(kwargs)
manager = _make_manager()
manager.discovered_components["routes"] = [
{"type": "class_route", "class": ClassController, "module": "m"}
]
manager._auto_register_routes(FakeApp())
assert len(calls) == 1
assert calls[0]["path"] == "/m"
class TestRestControllerEndToEnd:
"""用最小 FastAPI app + TestClient 验证 @rest_controller 注册后端点可访问
myboot 的注册逻辑只依赖 app.add_route 和 app.di_container,
用桩对象把 add_route 转接到 FastAPI 即可隔离全局 Application 状态。
"""
def _build_client(self):
@rest_controller("/api/items")
class ItemController:
def __init__(self):
pass
@core_get("/list")
def list_items(self):
return {"items": [1, 2]}
@core_get("//absolute")
def absolute(self):
return {"abs": True}
@core_get("relative")
def rel(self):
return {"rel": True}
fastapi_app = FastAPI()
class FakeApp:
def __init__(self):
self.di_container = SimpleNamespace(has_service=lambda name: False)
self.components = {}
self.clients = {}
def add_route(self, path, handler, methods, **kwargs):
fastapi_app.add_api_route(path, handler, methods=methods)
manager = _make_manager()
manager.discovered_components["rest_controllers"] = [
{
"class": ItemController,
"module": "tests.unit.test_web",
"type": "class_rest_controller",
}
]
manager._auto_register_rest_controllers(FakeApp())
return TestClient(fastapi_app)
def test_method_path_appended_to_base_path(self):
client = self._build_client()
resp = client.get("/api/items/list")
assert resp.status_code == 200
assert resp.json() == {"items": [1, 2]}
def test_double_slash_prefix_means_absolute_path(self):
# 路径合并规则:方法路径以 // 开头时作为绝对路径(去掉一个 /),
# 不拼接 base_path
client = self._build_client()
resp = client.get("/absolute")
assert resp.status_code == 200
assert resp.json() == {"abs": True}
# base_path 下不存在该路由
assert client.get("/api/items/absolute").status_code == 404
def test_relative_path_appended_with_slash(self):
client = self._build_client()
resp = client.get("/api/items/relative")
assert resp.status_code == 200
assert resp.json() == {"rel": True}
# ---------------------------------------------------------------------------
# 3. BaseResponse 响应结构
# ---------------------------------------------------------------------------
class TestBaseResponse:
def test_defaults(self):
r = BaseResponse()
assert r.success is True
assert r.message == "操作成功"
assert r.data is None
assert isinstance(r.timestamp, datetime)
def test_model_dump_field_names(self):
dumped = BaseResponse().model_dump()
# 字段名固定为这 4 个
assert sorted(dumped.keys()) == ["data", "message", "success", "timestamp"]
assert dumped["success"] is True
assert dumped["message"] == "操作成功"
# 当前行为:data=None 也会出现在 model_dump 结果中
assert dumped["data"] is None
assert isinstance(dumped["timestamp"], datetime)
def test_error_response_defaults(self):
r = ErrorResponse()
assert r.success is False
assert r.message == "操作失败"
assert r.error_code is None
assert r.details is None
dumped = r.model_dump()
assert sorted(dumped.keys()) == [
"data",
"details",
"error_code",
"message",
"success",
"timestamp",
]
def test_crud_response_defaults(self):
assert SuccessResponse().message == "操作成功"
created = CreatedResponse()
assert created.message == "创建成功"
# 当前行为:status_code 是模型字段,会出现在序列化结果里
assert created.status_code == 201
assert created.model_dump()["status_code"] == 201
assert UpdatedResponse().status_code == 200
assert UpdatedResponse().message == "更新成功"
deleted = DeletedResponse()
assert deleted.status_code == 204
assert deleted.message == "删除成功"
def test_helper_functions(self):
ok = success_response(data={"id": 1}, message="done")
assert isinstance(ok, SuccessResponse)
assert ok.data == {"id": 1}
assert ok.message == "done"
err = error_response(message="bad", error_code="E1", details={"k": "v"})
assert isinstance(err, ErrorResponse)
assert err.success is False
assert err.error_code == "E1"
assert err.details == {"k": "v"}
# ---------------------------------------------------------------------------
# 4. ResponseWrapper 包装行为
# ---------------------------------------------------------------------------
class TestResponseWrapper:
def test_success_minimal_omits_none_fields(self):
# message/data 为 None 时根本不出现在返回字典中
assert ResponseWrapper.success() == {"success": True, "code": 200}
def test_success_with_data_and_message(self):
result = ResponseWrapper.success(data=[1], message="ok", code=200)
assert result == {"success": True, "code": 200, "message": "ok", "data": [1]}
def test_error_defaults_to_500(self):
assert ResponseWrapper.error() == {"success": False, "code": 500}
result = ResponseWrapper.error(message="boom", code=400, data={"f": 1})
assert result == {
"success": False,
"code": 400,
"message": "boom",
"data": {"f": 1},
}
def test_created_updated_deleted_no_content_codes(self):
assert ResponseWrapper.created(data={"id": 1}) == {
"success": True,
"code": 201,
"data": {"id": 1},
}
assert ResponseWrapper.updated() == {"success": True, "code": 200}
# 可疑现状:deleted() 的 code 是 200(文档注释也写明 200),
# 而 DeletedResponse 模型的 status_code 默认 204,两处不一致
assert ResponseWrapper.deleted() == {"success": True, "code": 200}
# no_content() 标记 204 但仍返回非空响应体字典
assert ResponseWrapper.no_content() == {"success": True, "code": 204}
def test_pagination_structure(self):
result = ResponseWrapper.pagination(data=[1, 2], total=5, page=1, size=2)
assert result["success"] is True
assert result["code"] == 200
# 分页字段为 camelCasetotalPages/hasNext/hasPrev),
# 与 PaginationResponse 模型的 snake_casetotal_pages 等)不一致
assert result["data"] == {
"list": [1, 2],
"pagination": {
"total": 5,
"page": 1,
"size": 2,
"totalPages": 3,
"hasNext": True,
"hasPrev": False,
},
}
def test_pagination_size_zero_total_pages_zero(self):
result = ResponseWrapper.pagination(data=[], total=0, page=1, size=0)
assert result["data"]["pagination"]["totalPages"] == 0
def test_wrap_plain_data(self):
assert ResponseWrapper.wrap({"id": 1}) == {
"success": True,
"code": 200,
"data": {"id": 1},
}
assert ResponseWrapper.wrap("text", message="m", code=201) == {
"success": True,
"code": 201,
"message": "m",
"data": "text",
}
def test_wrap_passthrough_when_keys_present(self):
# 当前行为:只要 dict 同时含 "success" 和 "code" 键就原样透传,
# 即使是手工构造、值类型任意的字典也不会被再包装
already = {"success": False, "code": 500, "message": "x"}
assert ResponseWrapper.wrap(already) is already
fake = {"success": "yes", "code": "200", "extra": 1}
assert ResponseWrapper.wrap(fake) is fake
def test_api_response_model_dump_includes_none(self):
# ConfigDict(exclude_none=True) 在 FastAPI JSON 响应中生效;
# model_dump() 默认仍包含 None 字段,需显式 exclude_none=True 才会排除
r = ApiResponse(success=True, code=200)
assert r.model_dump() == {
"success": True,
"code": 200,
"message": None,
"data": None,
}
assert r.model_dump(exclude_none=True) == {"success": True, "code": 200}
# ---------------------------------------------------------------------------
# 5. HTTP 异常体系
# ---------------------------------------------------------------------------
class TestHTTPExceptions:
def test_http_status_exceptions_mapping_complete(self):
# 10 个状态码齐全,映射到对应异常类
assert HTTP_STATUS_EXCEPTIONS == {
400: BadRequestError,
401: UnauthorizedError,
403: ForbiddenError,
404: NotFoundError,
405: MethodNotAllowedError,
409: ConflictError,
422: UnprocessableEntityError,
429: TooManyRequestsError,
500: InternalServerError,
503: ServiceUnavailableError,
}
assert len(HTTP_STATUS_EXCEPTIONS) == 10
def test_each_exception_default_status_code_and_message(self):
expected = {
BadRequestError: (400, "错误请求"),
UnauthorizedError: (401, "未授权"),
ForbiddenError: (403, "禁止访问"),
NotFoundError: (404, "未找到"),
MethodNotAllowedError: (405, "方法不允许"),
ConflictError: (409, "资源冲突"),
UnprocessableEntityError: (422, "无法处理的实体"),
TooManyRequestsError: (429, "请求过多"),
InternalServerError: (500, "内部服务器错误"),
ServiceUnavailableError: (503, "服务不可用"),
}
for exc_class, (status_code, message) in expected.items():
exc = exc_class()
assert exc.status_code == status_code
assert exc.message == message
# details 缺省为 {}(非 None
assert exc.details == {}
assert isinstance(exc, HTTPException)
assert str(exc) == message
def test_exception_with_custom_message_and_details(self):
exc = NotFoundError(message="用户不存在", details={"user_id": 1})
assert exc.status_code == 404
assert exc.message == "用户不存在"
assert exc.details == {"user_id": 1}
def test_create_http_exception_unknown_status_returns_base(self):
exc = create_http_exception(418, "teapot", {"hint": "rfc2324"})
assert type(exc) is HTTPException
assert exc.status_code == 418
assert exc.message == "teapot"
assert exc.details == {"hint": "rfc2324"}
def test_create_http_exception_known_status_returns_mapped_subclass(self):
# 0.2.0 修复:旧版对已知状态码用三个位置参数调用 (message, details)
# 签名的子类,对全部 10 个已知状态码都抛 TypeError;
# 现在已知状态码返回对应子类实例
for status_code, exc_class in HTTP_STATUS_EXCEPTIONS.items():
exc = create_http_exception(status_code, "msg", {"k": "v"})
assert isinstance(exc, exc_class)
assert exc.status_code == status_code
assert exc.message == "msg"
assert exc.details == {"k": "v"}
# ---------------------------------------------------------------------------
# 6. myboot/exceptions.py 与 myboot/web/exceptions.py 同名类二义性
# ---------------------------------------------------------------------------
class TestExceptionClassUnification:
"""0.2.0 起两套同名异常收敛为同一个类(以 myboot.exceptions 为准),
web 路径保留 re-export,import 兼容但身份唯一"""
def test_validation_error_is_same_class(self):
from myboot.exceptions import MyBootException
from myboot.exceptions import ValidationError as CoreValidationError
from myboot.web.exceptions import ValidationError as WebValidationError
# 0.2.0:同一个类对象,except 任一路径都能捕获
assert CoreValidationError is WebValidationError
exc = WebValidationError(field="name", value="x")
assert isinstance(exc, MyBootException)
assert exc.code == "VALIDATION_ERROR"
assert exc.message == "验证失败"
assert exc.field == "name"
assert exc.value == "x"
# 旧 web 版独有的 error_type 参数已移除(以核心版签名为准)
assert not hasattr(exc, "error_type")
def test_configuration_error_is_same_class(self):
from myboot.exceptions import ConfigurationError as CoreConfigurationError
from myboot.exceptions import MyBootException
from myboot.web.exceptions import (
ConfigurationError as WebConfigurationError,
)
assert CoreConfigurationError is WebConfigurationError
exc = WebConfigurationError(config_key="app.name")
assert isinstance(exc, MyBootException)
assert exc.code == "CONFIGURATION_ERROR"
assert exc.config_key == "app.name"
assert exc.details == {}