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