test_api_performance.py
python
sha256:25d96102cb2d69a038356dff26f4633156da2f1faf98fe0d0e4438ff3f367f12
refactor: rename 0054/0055 migrations to standard convention
Sonnet 4.6
minor
⚠ breaking
20 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 BlobBackend |
| 9 | - Raw file download (ui_tree) uses StreamingResponse, not Response(content=...) |
| 10 | - Symbol intel is pre-computed at push time (fire-and-forget indexer triggered) |
| 11 | - Intel served from intel_full_json column, not computed per-request |
| 12 | """ |
| 13 | from __future__ import annotations |
| 14 | |
| 15 | import inspect |
| 16 | import pathlib |
| 17 | import tempfile |
| 18 | |
| 19 | from muse.core.types import blob_id, fake_id |
| 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_blob_backend_has_stream() -> None: |
| 121 | from musehub.storage.backends import BlobBackend |
| 122 | assert hasattr(BlobBackend, "stream"), "BlobBackend must implement stream()" |
| 123 | |
| 124 | |
| 125 | # --------------------------------------------------------------------------- |
| 126 | # Raw file download uses StreamingResponse, not full-buffer Response |
| 127 | # --------------------------------------------------------------------------- |
| 128 | |
| 129 | def test_raw_download_uses_streaming_response() -> None: |
| 130 | """ui_tree raw_file_semantic must use StreamingResponse for chunked download. |
| 131 | |
| 132 | The old implementation used Response(content=data) which loaded the full |
| 133 | file into RAM. The fix uses StreamingResponse with backend.stream(). |
| 134 | """ |
| 135 | src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_tree.py").read_text() |
| 136 | assert "StreamingResponse" in src, ( |
| 137 | "ui_tree.py must use StreamingResponse for raw file download, " |
| 138 | "not Response(content=data) which buffers the full file in RAM." |
| 139 | ) |
| 140 | assert "backend.stream(" in src or "storage.stream(" in src, ( |
| 141 | "ui_tree.py raw download must call backend.stream() for chunked iteration." |
| 142 | ) |
| 143 | # The old pattern should be gone |
| 144 | assert "Response(content=data)" not in src, ( |
| 145 | "Response(content=data) must be removed — it buffers the entire file in RAM." |
| 146 | ) |
| 147 | |
| 148 | |
| 149 | # --------------------------------------------------------------------------- |
| 150 | # Symbol intel pre-computed at push time |
| 151 | # --------------------------------------------------------------------------- |
| 152 | |
| 153 | def test_wire_push_triggers_intel_indexer() -> None: |
| 154 | """wire.py push route must enqueue intel jobs after a successful push.""" |
| 155 | src = (_REPO_ROOT / "musehub" / "api" / "routes" / "wire.py").read_text() |
| 156 | assert "enqueue_push_intel" in src, ( |
| 157 | "wire.py must call enqueue_push_intel after push to pre-compute intelligence." |
| 158 | ) |
| 159 | |
| 160 | |
| 161 | def test_symbol_intel_served_from_precomputed_column() -> None: |
| 162 | """ui_symbols must read intel from intel_full_json column, not recompute it.""" |
| 163 | src = (_REPO_ROOT / "musehub" / "api" / "routes" / "musehub" / "ui_symbols.py").read_text() |
| 164 | assert "load_intel_snapshot" in src, ( |
| 165 | "ui_symbols must call load_intel_snapshot() to read pre-computed intel." |
| 166 | ) |
| 167 | assert "intel_full_json" in src or "load_intel_snapshot" in src, ( |
| 168 | "Symbol intel must be loaded from the pre-computed DB column, not recomputed." |
| 169 | ) |
| 170 | # Must NOT call the compute functions directly |
| 171 | assert "compute_intel(" not in src, ( |
| 172 | "ui_symbols must not call compute_intel() — intel must be pre-computed at push." |
| 173 | ) |
File History
2 commits
sha256:25d96102cb2d69a038356dff26f4633156da2f1faf98fe0d0e4438ff3f367f12
refactor: rename 0054/0055 migrations to standard convention
Sonnet 4.6
minor
⚠
20 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d
chore: doc sweep, ignore wrangler build state, misc fixes
Sonnet 4.6
minor
⚠
22 days ago