"""openapi.json 导出测试:守住「一份文档覆盖三个服务」的合并结果 这份文档是外部(Apifox / Postman / 上游联调)唯一的接口来源,合并逻辑错了就会 把人引到不存在的接口、或漏标鉴权。下面的用例全部在内存里 build_spec() 检查合并 结果,不读仓库里的 openapi.json——那个文件是 gitignore 的生成物,CI 的全新 clone 里根本不存在,断言「文件内容 == 当前导出结果」在 CI 上必然失败。 因此「改了接口忘了重新导出」没有自动兜底,得手动跑: .venv/Scripts/python.exe scripts/export_openapi.py --check """ from __future__ import annotations import importlib.util import json import re import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent SCRIPT = ROOT / "scripts" / "export_openapi.py" _METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace") def _load_exporter(): """按路径加载 scripts/export_openapi.py(scripts 不是包) 必须先塞进 sys.modules 再 exec:dataclass 解析类型注解时会去 sys.modules[cls.__module__] 找命名空间,没登记就直接 AttributeError。 """ spec = importlib.util.spec_from_file_location("export_openapi", SCRIPT) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module @pytest.fixture(scope="module") def exporter(): return _load_exporter() @pytest.fixture(scope="module") def spec(exporter): return exporter.build_spec() def _operations(document: dict) -> list[tuple[str, str, dict]]: return [ (path, method, operation) for path, item in document["paths"].items() for method, operation in item.items() if method in _METHODS ] def test_covers_all_three_services(spec): paths = spec["paths"] # 抓取 assert "/api/search" in paths assert "/api/rakuma/search" in paths # 交易(有状态端) assert "/api/auth/login" in paths assert "/api/cart/add" in paths # 网关 assert "/api/orders" in paths assert "/api/orders/lease" in paths def test_each_operation_points_at_its_own_service(spec): """operation 级 servers:导入后不必手动切 base URL""" by_path = {path: operation["servers"][0]["url"] for path, _, operation in _operations(spec)} assert by_path["/api/search"].endswith(":31107") assert by_path["/api/cart/add"].endswith(":31108") assert by_path["/api/orders/lease"].endswith(":31109") # /health 三个服务都有,合成一条并列出三个 server health_servers = {s["url"] for s in spec["paths"]["/health"]["get"]["servers"]} assert len(health_servers) == 3 def test_only_health_is_public(spec): """除 /health 外都必须标 Bearer 鉴权——漏标会让调用方以为能匿名调""" unprotected = [ f"{method.upper()} {path}" for path, method, operation in _operations(spec) if "security" not in operation ] assert unprotected == ["GET /health"] assert "BearerAuth" in spec["components"]["securitySchemes"] def test_operation_ids_are_unique(spec): """三个服务的 /health 原本都叫 health_health_get,撞了会让导入工具丢接口""" ids = [operation["operationId"] for _, _, operation in _operations(spec)] assert len(ids) == len(set(ids)) def test_all_refs_resolve(spec): """合并 schema 时如果重名处理错了,$ref 会指向不存在的定义""" names = set(spec["components"]["schemas"]) refs = set(re.findall(r"#/components/schemas/([^\"]+)", json.dumps(spec))) assert not refs - names def test_submit_order_intent_schema_documents_multi_item_and_legacy_fields(spec): """intent 保持透传对象,同时在 OpenAPI 中明确展示新旧两种商品格式。""" intent = spec["components"]["schemas"]["SubmitOrderRequest"]["properties"]["intent"] properties = intent["properties"] assert intent["additionalProperties"] is True assert properties["items"]["type"] == "array" assert properties["items"]["minItems"] == 1 item_object = properties["items"]["items"]["oneOf"][0] assert item_object["required"] == ["item_url"] assert "item_url" in properties assert "quantity" in properties