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