gabriel / musehub public
test_api_contracts.py python
680 lines 28.1 KB
Raw
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