test_api_contracts.py
python
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠ breaking
156 days ago
| 1 | """Section 37 — API Contracts & OpenAPI: 7-layer test suite. |
| 2 | |
| 3 | Layers: |
| 4 | 1. Unit – to_camel, CamelModel config, model field validation |
| 5 | 2. Integration – OpenAPI schema generation, all paths have 2xx + summaries |
| 6 | 3. E2E – /api/openapi.json, /docs, component schemas via test client |
| 7 | 4. Stress – idempotent generation, bulk serialization |
| 8 | 5. Data Integrity – camelCase round-trips, required fields, alias correctness |
| 9 | 6. Security – error shapes, 401 not 500, no stack traces in responses |
| 10 | 7. Performance – schema under 1 second, model validation under 500ms |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import time |
| 15 | import warnings |
| 16 | from datetime import datetime, timezone |
| 17 | from typing import Any |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | # ── suppress known alias warnings from Pydantic union fields ────────────────── |
| 22 | warnings.filterwarnings("ignore", message=".*alias.*Field.*no effect.*") |
| 23 | |
| 24 | # ── imports ─────────────────────────────────────────────────────────────────── |
| 25 | from musehub.models.base import CamelModel, to_camel |
| 26 | from musehub.models.musehub import ( |
| 27 | BranchResponse, |
| 28 | CommitResponse, |
| 29 | IssueResponse, |
| 30 | ReleaseResponse, |
| 31 | RepoResponse, |
| 32 | TagResponse, |
| 33 | ) |
| 34 | |
| 35 | # ───────────────────────────────────────────────────────────────────────────── |
| 36 | # LAYER 1 — UNIT |
| 37 | # ───────────────────────────────────────────────────────────────────────────── |
| 38 | |
| 39 | |
| 40 | class TestToCamel: |
| 41 | """Unit tests for the to_camel converter.""" |
| 42 | |
| 43 | def test_single_word_unchanged(self): |
| 44 | assert to_camel("name") == "name" |
| 45 | |
| 46 | def test_two_word_snake(self): |
| 47 | assert to_camel("repo_id") == "repoId" |
| 48 | |
| 49 | def test_three_word_snake(self): |
| 50 | assert to_camel("head_commit_id") == "headCommitId" |
| 51 | |
| 52 | def test_is_prefix(self): |
| 53 | assert to_camel("is_verified") == "isVerified" |
| 54 | |
| 55 | def test_all_caps_word(self): |
| 56 | # Each segment after split capitalised by .capitalize() |
| 57 | assert to_camel("snake_url") == "snakeUrl" |
| 58 | |
| 59 | def test_empty_string(self): |
| 60 | assert to_camel("") == "" |
| 61 | |
| 62 | def test_already_camel_unchanged(self): |
| 63 | # No underscores → parts[0] only |
| 64 | assert to_camel("repoId") == "repoId" |
| 65 | |
| 66 | def test_trailing_underscore(self): |
| 67 | # trailing underscore adds empty segment → no change after capitalize |
| 68 | assert to_camel("name_") == "name" |
| 69 | |
| 70 | def test_multiple_underscores(self): |
| 71 | assert to_camel("a_b_c_d") == "aBCD" |
| 72 | |
| 73 | def test_return_type_is_str(self): |
| 74 | assert isinstance(to_camel("some_field"), str) |
| 75 | |
| 76 | |
| 77 | class TestCamelModelConfig: |
| 78 | """Unit tests for CamelModel base class configuration.""" |
| 79 | |
| 80 | def test_alias_generator_set(self): |
| 81 | assert CamelModel.model_config.get("alias_generator") is to_camel |
| 82 | |
| 83 | def test_populate_by_name_enabled(self): |
| 84 | assert CamelModel.model_config.get("populate_by_name") is True |
| 85 | |
| 86 | def test_subclass_inherits_alias_generator(self): |
| 87 | class MyModel(CamelModel): |
| 88 | my_field: str |
| 89 | |
| 90 | m = MyModel(my_field="x") |
| 91 | dumped = m.model_dump(by_alias=True) |
| 92 | assert "myField" in dumped |
| 93 | assert dumped["myField"] == "x" |
| 94 | |
| 95 | def test_populate_by_snake_name(self): |
| 96 | class MyModel(CamelModel): |
| 97 | repo_id: str |
| 98 | |
| 99 | m = MyModel(repo_id="abc") |
| 100 | assert m.repo_id == "abc" |
| 101 | |
| 102 | def test_populate_by_camel_alias(self): |
| 103 | class MyModel(CamelModel): |
| 104 | repo_id: str |
| 105 | |
| 106 | m = MyModel(**{"repoId": "abc"}) |
| 107 | assert m.repo_id == "abc" |
| 108 | |
| 109 | def test_snake_dump_default(self): |
| 110 | class MyModel(CamelModel): |
| 111 | repo_id: str |
| 112 | |
| 113 | m = MyModel(repo_id="x") |
| 114 | assert "repo_id" in m.model_dump() |
| 115 | assert "repoId" not in m.model_dump() |
| 116 | |
| 117 | def test_camel_dump_by_alias(self): |
| 118 | class MyModel(CamelModel): |
| 119 | repo_id: str |
| 120 | |
| 121 | m = MyModel(repo_id="x") |
| 122 | assert "repoId" in m.model_dump(by_alias=True) |
| 123 | |
| 124 | |
| 125 | class TestModelFieldValidation: |
| 126 | """Unit tests for required fields on key response models.""" |
| 127 | |
| 128 | def test_repo_response_required_fields(self): |
| 129 | fields = RepoResponse.model_fields |
| 130 | for f in ("repo_id", "name", "owner", "slug", "visibility", "owner_user_id", |
| 131 | "created_at", "updated_at"): |
| 132 | assert f in fields, f"{f} missing from RepoResponse" |
| 133 | |
| 134 | def test_commit_response_required_fields(self): |
| 135 | fields = CommitResponse.model_fields |
| 136 | for f in ("commit_id", "branch", "message", "author", "timestamp"): |
| 137 | assert f in fields, f"{f} missing from CommitResponse" |
| 138 | |
| 139 | def test_issue_response_required_fields(self): |
| 140 | fields = IssueResponse.model_fields |
| 141 | for f in ("issue_id", "number", "title", "state", "author"): |
| 142 | assert f in fields, f"{f} missing from IssueResponse" |
| 143 | |
| 144 | def test_release_response_required_fields(self): |
| 145 | fields = ReleaseResponse.model_fields |
| 146 | for f in ("release_id", "tag", "title", "commit_id"): |
| 147 | assert f in fields, f"{f} missing from ReleaseResponse" |
| 148 | |
| 149 | def test_tag_response_required_fields(self): |
| 150 | fields = TagResponse.model_fields |
| 151 | for f in ("tag", "namespace", "commit_id", "created_at"): |
| 152 | assert f in fields, f"{f} missing from TagResponse" |
| 153 | |
| 154 | def test_branch_response_required_fields(self): |
| 155 | fields = BranchResponse.model_fields |
| 156 | for f in ("branch_id", "name", "head_commit_id"): |
| 157 | assert f in fields, f"{f} missing from BranchResponse" |
| 158 | |
| 159 | def test_repo_response_json_schema_has_properties(self): |
| 160 | schema = RepoResponse.model_json_schema() |
| 161 | assert "properties" in schema or "$defs" in schema |
| 162 | |
| 163 | def test_commit_response_json_schema_generated(self): |
| 164 | schema = CommitResponse.model_json_schema() |
| 165 | assert schema.get("type") == "object" or "properties" in schema or "allOf" in schema |
| 166 | |
| 167 | |
| 168 | # ───────────────────────────────────────────────────────────────────────────── |
| 169 | # LAYER 2 — INTEGRATION |
| 170 | # ───────────────────────────────────────────────────────────────────────────── |
| 171 | |
| 172 | |
| 173 | @pytest.fixture(scope="module") |
| 174 | def openapi_schema(): |
| 175 | """Generate the OpenAPI schema once per module.""" |
| 176 | from musehub.main import app |
| 177 | |
| 178 | return app.openapi() |
| 179 | |
| 180 | |
| 181 | class TestOpenAPISchemaGeneration: |
| 182 | """Integration tests for the generated OpenAPI schema.""" |
| 183 | |
| 184 | def test_openapi_version_is_3_1(self, openapi_schema): |
| 185 | assert openapi_schema["openapi"].startswith("3.1") |
| 186 | |
| 187 | def test_has_info_block(self, openapi_schema): |
| 188 | assert "info" in openapi_schema |
| 189 | assert "title" in openapi_schema["info"] |
| 190 | |
| 191 | def test_path_count_at_least_170(self, openapi_schema): |
| 192 | assert len(openapi_schema["paths"]) >= 170 |
| 193 | |
| 194 | def test_component_schema_count_at_least_130(self, openapi_schema): |
| 195 | schemas = openapi_schema.get("components", {}).get("schemas", {}) |
| 196 | assert len(schemas) >= 130 |
| 197 | |
| 198 | def test_all_operations_have_summary(self, openapi_schema): |
| 199 | HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"} |
| 200 | missing = [] |
| 201 | for path, methods in openapi_schema["paths"].items(): |
| 202 | for method, op in methods.items(): |
| 203 | if method in HTTP_METHODS and not op.get("summary"): |
| 204 | missing.append(f"{method.upper()} {path}") |
| 205 | assert missing == [], f"Operations missing summary: {missing[:5]}" |
| 206 | |
| 207 | def test_all_operations_have_2xx_response(self, openapi_schema): |
| 208 | HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"} |
| 209 | missing = [] |
| 210 | for path, methods in openapi_schema["paths"].items(): |
| 211 | for method, op in methods.items(): |
| 212 | if method in HTTP_METHODS: |
| 213 | has_2xx = any( |
| 214 | str(code).startswith("2") for code in op.get("responses", {}) |
| 215 | ) |
| 216 | if not has_2xx: |
| 217 | missing.append(f"{method.upper()} {path}") |
| 218 | assert missing == [], f"Operations missing 2xx response: {missing[:5]}" |
| 219 | |
| 220 | def test_schema_is_dict(self, openapi_schema): |
| 221 | assert isinstance(openapi_schema, dict) |
| 222 | |
| 223 | def test_schema_has_paths_key(self, openapi_schema): |
| 224 | assert "paths" in openapi_schema |
| 225 | |
| 226 | def test_schema_has_components_key(self, openapi_schema): |
| 227 | assert "components" in openapi_schema |
| 228 | |
| 229 | def test_total_operation_count(self, openapi_schema): |
| 230 | HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"} |
| 231 | total = sum( |
| 232 | 1 |
| 233 | for methods in openapi_schema["paths"].values() |
| 234 | for m in methods |
| 235 | if m in HTTP_METHODS |
| 236 | ) |
| 237 | assert total >= 190 |
| 238 | |
| 239 | def test_key_response_models_in_components(self, openapi_schema): |
| 240 | schemas = openapi_schema.get("components", {}).get("schemas", {}) |
| 241 | for model_name in ("RepoResponse", "CommitResponse", "IssueResponse"): |
| 242 | assert model_name in schemas, f"{model_name} missing from component schemas" |
| 243 | |
| 244 | def test_validation_error_schema_present(self, openapi_schema): |
| 245 | schemas = openapi_schema.get("components", {}).get("schemas", {}) |
| 246 | assert "HTTPValidationError" in schemas |
| 247 | |
| 248 | def test_schema_serialisable_to_json(self, openapi_schema): |
| 249 | import json |
| 250 | |
| 251 | raw = json.dumps(openapi_schema) |
| 252 | assert len(raw) > 100_000 # schema is large |
| 253 | |
| 254 | |
| 255 | # ───────────────────────────────────────────────────────────────────────────── |
| 256 | # LAYER 3 — E2E (via test client) |
| 257 | # ───────────────────────────────────────────────────────────────────────────── |
| 258 | |
| 259 | |
| 260 | class TestOpenAPIEndpointsE2E: |
| 261 | """E2E tests hitting /api/openapi.json and /docs via the test client.""" |
| 262 | |
| 263 | async def test_openapi_json_returns_200(self, client): |
| 264 | resp = await client.get("/api/openapi.json") |
| 265 | assert resp.status_code == 200 |
| 266 | |
| 267 | async def test_openapi_json_content_type(self, client): |
| 268 | resp = await client.get("/api/openapi.json") |
| 269 | assert "application/json" in resp.headers["content-type"] |
| 270 | |
| 271 | async def test_openapi_json_has_paths(self, client): |
| 272 | resp = await client.get("/api/openapi.json") |
| 273 | body = resp.json() |
| 274 | assert "paths" in body |
| 275 | assert len(body["paths"]) >= 170 |
| 276 | |
| 277 | async def test_openapi_json_version_3_1(self, client): |
| 278 | resp = await client.get("/api/openapi.json") |
| 279 | assert resp.json()["openapi"].startswith("3.1") |
| 280 | |
| 281 | async def test_openapi_json_has_components(self, client): |
| 282 | resp = await client.get("/api/openapi.json") |
| 283 | body = resp.json() |
| 284 | assert "components" in body |
| 285 | assert "schemas" in body["components"] |
| 286 | |
| 287 | async def test_docs_returns_200(self, client): |
| 288 | resp = await client.get("/docs") |
| 289 | assert resp.status_code == 200 |
| 290 | |
| 291 | async def test_docs_returns_html(self, client): |
| 292 | resp = await client.get("/docs") |
| 293 | assert "text/html" in resp.headers["content-type"] |
| 294 | |
| 295 | async def test_redoc_returns_200(self, client): |
| 296 | resp = await client.get("/redoc") |
| 297 | assert resp.status_code == 200 |
| 298 | |
| 299 | async def test_openapi_json_repo_response_in_schemas(self, client): |
| 300 | resp = await client.get("/api/openapi.json") |
| 301 | schemas = resp.json()["components"]["schemas"] |
| 302 | assert "RepoResponse" in schemas |
| 303 | |
| 304 | async def test_openapi_json_commit_response_in_schemas(self, client): |
| 305 | resp = await client.get("/api/openapi.json") |
| 306 | schemas = resp.json()["components"]["schemas"] |
| 307 | assert "CommitResponse" in schemas |
| 308 | |
| 309 | async def test_openapi_json_issue_response_in_schemas(self, client): |
| 310 | resp = await client.get("/api/openapi.json") |
| 311 | schemas = resp.json()["components"]["schemas"] |
| 312 | assert "IssueResponse" in schemas |
| 313 | |
| 314 | async def test_openapi_json_release_response_in_schemas(self, client): |
| 315 | resp = await client.get("/api/openapi.json") |
| 316 | schemas = resp.json()["components"]["schemas"] |
| 317 | assert "ReleaseResponse" in schemas |
| 318 | |
| 319 | |
| 320 | # ───────────────────────────────────────────────────────────────────────────── |
| 321 | # LAYER 4 — STRESS |
| 322 | # ───────────────────────────────────────────────────────────────────────────── |
| 323 | |
| 324 | |
| 325 | class TestOpenAPIStress: |
| 326 | """Stress tests: idempotency and bulk serialisation.""" |
| 327 | |
| 328 | def test_schema_generation_idempotent(self): |
| 329 | from musehub.main import app |
| 330 | |
| 331 | s1 = app.openapi() |
| 332 | s2 = app.openapi() |
| 333 | assert s1 == s2 |
| 334 | |
| 335 | def test_schema_path_count_stable_across_calls(self): |
| 336 | from musehub.main import app |
| 337 | |
| 338 | counts = [len(app.openapi()["paths"]) for _ in range(3)] |
| 339 | assert len(set(counts)) == 1 |
| 340 | |
| 341 | def test_bulk_repo_response_serialisation(self): |
| 342 | """Serialise 500 RepoResponse objects — must not raise.""" |
| 343 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 344 | # Build one valid instance first to discover required fields |
| 345 | sample = dict( |
| 346 | repo_id="rid", |
| 347 | name="repo", |
| 348 | owner="gabriel", |
| 349 | slug="repo", |
| 350 | visibility="public", |
| 351 | owner_user_id="uid", |
| 352 | clone_url="http://localhost:1337/gabriel/repo", |
| 353 | tags=[], |
| 354 | created_at=ts, |
| 355 | updated_at=ts, |
| 356 | ) |
| 357 | # Try with optional fields omitted — Pydantic will apply defaults |
| 358 | proto = RepoResponse.model_validate(sample) |
| 359 | for i in range(500): |
| 360 | r = RepoResponse.model_validate({**sample, "repo_id": f"rid-{i}"}) |
| 361 | r.model_dump(by_alias=True) |
| 362 | |
| 363 | def test_bulk_commit_response_serialisation(self): |
| 364 | """Serialise 500 CommitResponse objects.""" |
| 365 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 366 | for i in range(500): |
| 367 | c = CommitResponse( |
| 368 | commit_id=f"cid-{i:040d}", |
| 369 | branch="main", |
| 370 | parent_ids=[], |
| 371 | message=f"commit {i}", |
| 372 | author="gabriel", |
| 373 | timestamp=ts, |
| 374 | snapshot_id=f"sid-{i}", |
| 375 | ) |
| 376 | c.model_dump(by_alias=True) |
| 377 | |
| 378 | def test_to_camel_bulk(self): |
| 379 | """Convert 10 000 snake_case strings without error.""" |
| 380 | samples = [ |
| 381 | "repo_id", "head_commit_id", "is_verified", "owner_user_id", |
| 382 | "created_at", "updated_at", "download_urls", "semver_major", |
| 383 | ] |
| 384 | for _ in range(1250): |
| 385 | for s in samples: |
| 386 | to_camel(s) |
| 387 | |
| 388 | def test_model_json_schema_repeated(self): |
| 389 | """model_json_schema() called 50 times produces consistent output.""" |
| 390 | schemas = [RepoResponse.model_json_schema() for _ in range(50)] |
| 391 | first = schemas[0] |
| 392 | for s in schemas[1:]: |
| 393 | assert s == first |
| 394 | |
| 395 | |
| 396 | # ───────────────────────────────────────────────────────────────────────────── |
| 397 | # LAYER 5 — DATA INTEGRITY |
| 398 | # ───────────────────────────────────────────────────────────────────────────── |
| 399 | |
| 400 | |
| 401 | class TestCamelCaseRoundTrip: |
| 402 | """Data integrity: camelCase alias round-trips.""" |
| 403 | |
| 404 | def _make_repo(self) -> RepoResponse: |
| 405 | ts = datetime(2025, 6, 1, tzinfo=timezone.utc) |
| 406 | return RepoResponse.model_validate(dict( |
| 407 | repo_id="repo-abc", |
| 408 | name="my-repo", |
| 409 | owner="gabriel", |
| 410 | slug="my-repo", |
| 411 | visibility="public", |
| 412 | owner_user_id="uid-1", |
| 413 | clone_url="http://localhost:1337/gabriel/my-repo", |
| 414 | tags=["music", "demo"], |
| 415 | tempo_bpm=120.0, |
| 416 | created_at=ts, |
| 417 | updated_at=ts, |
| 418 | )) |
| 419 | |
| 420 | def test_repo_camel_keys(self): |
| 421 | wire = self._make_repo().model_dump(by_alias=True) |
| 422 | assert "repoId" in wire |
| 423 | assert "ownerId" not in wire # field is owner_user_id → ownerUserId |
| 424 | assert "ownerUserId" in wire |
| 425 | assert "createdAt" in wire |
| 426 | |
| 427 | def test_repo_round_trip(self): |
| 428 | original = self._make_repo() |
| 429 | wire = original.model_dump(by_alias=True) |
| 430 | restored = RepoResponse.model_validate(wire) |
| 431 | assert restored.model_dump(by_alias=True) == wire |
| 432 | |
| 433 | def test_repo_snake_dump_no_camel(self): |
| 434 | wire = self._make_repo().model_dump() |
| 435 | assert "repo_id" in wire |
| 436 | assert "repoId" not in wire |
| 437 | |
| 438 | def test_commit_camel_keys(self): |
| 439 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 440 | c = CommitResponse( |
| 441 | commit_id="abc123", |
| 442 | branch="main", |
| 443 | parent_ids=["p1"], |
| 444 | message="init", |
| 445 | author="gabriel", |
| 446 | timestamp=ts, |
| 447 | snapshot_id="snap1", |
| 448 | ) |
| 449 | wire = c.model_dump(by_alias=True) |
| 450 | assert "commitId" in wire |
| 451 | assert "parentIds" in wire |
| 452 | assert "snapshotId" in wire |
| 453 | |
| 454 | def test_issue_camel_keys(self): |
| 455 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 456 | i = IssueResponse.model_validate(dict( |
| 457 | issue_id="iss-1", |
| 458 | number=1, |
| 459 | title="Bug", |
| 460 | body="", |
| 461 | state="open", |
| 462 | labels=[], |
| 463 | author="gabriel", |
| 464 | created_at=ts, |
| 465 | updated_at=ts, |
| 466 | comment_count=0, |
| 467 | )) |
| 468 | wire = i.model_dump(by_alias=True) |
| 469 | assert "issueId" in wire |
| 470 | assert "commentCount" in wire |
| 471 | |
| 472 | def test_tag_camel_keys(self): |
| 473 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 474 | t = TagResponse.model_validate(dict( |
| 475 | tag="v1.0", |
| 476 | namespace="release", |
| 477 | commit_id="abc123", |
| 478 | created_at=ts, |
| 479 | )) |
| 480 | wire = t.model_dump(by_alias=True) |
| 481 | assert "commitId" in wire |
| 482 | assert "createdAt" in wire |
| 483 | |
| 484 | def test_branch_camel_keys(self): |
| 485 | b = BranchResponse(branch_id="bid-1", name="main", head_commit_id="abc") |
| 486 | wire = b.model_dump(by_alias=True) |
| 487 | assert "branchId" in wire |
| 488 | assert "headCommitId" in wire |
| 489 | |
| 490 | def test_camelmodel_requires_snake_or_camel_on_input(self): |
| 491 | class M(CamelModel): |
| 492 | owner_id: str |
| 493 | |
| 494 | m1 = M(owner_id="a") |
| 495 | m2 = M(**{"ownerId": "a"}) |
| 496 | assert m1.owner_id == m2.owner_id |
| 497 | |
| 498 | def test_alias_generator_consistent(self): |
| 499 | """All model fields with underscores produce valid camelCase aliases.""" |
| 500 | for field_name in RepoResponse.model_fields: |
| 501 | alias = to_camel(field_name) |
| 502 | assert "_" not in alias or field_name == alias # no underscores in result |
| 503 | |
| 504 | |
| 505 | # ───────────────────────────────────────────────────────────────────────────── |
| 506 | # LAYER 6 — SECURITY |
| 507 | # ───────────────────────────────────────────────────────────────────────────── |
| 508 | |
| 509 | |
| 510 | class TestAPIContractsSecurity: |
| 511 | """Security: proper error shapes, no stack traces, auth enforced.""" |
| 512 | |
| 513 | async def test_invalid_json_body_returns_422_not_500(self, client, auth_headers): |
| 514 | # Sending invalid JSON to a POST endpoint should yield 422 |
| 515 | resp = await client.post( |
| 516 | "/api/repos", |
| 517 | content=b"not-json", |
| 518 | headers={**auth_headers, "content-type": "application/json"}, |
| 519 | ) |
| 520 | assert resp.status_code in (400, 422), f"Expected 4xx, got {resp.status_code}" |
| 521 | |
| 522 | async def test_422_response_has_detail_key(self, client, auth_headers): |
| 523 | resp = await client.post( |
| 524 | "/api/repos", |
| 525 | content=b"not-json", |
| 526 | headers={**auth_headers, "content-type": "application/json"}, |
| 527 | ) |
| 528 | if resp.status_code == 422: |
| 529 | body = resp.json() |
| 530 | assert "detail" in body |
| 531 | |
| 532 | async def test_missing_required_body_field_returns_422(self, client, auth_headers): |
| 533 | # POST /repos with empty JSON object — missing required fields |
| 534 | resp = await client.post( |
| 535 | "/api/repos", |
| 536 | json={}, |
| 537 | headers=auth_headers, |
| 538 | ) |
| 539 | assert resp.status_code == 422 |
| 540 | |
| 541 | async def test_openapi_json_no_stack_trace(self, client): |
| 542 | resp = await client.get("/api/openapi.json") |
| 543 | body = resp.text |
| 544 | assert "Traceback" not in body |
| 545 | # Check for Python exception raise syntax (not natural English "raise") |
| 546 | assert "raise Exception" not in body |
| 547 | assert "raise ValueError" not in body |
| 548 | assert "raise HTTPException" not in body |
| 549 | |
| 550 | async def test_error_body_no_python_exception_class(self, client, auth_headers): |
| 551 | resp = await client.post( |
| 552 | "/api/repos", |
| 553 | json={"x": 1}, |
| 554 | headers=auth_headers, |
| 555 | ) |
| 556 | # Should be 422, body should not leak Python exception names |
| 557 | if resp.status_code != 200: |
| 558 | body = resp.text |
| 559 | assert "Exception" not in body or "detail" in resp.json() |
| 560 | |
| 561 | async def test_unknown_api_route_returns_404(self, client): |
| 562 | # /api/* routes are strict — unknown API paths return 404 |
| 563 | resp = await client.get("/api/this-route-does-not-exist-xyz-abc-12345") |
| 564 | assert resp.status_code == 404 |
| 565 | |
| 566 | async def test_404_response_no_traceback(self, client): |
| 567 | resp = await client.get("/api/totally-unknown-endpoint-xyzabc-99999") |
| 568 | assert "Traceback" not in resp.text |
| 569 | |
| 570 | async def test_auth_required_route_returns_401_not_500(self, client): |
| 571 | # Access a protected route WITHOUT auth headers — must yield 401, not 500 |
| 572 | resp = await client.get("/api/repos") |
| 573 | assert resp.status_code == 401, f"Expected 401, got {resp.status_code}" |
| 574 | |
| 575 | async def test_method_not_allowed_returns_405(self, client, auth_headers): |
| 576 | # DELETE on a GET-only endpoint should yield 405 |
| 577 | resp = await client.delete("/api/openapi.json", headers=auth_headers) |
| 578 | assert resp.status_code in (405, 404) # FastAPI returns 405 for wrong method |
| 579 | |
| 580 | async def test_openapi_json_not_exposing_server_internals(self, client): |
| 581 | resp = await client.get("/api/openapi.json") |
| 582 | body = resp.text |
| 583 | # Should not contain local filesystem paths |
| 584 | assert "/Users/" not in body |
| 585 | assert "/home/" not in body |
| 586 | |
| 587 | |
| 588 | # ───────────────────────────────────────────────────────────────────────────── |
| 589 | # LAYER 7 — PERFORMANCE |
| 590 | # ───────────────────────────────────────────────────────────────────────────── |
| 591 | |
| 592 | |
| 593 | class TestAPIContractsPerformance: |
| 594 | """Performance: schema generation and model validation within time budgets.""" |
| 595 | |
| 596 | def test_openapi_schema_generation_under_1_second(self): |
| 597 | from musehub.main import app |
| 598 | |
| 599 | # Clear cached schema to force regeneration |
| 600 | app.openapi_schema = None |
| 601 | start = time.perf_counter() |
| 602 | app.openapi() |
| 603 | elapsed = time.perf_counter() - start |
| 604 | assert elapsed < 1.0, f"Schema generation took {elapsed:.3f}s (limit 1.0s)" |
| 605 | |
| 606 | def test_openapi_cached_retrieval_under_10ms(self): |
| 607 | from musehub.main import app |
| 608 | |
| 609 | # Ensure schema is cached |
| 610 | app.openapi() |
| 611 | start = time.perf_counter() |
| 612 | for _ in range(10): |
| 613 | app.openapi() |
| 614 | elapsed = time.perf_counter() - start |
| 615 | avg = elapsed / 10 |
| 616 | assert avg < 0.010, f"Cached schema avg retrieval {avg*1000:.1f}ms (limit 10ms)" |
| 617 | |
| 618 | def test_to_camel_10k_strings_under_100ms(self): |
| 619 | samples = [ |
| 620 | "repo_id", "head_commit_id", "is_verified", "owner_user_id", |
| 621 | "created_at", "updated_at", "download_urls", "semver_major", |
| 622 | ] |
| 623 | start = time.perf_counter() |
| 624 | for _ in range(1250): |
| 625 | for s in samples: |
| 626 | to_camel(s) |
| 627 | elapsed = time.perf_counter() - start |
| 628 | assert elapsed < 0.100, f"to_camel 10K calls took {elapsed*1000:.1f}ms (limit 100ms)" |
| 629 | |
| 630 | def test_1k_repo_responses_validated_under_500ms(self): |
| 631 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 632 | base = { |
| 633 | "repo_id": "rid", |
| 634 | "name": "repo", |
| 635 | "owner": "gabriel", |
| 636 | "slug": "repo", |
| 637 | "visibility": "public", |
| 638 | "owner_user_id": "uid", |
| 639 | "clone_url": "http://localhost:1337/gabriel/repo", |
| 640 | "tags": [], |
| 641 | "created_at": ts.isoformat(), |
| 642 | "updated_at": ts.isoformat(), |
| 643 | } |
| 644 | data = [{**base, "repo_id": f"rid-{i}"} for i in range(1000)] |
| 645 | start = time.perf_counter() |
| 646 | for d in data: |
| 647 | RepoResponse.model_validate(d) |
| 648 | elapsed = time.perf_counter() - start |
| 649 | assert elapsed < 0.500, f"1K RepoResponse validates took {elapsed*1000:.1f}ms (limit 500ms)" |
| 650 | |
| 651 | async def test_openapi_json_endpoint_under_200ms(self, client): |
| 652 | # First call may be slower; measure after warmup |
| 653 | await client.get("/api/openapi.json") |
| 654 | start = time.perf_counter() |
| 655 | resp = await client.get("/api/openapi.json") |
| 656 | elapsed = time.perf_counter() - start |
| 657 | assert resp.status_code == 200 |
| 658 | assert elapsed < 0.200, f"/api/openapi.json took {elapsed*1000:.1f}ms (limit 200ms)" |
| 659 | |
| 660 | def test_model_dump_by_alias_1k_under_200ms(self): |
| 661 | ts = datetime(2025, 1, 1, tzinfo=timezone.utc) |
| 662 | instances = [ |
| 663 | CommitResponse( |
| 664 | commit_id=f"cid-{i:040d}", |
| 665 | branch="main", |
| 666 | parent_ids=[], |
| 667 | message=f"msg {i}", |
| 668 | author="gabriel", |
| 669 | timestamp=ts, |
| 670 | snapshot_id=f"sid-{i}", |
| 671 | ) |
| 672 | for i in range(1000) |
| 673 | ] |
| 674 | start = time.perf_counter() |
| 675 | for inst in instances: |
| 676 | inst.model_dump(by_alias=True) |
| 677 | elapsed = time.perf_counter() - start |
| 678 | assert elapsed < 0.200, f"1K model_dump(by_alias) took {elapsed*1000:.1f}ms (limit 200ms)" |
File History
1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65
refactor: enforce gRPC framing on all MWP wire traffic
Sonnet 4.6
minor
⚠
156 days ago