test_musehub_openapi.py
python
sha256:9b711047e27df5ac91681c74aadfb0e31f69ffd4269932ea52f0c113764d8c0a
docs(phase-03): rewrite Domain Protocol — AddressedMergePlu…
Sonnet 4.6
minor
⚠ breaking
23 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
1 commit
sha256:9b711047e27df5ac91681c74aadfb0e31f69ffd4269932ea52f0c113764d8c0a
docs(phase-03): rewrite Domain Protocol — AddressedMergePlu…
Sonnet 4.6
minor
⚠
23 days ago