gabriel / musehub public
test_musehub_openapi.py python
224 lines 8.1 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Tests for MuseHub OpenAPI 3.1 specification completeness and correctness.
2
3 Verifies that:
4 - /api/openapi.json returns valid OpenAPI 3.1 JSON
5 - All registered MuseHub routes appear in the spec paths
6 - All schema properties have description fields where expected
7 - No duplicate operationId values exist across the entire spec
8 """
9 from __future__ import annotations
10
11 import json
12
13 import pytest
14 from httpx import ASGITransport, AsyncClient
15
16 from musehub.main import app
17 from musehub.muse_contracts.json_types import JSONObject
18
19 # ── Fixtures ──────────────────────────────────────────────────────────────────
20
21
22 @pytest.fixture()
23 def anyio_backend() -> str:
24 return "asyncio"
25
26
27 @pytest.fixture()
28 async def openapi_spec() -> JSONObject:
29 """Fetch the OpenAPI spec from the running app."""
30 async with AsyncClient(
31 transport=ASGITransport(app=app), base_url="http://test"
32 ) as client:
33 response = await client.get("/api/openapi.json")
34 assert response.status_code == 200, f"OpenAPI spec endpoint returned {response.status_code}"
35 data: JSONObject = response.json()
36 return data
37
38
39 # ── Tests ─────────────────────────────────────────────────────────────────────
40
41
42 @pytest.mark.anyio
43 async def test_openapi_spec_valid(openapi_spec: JSONObject) -> None:
44 """GET /api/openapi.json returns valid JSON with openapi: '3.1.0'."""
45 assert "openapi" in openapi_spec, "Spec missing 'openapi' field"
46 assert openapi_spec["openapi"].startswith("3.1"), (
47 f"Expected OpenAPI 3.1.x, got {openapi_spec['openapi']!r}"
48 )
49 assert "info" in openapi_spec, "Spec missing 'info' field"
50 assert "paths" in openapi_spec, "Spec missing 'paths' field"
51 assert len(openapi_spec["paths"]) > 0, "Spec has no paths"
52
53
54 @pytest.mark.anyio
55 async def test_openapi_spec_has_title_and_version(openapi_spec: JSONObject) -> None:
56 """Spec info block contains a non-empty title and version."""
57 info = openapi_spec["info"]
58 assert info.get("title"), "OpenAPI info.title is empty"
59 assert info.get("version"), "OpenAPI info.version is empty"
60
61
62 @pytest.mark.anyio
63 async def test_all_musehub_endpoints_in_spec(openapi_spec: JSONObject) -> None:
64 """Core MuseHub API paths appear in the OpenAPI spec."""
65 paths = openapi_spec["paths"]
66 expected_path_prefixes = [
67 "/api/repos",
68 "/api/search",
69 "/api/discover",
70 "/api/users",
71 ]
72 for prefix in expected_path_prefixes:
73 matching = [p for p in paths if p.startswith(prefix)]
74 assert matching, f"No spec paths start with {prefix!r}"
75
76
77 @pytest.mark.anyio
78 async def test_operation_ids_unique(openapi_spec: JSONObject) -> None:
79 """No duplicate operationId values exist across the spec."""
80 seen: set[str] = set()
81 duplicates: list[str] = []
82
83 for path, path_item in openapi_spec["paths"].items():
84 for method, operation in path_item.items():
85 if method in ("get", "post", "put", "patch", "delete", "head", "options", "trace"):
86 op_id = operation.get("operationId")
87 if op_id:
88 if op_id in seen:
89 duplicates.append(f"{method.upper()} {path} → {op_id}")
90 seen.add(op_id)
91
92 assert not duplicates, f"Duplicate operationIds found:\n" + "\n".join(duplicates)
93
94
95 @pytest.mark.anyio
96 async def test_musehub_endpoints_have_operation_ids(openapi_spec: JSONObject) -> None:
97 """All MuseHub API endpoints have operationId set."""
98 missing: list[str] = []
99
100 for path, path_item in openapi_spec["paths"].items():
101 if "/api/musehub" not in path and "/api/musehub" not in path:
102 continue
103 # Skip UI/HTML routes (they don't return JSON)
104 if path.startswith("/"):
105 continue
106
107 for method, operation in path_item.items():
108 if method in ("get", "post", "put", "patch", "delete"):
109 if not operation.get("operationId"):
110 missing.append(f"{method.upper()} {path}")
111
112 assert not missing, (
113 f"MuseHub endpoints missing operationId:\n" + "\n".join(sorted(missing))
114 )
115
116
117 @pytest.mark.anyio
118 async def test_key_musehub_operation_ids_exist(openapi_spec: JSONObject) -> None:
119 """Specific high-priority operationIds are present in the spec."""
120 all_operation_ids: set[str] = set()
121 for path_item in openapi_spec["paths"].values():
122 for method, operation in path_item.items():
123 if method in ("get", "post", "put", "patch", "delete"):
124 op_id = operation.get("operationId")
125 if op_id:
126 all_operation_ids.add(op_id)
127
128 expected_ids = [
129 "createRepo",
130 "getRepo",
131 "listRepoBranches",
132 "listRepoCommits",
133 "getRepoCommit",
134 "getRepoTimeline",
135 "getRepoDivergence",
136 "createIssue",
137 "listIssues",
138 "getIssue",
139 "closeIssue",
140 "createProposal",
141 "listProposals",
142 "getProposal",
143 "mergeProposal",
144 "globalSearch",
145 "searchRepo",
146 "listObjects",
147 "getObjectContent",
148 "createRelease",
149 "listReleases",
150 "getRelease",
151 "createSession",
152 "listSessions",
153 "getUserProfile",
154 "createUserProfile",
155 "listPublicRepos",
156 "createWebhook",
157 "listWebhooks",
158 ]
159
160 missing = [op_id for op_id in expected_ids if op_id not in all_operation_ids]
161 assert not missing, (
162 f"Expected operationIds missing from spec:\n" + "\n".join(sorted(missing))
163 )
164
165
166 @pytest.mark.anyio
167 async def test_openapi_spec_has_security_schemes(openapi_spec: JSONObject) -> None:
168 """Spec components.securitySchemes is a dict (may be empty for MSign auth)."""
169 components = openapi_spec.get("components", {})
170 security_schemes = components.get("securitySchemes", {})
171 # MSign is a custom scheme — FastAPI does not auto-generate it.
172 # We only verify the field is a dict when present.
173 assert isinstance(security_schemes, dict), "securitySchemes is not a dict"
174
175
176 @pytest.mark.anyio
177 async def test_openapi_spec_info_contact(openapi_spec: JSONObject) -> None:
178 """Spec info.contact is populated."""
179 info = openapi_spec["info"]
180 contact = info.get("contact", {})
181 assert contact, "info.contact is missing or empty"
182 assert contact.get("name") or contact.get("url") or contact.get("email"), (
183 "info.contact has no name, url, or email"
184 )
185
186
187 @pytest.mark.anyio
188 async def test_repo_schema_has_descriptions(openapi_spec: JSONObject) -> None:
189 """RepoResponse schema properties have descriptions."""
190 schemas = openapi_spec.get("components", {}).get("schemas", {})
191 repo_schema = schemas.get("RepoResponse")
192 assert repo_schema is not None, "RepoResponse schema not found in spec components"
193
194 properties = repo_schema.get("properties", {})
195 assert properties, "RepoResponse has no properties"
196
197 missing_descriptions = [
198 prop_name
199 for prop_name, prop_schema in properties.items()
200 if not prop_schema.get("description")
201 ]
202 assert not missing_descriptions, (
203 f"RepoResponse properties missing descriptions: {missing_descriptions}"
204 )
205
206
207 @pytest.mark.anyio
208 async def test_commit_response_schema_has_descriptions(openapi_spec: JSONObject) -> None:
209 """CommitResponse schema properties have descriptions."""
210 schemas = openapi_spec.get("components", {}).get("schemas", {})
211 schema = schemas.get("CommitResponse")
212 assert schema is not None, "CommitResponse schema not found in spec components"
213
214 properties = schema.get("properties", {})
215 assert properties, "CommitResponse has no properties"
216
217 missing_descriptions = [
218 prop_name
219 for prop_name, prop_schema in properties.items()
220 if not prop_schema.get("description")
221 ]
222 assert not missing_descriptions, (
223 f"CommitResponse properties missing descriptions: {missing_descriptions}"
224 )
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago