test_api_performance_section62.py
python
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