test_musehub_openapi.py
python
| 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 | "pushCommits", |
| 147 | "pullCommits", |
| 148 | "listObjects", |
| 149 | "getObjectContent", |
| 150 | "createRelease", |
| 151 | "listReleases", |
| 152 | "getRelease", |
| 153 | "createSession", |
| 154 | "listSessions", |
| 155 | "getUserProfile", |
| 156 | "createUserProfile", |
| 157 | "listPublicRepos", |
| 158 | "createWebhook", |
| 159 | "listWebhooks", |
| 160 | ] |
| 161 | |
| 162 | missing = [op_id for op_id in expected_ids if op_id not in all_operation_ids] |
| 163 | assert not missing, ( |
| 164 | f"Expected operationIds missing from spec:\n" + "\n".join(sorted(missing)) |
| 165 | ) |
| 166 | |
| 167 | |
| 168 | @pytest.mark.anyio |
| 169 | async def test_openapi_spec_has_security_schemes(openapi_spec: dict) -> None: # type: ignore[type-arg] |
| 170 | """Spec components contain security scheme definitions (Bearer JWT).""" |
| 171 | components = openapi_spec.get("components", {}) |
| 172 | security_schemes = components.get("securitySchemes", {}) |
| 173 | # FastAPI auto-generates securitySchemes from OAuth2/HTTPBearer dependencies. |
| 174 | # We verify at least one scheme exists when auth dependencies are registered. |
| 175 | # If no scheme exists yet, the test still passes — this is a soft check |
| 176 | # since FastAPI only generates securitySchemes for documented auth flows. |
| 177 | assert isinstance(security_schemes, dict), "securitySchemes is not a dict" |
| 178 | |
| 179 | |
| 180 | @pytest.mark.anyio |
| 181 | async def test_openapi_spec_info_contact(openapi_spec: dict) -> None: # type: ignore[type-arg] |
| 182 | """Spec info.contact is populated.""" |
| 183 | info = openapi_spec["info"] |
| 184 | contact = info.get("contact", {}) |
| 185 | assert contact, "info.contact is missing or empty" |
| 186 | assert contact.get("name") or contact.get("url") or contact.get("email"), ( |
| 187 | "info.contact has no name, url, or email" |
| 188 | ) |
| 189 | |
| 190 | |
| 191 | @pytest.mark.anyio |
| 192 | async def test_repo_schema_has_descriptions(openapi_spec: dict) -> None: # type: ignore[type-arg] |
| 193 | """RepoResponse schema properties have descriptions.""" |
| 194 | schemas = openapi_spec.get("components", {}).get("schemas", {}) |
| 195 | repo_schema = schemas.get("RepoResponse") |
| 196 | assert repo_schema is not None, "RepoResponse schema not found in spec components" |
| 197 | |
| 198 | properties = repo_schema.get("properties", {}) |
| 199 | assert properties, "RepoResponse has no properties" |
| 200 | |
| 201 | missing_descriptions = [ |
| 202 | prop_name |
| 203 | for prop_name, prop_schema in properties.items() |
| 204 | if not prop_schema.get("description") |
| 205 | ] |
| 206 | assert not missing_descriptions, ( |
| 207 | f"RepoResponse properties missing descriptions: {missing_descriptions}" |
| 208 | ) |
| 209 | |
| 210 | |
| 211 | @pytest.mark.anyio |
| 212 | async def test_commit_response_schema_has_descriptions(openapi_spec: dict) -> None: # type: ignore[type-arg] |
| 213 | """CommitResponse schema properties have descriptions.""" |
| 214 | schemas = openapi_spec.get("components", {}).get("schemas", {}) |
| 215 | schema = schemas.get("CommitResponse") |
| 216 | assert schema is not None, "CommitResponse schema not found in spec components" |
| 217 | |
| 218 | properties = schema.get("properties", {}) |
| 219 | assert properties, "CommitResponse has no properties" |
| 220 | |
| 221 | missing_descriptions = [ |
| 222 | prop_name |
| 223 | for prop_name, prop_schema in properties.items() |
| 224 | if not prop_schema.get("description") |
| 225 | ] |
| 226 | assert not missing_descriptions, ( |
| 227 | f"CommitResponse properties missing descriptions: {missing_descriptions}" |
| 228 | ) |