gabriel / musehub public
test_api_performance.py python
213 lines 8.8 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """Tests for checklist section 6.2 — API performance.
2
3 Covers:
4 - Pagination infrastructure exists (PaginationParams, build_cursor_link_header)
5 - Commit log endpoint is DB-level paginated (cursor-based keyset, not unbounded SELECT)
6 - Symbol list endpoint is paginated (page/per_page, bounded result set)
7 - Blame endpoint has a hard limit
8 - StorageBackend.stream() exists on both LocalBackend and S3Backend
9 - LocalBackend.stream() yields content in chunks without loading full file
10 - Raw file download (ui_tree) uses StreamingResponse, not Response(content=...)
11 - Symbol intel is pre-computed at push time (fire-and-forget indexer triggered)
12 - Intel served from intel_full_json column, not computed per-request
13 """
14 from __future__ import annotations
15
16 import hashlib
17 import inspect
18 import pathlib
19 import tempfile
20
21 import pytest
22
23 _REPO_ROOT = pathlib.Path(__file__).parent.parent
24
25
26 # ---------------------------------------------------------------------------
27 # Pagination infrastructure
28 # ---------------------------------------------------------------------------
29
30 def test_pagination_params_exist() -> None:
31 """PaginationParams provides cursor and limit for cursor-based pagination."""
32 from musehub.api.routes.musehub.pagination import PaginationParams
33 p = PaginationParams(cursor="abc", limit=25)
34 assert p.cursor == "abc"
35 assert p.limit == 25
36
37
38 def test_pagination_params_defaults() -> None:
39 """PaginationParams accepts explicit cursor=None and limit=20."""
40 from musehub.api.routes.musehub.pagination import PaginationParams
41 p = PaginationParams(cursor=None, limit=20)
42 assert p.cursor is None
43 assert p.limit == 20
44
45
46 def test_build_cursor_link_header_emits_rel_next() -> None:
47 """build_cursor_link_header emits a rel='next' RFC 8288 Link header."""
48 from starlette.requests import Request as StarletteRequest
49 from musehub.api.routes.musehub.pagination import build_cursor_link_header
50
51 scope = {
52 "type": "http", "method": "GET",
53 "path": "/repos/alice/midi/commits",
54 "query_string": b"limit=20",
55 "headers": [],
56 }
57 req = StarletteRequest(scope)
58 header = build_cursor_link_header(req, next_cursor="tok123", limit=20)
59 assert 'rel="next"' in header
60 assert "cursor=tok123" in header
61 assert "limit=20" in header
62
63
64 def test_limit_is_bounded() -> None:
65 """PaginationParams __init__ must declare limit with le=200 via Query."""
66 import inspect
67 from musehub.api.routes.musehub.pagination import PaginationParams
68
69 src = inspect.getsource(PaginationParams.__init__)
70 assert "le=200" in src or "le = 200" in src, (
71 "PaginationParams.limit must declare le=200 in its Query() to bound result sets."
72 )
73
74
75 # ---------------------------------------------------------------------------
76 # Commit log is DB-level paginated (not SELECT *)
77 # ---------------------------------------------------------------------------
78
79 def test_ui_commits_uses_db_level_limit() -> None:
80 """ui_commits must call .limit() for DB-level pagination."""
81 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_commits.py").read_text()
82 assert ".limit(" in src, "ui_commits must use .limit() for DB-level pagination"
83
84
85 # ---------------------------------------------------------------------------
86 # Symbol list pagination
87 # ---------------------------------------------------------------------------
88
89 def test_ui_symbols_applies_pagination_window() -> None:
90 """ui_symbols must apply a page/per_page window before returning results."""
91 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text()
92 assert "per_page" in src, "ui_symbols must accept a per_page parameter"
93 assert "offset" in src or "page_symbols" in src, (
94 "ui_symbols must slice results to the requested page window"
95 )
96
97
98 # ---------------------------------------------------------------------------
99 # Blame limit
100 # ---------------------------------------------------------------------------
101
102 def test_blame_has_hard_limit() -> None:
103 """blame endpoint must have a .limit() call to prevent unbounded result sets."""
104 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "blame.py").read_text()
105 assert ".limit(" in src, "blame.py must call .limit() on its DB query"
106
107
108 # ---------------------------------------------------------------------------
109 # StorageBackend.stream() — protocol and implementations
110 # ---------------------------------------------------------------------------
111
112 def test_storage_backend_protocol_has_stream() -> None:
113 """StorageBackend Protocol must declare a stream() method."""
114 from musehub.storage.backends import StorageBackend
115 assert hasattr(StorageBackend, "stream"), (
116 "StorageBackend Protocol must declare stream() for chunked downloads"
117 )
118
119
120 def test_local_backend_has_stream() -> None:
121 from musehub.storage.backends import LocalBackend
122 assert hasattr(LocalBackend, "stream"), "LocalBackend must implement stream()"
123
124
125 def test_s3_backend_has_stream() -> None:
126 from musehub.storage.backends import S3Backend
127 assert hasattr(S3Backend, "stream"), "S3Backend must implement stream()"
128
129
130 async def test_local_backend_stream_yields_all_content() -> None:
131 """LocalBackend.stream() must yield the complete file content in chunks."""
132 from musehub.storage.backends import LocalBackend
133
134 data = b"a" * 200_000 # 200 KB — forces multiple chunks at 65536 chunk_size
135
136 with tempfile.TemporaryDirectory() as tmpdir:
137 backend = LocalBackend(objects_dir=tmpdir)
138 oid = "sha256:" + hashlib.sha256(data).hexdigest()
139 await backend.put(oid, data)
140
141 received = b""
142 chunk_count = 0
143 async for chunk in backend.stream(oid, chunk_size=65536):
144 received += chunk
145 chunk_count += 1
146
147 assert received == data, "stream() must yield the complete file content"
148 assert chunk_count >= 3, (
149 f"200 KB at 64 KiB chunks should take ≥ 3 chunks, got {chunk_count}"
150 )
151
152
153 async def test_local_backend_stream_missing_yields_nothing() -> None:
154 """LocalBackend.stream() must yield nothing (not raise) for a missing object."""
155 from musehub.storage.backends import LocalBackend
156
157 with tempfile.TemporaryDirectory() as tmpdir:
158 backend = LocalBackend(objects_dir=tmpdir)
159 chunks = []
160 async for chunk in backend.stream("sha256:" + "f" * 64):
161 chunks.append(chunk)
162 assert chunks == [], "stream() must yield nothing for a non-existent object"
163
164
165 # ---------------------------------------------------------------------------
166 # Raw file download uses StreamingResponse, not full-buffer Response
167 # ---------------------------------------------------------------------------
168
169 def test_raw_download_uses_streaming_response() -> None:
170 """ui_tree raw_file_semantic must use StreamingResponse for chunked download.
171
172 The old implementation used Response(content=data) which loaded the full
173 file into RAM. The fix uses StreamingResponse with backend.stream().
174 """
175 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_tree.py").read_text()
176 assert "StreamingResponse" in src, (
177 "ui_tree.py must use StreamingResponse for raw file download, "
178 "not Response(content=data) which buffers the full file in RAM."
179 )
180 assert "backend.stream(" in src or "storage.stream(" in src, (
181 "ui_tree.py raw download must call backend.stream() for chunked iteration."
182 )
183 # The old pattern should be gone
184 assert "Response(content=data)" not in src, (
185 "Response(content=data) must be removed — it buffers the entire file in RAM."
186 )
187
188
189 # ---------------------------------------------------------------------------
190 # Symbol intel pre-computed at push time
191 # ---------------------------------------------------------------------------
192
193 def test_wire_push_triggers_intel_indexer() -> None:
194 """wire.py push route must enqueue intel jobs after a successful push."""
195 src = (_REPO_ROOT / "musehub" / "services" / "musehub_wire.py").read_text()
196 assert "enqueue_push_intel" in src, (
197 "musehub_wire.py must call enqueue_push_intel after push to pre-compute intelligence."
198 )
199
200
201 def test_symbol_intel_served_from_precomputed_column() -> None:
202 """ui_symbols must read intel from intel_full_json column, not recompute it."""
203 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text()
204 assert "load_intel_snapshot" in src, (
205 "ui_symbols must call load_intel_snapshot() to read pre-computed intel."
206 )
207 assert "intel_full_json" in src or "load_intel_snapshot" in src, (
208 "Symbol intel must be loaded from the pre-computed DB column, not recomputed."
209 )
210 # Must NOT call the compute functions directly
211 assert "compute_intel(" not in src, (
212 "ui_symbols must not call compute_intel() — intel must be pre-computed at push."
213 )
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago