Files
2026-07-13 12:34:57 +08:00

699 lines
25 KiB
Python
Raw Permalink 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.
"""
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 默认 201delete 默认 204get/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_typeAST 扫描产生的 '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-exportimport 兼容但身份唯一"""
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 == {}