"""Tests for checklist section 6.2 — API performance. Covers: - Pagination infrastructure exists (PaginationParams, build_link_header, paginate_list) - Commit log endpoint is DB-level paginated (limit/offset, not unbounded SELECT) - Symbol list endpoint is paginated (page/per_page, bounded result set) - Blame endpoint has a hard limit - StorageBackend.stream() exists on both LocalBackend and S3Backend - LocalBackend.stream() yields content in chunks without loading full file - Raw file download (ui_tree) uses StreamingResponse, not Response(content=...) - Symbol intel is pre-computed at push time (fire-and-forget indexer triggered) - Intel served from intel_full_json column, not computed per-request """ from __future__ import annotations import hashlib import inspect import pathlib import tempfile import pytest _REPO_ROOT = pathlib.Path(__file__).parent.parent # --------------------------------------------------------------------------- # Pagination infrastructure # --------------------------------------------------------------------------- def test_pagination_params_exist() -> None: from musehub.api.routes.musehub.pagination import PaginationParams p = PaginationParams(page=2, per_page=25) assert p.page == 2 assert p.per_page == 25 def test_paginate_list_slices_correctly() -> None: from musehub.api.routes.musehub.pagination import paginate_list items = list(range(100)) page, total = paginate_list(items, page=3, per_page=10) assert total == 100 assert page == list(range(20, 30)) def test_paginate_list_last_page_shorter() -> None: from musehub.api.routes.musehub.pagination import paginate_list items = list(range(25)) page, total = paginate_list(items, page=3, per_page=10) assert total == 25 assert page == list(range(20, 25)) def test_build_link_header_emits_rfc8288() -> None: from unittest.mock import MagicMock from musehub.api.routes.musehub.pagination import build_link_header req = MagicMock() req.url = "https://hub.example.com/repos/alice/midi/commits?page=2&per_page=20" req.query_params = {"page": "2", "per_page": "20"} header = build_link_header(req, total=100, page=2, per_page=20) assert 'rel="first"' in header assert 'rel="last"' in header assert 'rel="prev"' in header assert 'rel="next"' in header def test_per_page_is_bounded() -> None: """PaginationParams __init__ must declare per_page with le=100 via Query.""" import inspect from musehub.api.routes.musehub.pagination import PaginationParams src = inspect.getsource(PaginationParams.__init__) # per_page Query must have an upper bound assert "le=100" in src or "le = 100" in src, ( "PaginationParams.per_page must declare le=100 in its Query() to bound result sets." ) # --------------------------------------------------------------------------- # Commit log is DB-level paginated (not SELECT *) # --------------------------------------------------------------------------- def test_ui_commits_uses_db_level_limit_offset() -> None: """ui_commits must call .limit() and .offset() before executing the query.""" src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_commits.py").read_text() assert ".limit(" in src, "ui_commits must use .limit() for DB-level pagination" assert ".offset(" in src, "ui_commits must use .offset() for DB-level pagination" # --------------------------------------------------------------------------- # Symbol list pagination # --------------------------------------------------------------------------- def test_ui_symbols_applies_pagination_window() -> None: """ui_symbols must apply a page/per_page window before returning results.""" src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text() assert "per_page" in src, "ui_symbols must accept a per_page parameter" assert "offset" in src or "page_symbols" in src, ( "ui_symbols must slice results to the requested page window" ) # --------------------------------------------------------------------------- # Blame limit # --------------------------------------------------------------------------- def test_blame_has_hard_limit() -> None: """blame endpoint must have a .limit() call to prevent unbounded result sets.""" src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "blame.py").read_text() assert ".limit(" in src, "blame.py must call .limit() on its DB query" # --------------------------------------------------------------------------- # StorageBackend.stream() — protocol and implementations # --------------------------------------------------------------------------- def test_storage_backend_protocol_has_stream() -> None: """StorageBackend Protocol must declare a stream() method.""" from musehub.storage.backends import StorageBackend assert hasattr(StorageBackend, "stream"), ( "StorageBackend Protocol must declare stream() for chunked downloads" ) def test_local_backend_has_stream() -> None: from musehub.storage.backends import LocalBackend assert hasattr(LocalBackend, "stream"), "LocalBackend must implement stream()" def test_s3_backend_has_stream() -> None: from musehub.storage.backends import S3Backend assert hasattr(S3Backend, "stream"), "S3Backend must implement stream()" @pytest.mark.anyio async def test_local_backend_stream_yields_all_content() -> None: """LocalBackend.stream() must yield the complete file content in chunks.""" from musehub.storage.backends import LocalBackend data = b"a" * 200_000 # 200 KB — forces multiple chunks at 65536 chunk_size with tempfile.TemporaryDirectory() as tmpdir: backend = LocalBackend(objects_dir=tmpdir) oid = "sha256:" + hashlib.sha256(data).hexdigest() await backend.put("repo1", oid, data) received = b"" chunk_count = 0 async for chunk in backend.stream("repo1", oid, chunk_size=65536): received += chunk chunk_count += 1 assert received == data, "stream() must yield the complete file content" assert chunk_count >= 3, ( f"200 KB at 64 KiB chunks should take ≥ 3 chunks, got {chunk_count}" ) @pytest.mark.anyio async def test_local_backend_stream_missing_yields_nothing() -> None: """LocalBackend.stream() must yield nothing (not raise) for a missing object.""" from musehub.storage.backends import LocalBackend with tempfile.TemporaryDirectory() as tmpdir: backend = LocalBackend(objects_dir=tmpdir) chunks = [] async for chunk in backend.stream("repo1", "sha256:" + "f" * 64): chunks.append(chunk) assert chunks == [], "stream() must yield nothing for a non-existent object" # --------------------------------------------------------------------------- # Raw file download uses StreamingResponse, not full-buffer Response # --------------------------------------------------------------------------- def test_raw_download_uses_streaming_response() -> None: """ui_tree raw_file_semantic must use StreamingResponse for chunked download. The old implementation used Response(content=data) which loaded the full file into RAM. The fix uses StreamingResponse with backend.stream(). """ src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_tree.py").read_text() assert "StreamingResponse" in src, ( "ui_tree.py must use StreamingResponse for raw file download, " "not Response(content=data) which buffers the full file in RAM." ) assert "backend.stream(" in src or "storage.stream(" in src, ( "ui_tree.py raw download must call backend.stream() for chunked iteration." ) # The old pattern should be gone assert "Response(content=data)" not in src, ( "Response(content=data) must be removed — it buffers the entire file in RAM." ) # --------------------------------------------------------------------------- # Symbol intel pre-computed at push time # --------------------------------------------------------------------------- def test_wire_push_triggers_symbol_indexer() -> None: """wire.py push route must trigger the symbol indexer after a successful push.""" src = (_REPO_ROOT / "musehub" / "api" / "routes" / "wire.py").read_text() assert "_build_symbol_index_async" in src, ( "wire.py must call _build_symbol_index_async after push to pre-compute intel." ) assert "create_task" in src, ( "The symbol indexer must be triggered via asyncio.create_task (fire-and-forget)." ) def test_symbol_intel_served_from_precomputed_column() -> None: """ui_symbols must read intel from intel_full_json column, not recompute it.""" src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text() assert "load_intel_snapshot" in src, ( "ui_symbols must call load_intel_snapshot() to read pre-computed intel." ) assert "intel_full_json" in src or "load_intel_snapshot" in src, ( "Symbol intel must be loaded from the pre-computed DB column, not recomputed." ) # Must NOT call the compute functions directly assert "compute_intel(" not in src, ( "ui_symbols must not call compute_intel() — intel must be pre-computed at push." )