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