"""手写契约 `openapi-internal.yaml` 与实际实现的一致性守卫。 **为什么需要它**:契约是手写的,实现是另一份代码。本项目开工审计已经证明「没有 门禁的契约必然漂移」——四模块快照锁实际不存在,结果白名单 42/41 之误四份文档一路 不可见,整整一个迭代没人发现。内部契约同样是契约,同样需要门禁。 **不做的事**:不比对 FastAPI 自动生成的 `/openapi.json` 与手写 yaml 的字节一致性。 两者永远不会字节相同(描述、示例、`$ref` 组织方式都不同),逐字比对只会制造一个 天天红的门禁,然后被人加 skip 绕过。这里比对的是**有实质意义的东西**:路径、方法、 字段集、枚举取值。 """ from __future__ import annotations import pathlib import yaml from app.api.errors import ServiceErrorCode from app.api.main import create_app from app.api.schemas import FailureModel, SubmitRequest, SubmitResponse, TaskResponse from app.config import ServiceAuthSettings, Settings, VolcengineSettings from app.domain.generation import Retry, TaskState, UserFacing CONTRACT = yaml.safe_load( ( pathlib.Path(__file__).resolve().parent.parent.parent / "openapi-internal.yaml" ).read_text(encoding="utf-8") ) def schema_properties(name: str) -> set[str]: return set(CONTRACT["components"]["schemas"][name]["properties"]) def app_routes() -> set[tuple[str, str]]: """实际对外暴露的 `/internal` 端点集合。 取自 `app.openapi()` 而非遍历 `app.routes`:后者在不同 FastAPI 版本里结构不同 (本版本的 `include_router` 会包一层内部 wrapper,路由并不平铺在 `app.routes` 上),依赖它会让这个守卫在升级框架时莫名失效——而一个悄悄失效的门禁比没有 门禁更糟。`openapi()` 是「究竟暴露了什么」的权威视图。 """ app = create_app( Settings( volcengine=VolcengineSettings(api_key="dummy-api-key-for-tests"), service_auth=ServiceAuthSettings(public_key=""), ) ) return { (method.upper(), path) for path, operations in app.openapi()["paths"].items() if path.startswith("/internal") for method in operations } class TestPathsMatch: def test_every_contract_path_is_implemented(self): implemented = app_routes() for path, operations in CONTRACT["paths"].items(): for method in operations: assert (method.upper(), path) in implemented, ( f"契约声明了 {method.upper()} {path} 但实现里没有" ) def test_no_undeclared_internal_route_exists(self): """实现里不得有契约之外的 /internal 端点。 未声明的内部端点是最容易被遗忘的表面——它不在契约里,评审看不到, 但它确实对外开放。 """ declared = { (method.upper(), path) for path, operations in CONTRACT["paths"].items() for method in operations } assert app_routes() <= declared class TestFieldSetsMatch: """字段集漂移是最隐蔽的一种:多一个字段无人察觉,少一个字段调用方拿到 None。""" def test_submit_request(self): assert schema_properties("SubmitRequest") == set(SubmitRequest.model_fields) def test_submit_response(self): assert schema_properties("SubmitResponse") == set(SubmitResponse.model_fields) def test_task_response(self): assert schema_properties("TaskResponse") == set(TaskResponse.model_fields) def test_failure(self): assert schema_properties("Failure") == set(FailureModel.model_fields) def test_all_field_names_are_snake_case(self): """契约定为 snake_case(与对客户端的 camelCase 契约不同,见契约说明)。 混入一个 camelCase 字段就会让「JSON 与 dataclass 字段逐一对应、无需转换层」 这个前提失效。 """ for name in ("SubmitRequest", "SubmitResponse", "TaskResponse", "Failure"): for field in schema_properties(name): assert field.islower() or "_" in field, ( f"{name}.{field} 不是 snake_case" ) assert not any(char.isupper() for char in field), ( f"{name}.{field} 含大写字母" ) class TestEnumsMatch: """枚举漂移的后果是调用方收到一个它没处理的取值——而那通常走到默认分支。""" def test_task_states(self): declared = set( CONTRACT["components"]["schemas"]["TaskResponse"]["properties"]["state"][ "enum" ] ) assert declared == {state.value for state in TaskState} def test_retry_strategies(self): declared = set( CONTRACT["components"]["schemas"]["Failure"]["properties"]["retry"]["enum"] ) assert declared == {retry.value for retry in Retry} def test_user_facing_categories(self): declared = set( CONTRACT["components"]["schemas"]["Failure"]["properties"]["user_facing"][ "enum" ] ) assert declared == {category.value for category in UserFacing} def test_service_error_codes(self): declared = set( CONTRACT["components"]["schemas"]["ServiceErrorEnvelope"]["properties"][ "error" ]["properties"]["code"]["enum"] ) assert declared == {code.code for code in ServiceErrorCode} def test_provider_error_reuses_the_failure_classification_fields(self): """`provider` 错误信封与 `Failure` 的分类字段必须一致。 这是刻意设计:调用方因此可以复用同一段处置逻辑,不必为「提交时被限流」 与「任务失败于限流」写两套分支。两边若漂移,那份复用就会悄悄失效。 """ envelope = set( CONTRACT["components"]["schemas"]["ProviderErrorEnvelope"]["properties"][ "error" ]["properties"] ) failure = schema_properties("Failure") assert envelope == failure | {"kind"} class TestRequiredFieldsMatch: def test_submit_request_required(self): declared = set(CONTRACT["components"]["schemas"]["SubmitRequest"]["required"]) actual = { name for name, field in SubmitRequest.model_fields.items() if field.is_required() } assert declared == actual def test_task_response_required(self): declared = set(CONTRACT["components"]["schemas"]["TaskResponse"]["required"]) actual = { name for name, field in TaskResponse.model_fields.items() if field.is_required() } assert declared == actual