gabriel / musehub public
test_api_performance_section62.py python
224 lines 9.2 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Tests for checklist section 6.2 — API performance.
2
3 Covers:
4 - Pagination infrastructure exists (PaginationParams, build_link_header, paginate_list)
5 - Commit log endpoint is DB-level paginated (limit/offset, 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 from musehub.api.routes.musehub.pagination import PaginationParams
32 p = PaginationParams(page=2, per_page=25)
33 assert p.page == 2
34 assert p.per_page == 25
35
36
37 def test_paginate_list_slices_correctly() -> None:
38 from musehub.api.routes.musehub.pagination import paginate_list
39 items = list(range(100))
40 page, total = paginate_list(items, page=3, per_page=10)
41 assert total == 100
42 assert page == list(range(20, 30))
43
44
45 def test_paginate_list_last_page_shorter() -> None:
46 from musehub.api.routes.musehub.pagination import paginate_list
47 items = list(range(25))
48 page, total = paginate_list(items, page=3, per_page=10)
49 assert total == 25
50 assert page == list(range(20, 25))
51
52
53 def test_build_link_header_emits_rfc8288() -> None:
54 from unittest.mock import MagicMock
55 from musehub.api.routes.musehub.pagination import build_link_header
56
57 req = MagicMock()
58 req.url = "https://hub.example.com/repos/alice/midi/commits?page=2&per_page=20"
59 req.query_params = {"page": "2", "per_page": "20"}
60
61 header = build_link_header(req, total=100, page=2, per_page=20)
62 assert 'rel="first"' in header
63 assert 'rel="last"' in header
64 assert 'rel="prev"' in header
65 assert 'rel="next"' in header
66
67
68 def test_per_page_is_bounded() -> None:
69 """PaginationParams __init__ must declare per_page with le=100 via Query."""
70 import inspect
71 from musehub.api.routes.musehub.pagination import PaginationParams
72
73 src = inspect.getsource(PaginationParams.__init__)
74 # per_page Query must have an upper bound
75 assert "le=100" in src or "le = 100" in src, (
76 "PaginationParams.per_page must declare le=100 in its Query() to bound result sets."
77 )
78
79
80 # ---------------------------------------------------------------------------
81 # Commit log is DB-level paginated (not SELECT *)
82 # ---------------------------------------------------------------------------
83
84 def test_ui_commits_uses_db_level_limit_offset() -> None:
85 """ui_commits must call .limit() and .offset() before executing the query."""
86 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_commits.py").read_text()
87 assert ".limit(" in src, "ui_commits must use .limit() for DB-level pagination"
88 assert ".offset(" in src, "ui_commits must use .offset() for DB-level pagination"
89
90
91 # ---------------------------------------------------------------------------
92 # Symbol list pagination
93 # ---------------------------------------------------------------------------
94
95 def test_ui_symbols_applies_pagination_window() -> None:
96 """ui_symbols must apply a page/per_page window before returning results."""
97 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text()
98 assert "per_page" in src, "ui_symbols must accept a per_page parameter"
99 assert "offset" in src or "page_symbols" in src, (
100 "ui_symbols must slice results to the requested page window"
101 )
102
103
104 # ---------------------------------------------------------------------------
105 # Blame limit
106 # ---------------------------------------------------------------------------
107
108 def test_blame_has_hard_limit() -> None:
109 """blame endpoint must have a .limit() call to prevent unbounded result sets."""
110 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "blame.py").read_text()
111 assert ".limit(" in src, "blame.py must call .limit() on its DB query"
112
113
114 # ---------------------------------------------------------------------------
115 # StorageBackend.stream() — protocol and implementations
116 # ---------------------------------------------------------------------------
117
118 def test_storage_backend_protocol_has_stream() -> None:
119 """StorageBackend Protocol must declare a stream() method."""
120 from musehub.storage.backends import StorageBackend
121 assert hasattr(StorageBackend, "stream"), (
122 "StorageBackend Protocol must declare stream() for chunked downloads"
123 )
124
125
126 def test_local_backend_has_stream() -> None:
127 from musehub.storage.backends import LocalBackend
128 assert hasattr(LocalBackend, "stream"), "LocalBackend must implement stream()"
129
130
131 def test_s3_backend_has_stream() -> None:
132 from musehub.storage.backends import S3Backend
133 assert hasattr(S3Backend, "stream"), "S3Backend must implement stream()"
134
135
136 @pytest.mark.anyio
137 async def test_local_backend_stream_yields_all_content() -> None:
138 """LocalBackend.stream() must yield the complete file content in chunks."""
139 from musehub.storage.backends import LocalBackend
140
141 data = b"a" * 200_000 # 200 KB — forces multiple chunks at 65536 chunk_size
142
143 with tempfile.TemporaryDirectory() as tmpdir:
144 backend = LocalBackend(objects_dir=tmpdir)
145 oid = "sha256:" + hashlib.sha256(data).hexdigest()
146 await backend.put("repo1", oid, data)
147
148 received = b""
149 chunk_count = 0
150 async for chunk in backend.stream("repo1", oid, chunk_size=65536):
151 received += chunk
152 chunk_count += 1
153
154 assert received == data, "stream() must yield the complete file content"
155 assert chunk_count >= 3, (
156 f"200 KB at 64 KiB chunks should take ≥ 3 chunks, got {chunk_count}"
157 )
158
159
160 @pytest.mark.anyio
161 async def test_local_backend_stream_missing_yields_nothing() -> None:
162 """LocalBackend.stream() must yield nothing (not raise) for a missing object."""
163 from musehub.storage.backends import LocalBackend
164
165 with tempfile.TemporaryDirectory() as tmpdir:
166 backend = LocalBackend(objects_dir=tmpdir)
167 chunks = []
168 async for chunk in backend.stream("repo1", "sha256:" + "f" * 64):
169 chunks.append(chunk)
170 assert chunks == [], "stream() must yield nothing for a non-existent object"
171
172
173 # ---------------------------------------------------------------------------
174 # Raw file download uses StreamingResponse, not full-buffer Response
175 # ---------------------------------------------------------------------------
176
177 def test_raw_download_uses_streaming_response() -> None:
178 """ui_tree raw_file_semantic must use StreamingResponse for chunked download.
179
180 The old implementation used Response(content=data) which loaded the full
181 file into RAM. The fix uses StreamingResponse with backend.stream().
182 """
183 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_tree.py").read_text()
184 assert "StreamingResponse" in src, (
185 "ui_tree.py must use StreamingResponse for raw file download, "
186 "not Response(content=data) which buffers the full file in RAM."
187 )
188 assert "backend.stream(" in src or "storage.stream(" in src, (
189 "ui_tree.py raw download must call backend.stream() for chunked iteration."
190 )
191 # The old pattern should be gone
192 assert "Response(content=data)" not in src, (
193 "Response(content=data) must be removed — it buffers the entire file in RAM."
194 )
195
196
197 # ---------------------------------------------------------------------------
198 # Symbol intel pre-computed at push time
199 # ---------------------------------------------------------------------------
200
201 def test_wire_push_triggers_symbol_indexer() -> None:
202 """wire.py push route must trigger the symbol indexer after a successful push."""
203 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "wire.py").read_text()
204 assert "_build_symbol_index_async" in src, (
205 "wire.py must call _build_symbol_index_async after push to pre-compute intel."
206 )
207 assert "create_task" in src, (
208 "The symbol indexer must be triggered via asyncio.create_task (fire-and-forget)."
209 )
210
211
212 def test_symbol_intel_served_from_precomputed_column() -> None:
213 """ui_symbols must read intel from intel_full_json column, not recompute it."""
214 src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text()
215 assert "load_intel_snapshot" in src, (
216 "ui_symbols must call load_intel_snapshot() to read pre-computed intel."
217 )
218 assert "intel_full_json" in src or "load_intel_snapshot" in src, (
219 "Symbol intel must be loaded from the pre-computed DB column, not recomputed."
220 )
221 # Must NOT call the compute functions directly
222 assert "compute_intel(" not in src, (
223 "ui_symbols must not call compute_intel() — intel must be pre-computed at push."
224 )
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago