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