gabriel / musehub public
test_musehub_openapi.py python
214 lines 7.9 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 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.types.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 async def test_openapi_spec_valid(openapi_spec: JSONObject) -> None:
43 """GET /api/openapi.json returns valid JSON with openapi: '3.1.0'."""
44 assert "openapi" in openapi_spec, "Spec missing 'openapi' field"
45 assert openapi_spec["openapi"].startswith("3.1"), (
46 f"Expected OpenAPI 3.1.x, got {openapi_spec['openapi']!r}"
47 )
48 assert "info" in openapi_spec, "Spec missing 'info' field"
49 assert "paths" in openapi_spec, "Spec missing 'paths' field"
50 assert len(openapi_spec["paths"]) > 0, "Spec has no paths"
51
52
53 async def test_openapi_spec_has_title_and_version(openapi_spec: JSONObject) -> None:
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 async def test_all_musehub_endpoints_in_spec(openapi_spec: JSONObject) -> None:
61 """Core MuseHub API paths appear in the OpenAPI spec."""
62 paths = openapi_spec["paths"]
63 expected_path_prefixes = [
64 "/api/repos",
65 "/api/search",
66 "/api/discover",
67 "/api/users",
68 ]
69 for prefix in expected_path_prefixes:
70 matching = [p for p in paths if p.startswith(prefix)]
71 assert matching, f"No spec paths start with {prefix!r}"
72
73
74 async def test_operation_ids_unique(openapi_spec: JSONObject) -> None:
75 """No duplicate operationId values exist across the spec."""
76 seen: set[str] = set()
77 duplicates: list[str] = []
78
79 for path, path_item in openapi_spec["paths"].items():
80 for method, operation in path_item.items():
81 if method in ("get", "post", "put", "patch", "delete", "head", "options", "trace"):
82 op_id = operation.get("operationId")
83 if op_id:
84 if op_id in seen:
85 duplicates.append(f"{method.upper()} {path} → {op_id}")
86 seen.add(op_id)
87
88 assert not duplicates, f"Duplicate operationIds found:\n{'\n'.join(duplicates)}"
89
90
91 async def test_musehub_endpoints_have_operation_ids(openapi_spec: JSONObject) -> None:
92 """All MuseHub API endpoints have operationId set."""
93 missing: list[str] = []
94
95 for path, path_item in openapi_spec["paths"].items():
96 if "/api/musehub" not in path and "/api/musehub" not in path:
97 continue
98 # Skip UI/HTML routes (they don't return JSON)
99 if path.startswith("/"):
100 continue
101
102 for method, operation in path_item.items():
103 if method in ("get", "post", "put", "patch", "delete"):
104 if not operation.get("operationId"):
105 missing.append(f"{method.upper()} {path}")
106
107 assert not missing, (
108 f"MuseHub endpoints missing operationId:\n{'\n'.join(sorted(missing))}"
109 )
110
111
112 async def test_key_musehub_operation_ids_exist(openapi_spec: JSONObject) -> None:
113 """Specific high-priority operationIds are present in the spec."""
114 all_operation_ids: set[str] = set()
115 for path_item in openapi_spec["paths"].values():
116 for method, operation in path_item.items():
117 if method in ("get", "post", "put", "patch", "delete"):
118 op_id = operation.get("operationId")
119 if op_id:
120 all_operation_ids.add(op_id)
121
122 expected_ids = [
123 "createRepo",
124 "getRepo",
125 "listRepoBranches",
126 "listRepoCommits",
127 "getRepoCommit",
128 "getRepoTimeline",
129 "getRepoDivergence",
130 "createIssue",
131 "listIssues",
132 "getIssue",
133 "closeIssue",
134 "createProposal",
135 "listProposals",
136 "getProposal",
137 "mergeProposal",
138 "globalSearch",
139 "searchRepo",
140 "listObjects",
141 "getObjectContent",
142 "createRelease",
143 "listReleases",
144 "getRelease",
145 "createSession",
146 "listSessions",
147 "getUserProfile",
148 "createUserProfile",
149 "listPublicRepos",
150 "createWebhook",
151 "listWebhooks",
152 ]
153
154 missing = [op_id for op_id in expected_ids if op_id not in all_operation_ids]
155 assert not missing, (
156 f"Expected operationIds missing from spec:\n{'\n'.join(sorted(missing))}"
157 )
158
159
160 async def test_openapi_spec_has_security_schemes(openapi_spec: JSONObject) -> None:
161 """Spec components.securitySchemes is a dict (may be empty for MSign auth)."""
162 components = openapi_spec.get("components", {})
163 security_schemes = components.get("securitySchemes", {})
164 # MSign is a custom scheme — FastAPI does not auto-generate it.
165 # We only verify the field is a dict when present.
166 assert isinstance(security_schemes, dict), "securitySchemes is not a dict"
167
168
169 async def test_openapi_spec_info_contact(openapi_spec: JSONObject) -> None:
170 """Spec info.contact is populated."""
171 info = openapi_spec["info"]
172 contact = info.get("contact", {})
173 assert contact, "info.contact is missing or empty"
174 assert contact.get("name") or contact.get("url") or contact.get("email"), (
175 "info.contact has no name, url, or email"
176 )
177
178
179 async def test_repo_schema_has_descriptions(openapi_spec: JSONObject) -> None:
180 """RepoResponse schema properties have descriptions."""
181 schemas = openapi_spec.get("components", {}).get("schemas", {})
182 repo_schema = schemas.get("RepoResponse")
183 assert repo_schema is not None, "RepoResponse schema not found in spec components"
184
185 properties = repo_schema.get("properties", {})
186 assert properties, "RepoResponse has no properties"
187
188 missing_descriptions = [
189 prop_name
190 for prop_name, prop_schema in properties.items()
191 if not prop_schema.get("description")
192 ]
193 assert not missing_descriptions, (
194 f"RepoResponse properties missing descriptions: {missing_descriptions}"
195 )
196
197
198 async def test_commit_response_schema_has_descriptions(openapi_spec: JSONObject) -> None:
199 """CommitResponse schema properties have descriptions."""
200 schemas = openapi_spec.get("components", {}).get("schemas", {})
201 schema = schemas.get("CommitResponse")
202 assert schema is not None, "CommitResponse schema not found in spec components"
203
204 properties = schema.get("properties", {})
205 assert properties, "CommitResponse has no properties"
206
207 missing_descriptions = [
208 prop_name
209 for prop_name, prop_schema in properties.items()
210 if not prop_schema.get("description")
211 ]
212 assert not missing_descriptions, (
213 f"CommitResponse properties missing descriptions: {missing_descriptions}"
214 )
File History 14 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 29 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 33 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 35 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago