gabriel / musehub public
test_api_performance.py python
173 lines 7.2 KB
Raw
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