test_symbol_detail_pagination.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
22 days ago
| 1 | """Cursor-based pagination for symbol detail — provenance and coupling. |
| 2 | |
| 3 | TDD spec — tests are written before implementation. |
| 4 | |
| 5 | Provenance: 10 entries per page, cursor = committed_at ISO string of last entry. |
| 6 | Coupling: 15 entries per page, cursor = "shared_commits:address" of last row. |
| 7 | |
| 8 | URL contracts |
| 9 | ───────────── |
| 10 | GET /{owner}/{repo}/symbol/{address} → page 1 (no cursor) |
| 11 | GET /{owner}/{repo}/symbol/{address}?history_cursor=<iso> → provenance page N+1 |
| 12 | GET /{owner}/{repo}/symbol/{address}?coupling_cursor=<sha> → coupling page N+1 |
| 13 | |
| 14 | Both cursors may appear together (independent pagination). |
| 15 | |
| 16 | Tier breakdown |
| 17 | ────────────── |
| 18 | P1xx Unit — pure pagination helpers |
| 19 | P2xx Integration — route returns correct slices with real DB rows |
| 20 | P3xx HTML — template wires next-page links correctly |
| 21 | P4xx Edge cases — empty, last page, invalid cursor |
| 22 | P5xx Performance — no extra DB round-trips per page |
| 23 | P6xx Security — cursor injection does not leak data or 500 |
| 24 | """ |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import datetime as _dt |
| 28 | import typing |
| 29 | from collections.abc import MutableMapping, Sequence |
| 30 | |
| 31 | import pytest |
| 32 | from httpx import AsyncClient |
| 33 | from sqlalchemy.engine import CursorResult |
| 34 | from sqlalchemy.sql.base import Executable |
| 35 | from sqlalchemy.ext.asyncio import AsyncSession |
| 36 | from muse.core.types import blob_id |
| 37 | |
| 38 | # --------------------------------------------------------------------------- |
| 39 | # Shared constants |
| 40 | # --------------------------------------------------------------------------- |
| 41 | |
| 42 | HISTORY_PAGE = 10 |
| 43 | COUPLING_PAGE = 15 |
| 44 | |
| 45 | # --------------------------------------------------------------------------- |
| 46 | # P1xx — Unit tests (pure helpers, no DB) |
| 47 | # --------------------------------------------------------------------------- |
| 48 | |
| 49 | |
| 50 | class TestHistoryCursorParsing: |
| 51 | """P101–P104: history cursor encode/decode round-trips.""" |
| 52 | |
| 53 | def _encode(self, iso: str) -> str: |
| 54 | # The cursor IS the ISO timestamp of the last entry on the current page. |
| 55 | return iso |
| 56 | |
| 57 | def _decode(self, cursor: str) -> _dt.datetime: |
| 58 | return _dt.datetime.fromisoformat(cursor) |
| 59 | |
| 60 | def test_P101_roundtrip_utc(self) -> None: |
| 61 | """P101: ISO cursor survives encode→decode for a UTC timestamp.""" |
| 62 | ts = "2026-01-15T10:30:00+00:00" |
| 63 | assert self._decode(self._encode(ts)).isoformat() == _dt.datetime.fromisoformat(ts).isoformat() |
| 64 | |
| 65 | def test_P102_cursor_is_comparable(self) -> None: |
| 66 | """P102: decoded cursor compares correctly to committed_at values.""" |
| 67 | cursor_ts = _dt.datetime.fromisoformat("2026-01-15T10:30:00+00:00") |
| 68 | older = _dt.datetime.fromisoformat("2026-01-10T00:00:00+00:00") |
| 69 | newer = _dt.datetime.fromisoformat("2026-01-20T00:00:00+00:00") |
| 70 | assert older < cursor_ts |
| 71 | assert newer > cursor_ts |
| 72 | |
| 73 | def test_P103_page_size_constant_is_10(self) -> None: |
| 74 | """P103: HISTORY_PAGE == 10.""" |
| 75 | assert HISTORY_PAGE == 10 |
| 76 | |
| 77 | def test_P104_coupling_page_size_constant_is_15(self) -> None: |
| 78 | """P104: COUPLING_PAGE == 15.""" |
| 79 | assert COUPLING_PAGE == 15 |
| 80 | |
| 81 | |
| 82 | class TestCouplingCursorParsing: |
| 83 | """P105–P107: coupling cursor encode/decode.""" |
| 84 | |
| 85 | def _encode(self, shared: int, address: str) -> str: |
| 86 | return f"{shared}:{address}" |
| 87 | |
| 88 | def _decode(self, cursor: str) -> tuple[int, str]: |
| 89 | shared_str, _, addr = cursor.partition(":") |
| 90 | return int(shared_str), addr |
| 91 | |
| 92 | def test_P105_roundtrip(self) -> None: |
| 93 | """P105: coupling cursor survives encode→decode.""" |
| 94 | shared, addr = 7, "src/auth.py::validate_token" |
| 95 | cursor = self._encode(shared, addr) |
| 96 | s, a = self._decode(cursor) |
| 97 | assert s == shared and a == addr |
| 98 | |
| 99 | def test_P106_address_with_colons_preserved(self) -> None: |
| 100 | """P106: address containing '::' is preserved through cursor.""" |
| 101 | shared, addr = 3, "lib/core.py::MyClass::method" |
| 102 | cursor = self._encode(shared, addr) |
| 103 | s, a = self._decode(cursor) |
| 104 | assert s == shared and a == addr |
| 105 | |
| 106 | def test_P107_zero_shared_valid(self) -> None: |
| 107 | """P107: shared_commits == 0 is a valid cursor value.""" |
| 108 | cursor = self._encode(0, "src/x.py::fn") |
| 109 | s, a = self._decode(cursor) |
| 110 | assert s == 0 and a == "src/x.py::fn" |
| 111 | |
| 112 | |
| 113 | class TestPageSlicing: |
| 114 | """P108–P112: page-slice logic on in-memory lists.""" |
| 115 | |
| 116 | @staticmethod |
| 117 | def _slice(items: Sequence[int], page: int, cursor_idx: int | None = None) -> tuple[list[int], bool, str | None]: |
| 118 | """Simulate cursor pagination over a pre-sorted list.""" |
| 119 | start = cursor_idx + 1 if cursor_idx is not None else 0 |
| 120 | window = items[start : start + page + 1] |
| 121 | has_next = len(window) > page |
| 122 | page_items = window[:page] |
| 123 | next_cursor = str(start + page - 1) if has_next else None |
| 124 | return page_items, has_next, next_cursor |
| 125 | |
| 126 | def test_P108_first_page_returns_page_items(self) -> None: |
| 127 | """P108: first page of 25 items returns exactly HISTORY_PAGE items.""" |
| 128 | items = list(range(25)) |
| 129 | page, has_next, cursor = self._slice(items, HISTORY_PAGE) |
| 130 | assert len(page) == HISTORY_PAGE |
| 131 | assert has_next is True |
| 132 | assert cursor is not None |
| 133 | |
| 134 | def test_P109_last_page_has_no_next(self) -> None: |
| 135 | """P109: last page has has_next=False and cursor=None.""" |
| 136 | items = list(range(12)) # 12 items, page=10 → second page has 2 |
| 137 | page, has_next, cursor = self._slice(items, HISTORY_PAGE, cursor_idx=9) |
| 138 | assert has_next is False |
| 139 | assert cursor is None |
| 140 | |
| 141 | def test_P110_exact_fit_no_next(self) -> None: |
| 142 | """P110: exactly HISTORY_PAGE items → has_next=False.""" |
| 143 | items = list(range(HISTORY_PAGE)) |
| 144 | page, has_next, _ = self._slice(items, HISTORY_PAGE) |
| 145 | assert len(page) == HISTORY_PAGE |
| 146 | assert has_next is False |
| 147 | |
| 148 | def test_P111_empty_set_returns_empty(self) -> None: |
| 149 | """P111: zero items → empty page, has_next=False.""" |
| 150 | page, has_next, cursor = self._slice([], HISTORY_PAGE) |
| 151 | assert page == [] |
| 152 | assert has_next is False |
| 153 | assert cursor is None |
| 154 | |
| 155 | def test_P112_coupling_page_size_15(self) -> None: |
| 156 | """P112: COUPLING_PAGE slices produce at most 15 items.""" |
| 157 | items = list(range(40)) |
| 158 | page, has_next, cursor = self._slice(items, COUPLING_PAGE) |
| 159 | assert len(page) == COUPLING_PAGE |
| 160 | assert has_next is True |
| 161 | |
| 162 | |
| 163 | # --------------------------------------------------------------------------- |
| 164 | # P2xx — Integration tests (real DB, route handler) |
| 165 | # --------------------------------------------------------------------------- |
| 166 | |
| 167 | |
| 168 | @pytest.mark.asyncio |
| 169 | class TestProvenancePagination: |
| 170 | """P201–P208: provenance history pagination via history_cursor query param.""" |
| 171 | |
| 172 | async def test_P201_first_page_returns_10_entries( |
| 173 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 174 | ) -> None: |
| 175 | """P201: no cursor → exactly 10 history entries in HTML.""" |
| 176 | owner, slug, address = seed_symbol_with_26_history |
| 177 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 178 | assert resp.status_code == 200 |
| 179 | count = resp.content.count(b"sym2-tl-entry") |
| 180 | assert count == HISTORY_PAGE |
| 181 | |
| 182 | async def test_P202_first_page_has_next_link( |
| 183 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 184 | ) -> None: |
| 185 | """P202: history_cursor link present when more entries exist.""" |
| 186 | owner, slug, address = seed_symbol_with_26_history |
| 187 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 188 | assert b"history_cursor=" in resp.content |
| 189 | |
| 190 | async def test_P203_last_page_no_next_link( |
| 191 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 192 | ) -> None: |
| 193 | """P203: final history page (offset 20) has 6 entries and no next link.""" |
| 194 | owner, slug, address = seed_symbol_with_26_history |
| 195 | import re |
| 196 | # Page 2 (offset 10) → 10 entries |
| 197 | r2 = await client.get(f"/{owner}/{slug}/symbol/{address}?history_cursor=10") |
| 198 | assert r2.status_code == 200 |
| 199 | assert r2.content.count(b"sym2-tl-entry") == HISTORY_PAGE |
| 200 | # Page 3 (offset 20) → 6 remaining, no --next link |
| 201 | r3 = await client.get(f"/{owner}/{slug}/symbol/{address}?history_cursor=20") |
| 202 | assert r3.status_code == 200 |
| 203 | assert r3.content.count(b"sym2-tl-entry") == 6 |
| 204 | assert not re.search(rb'sym2-page-btn--next', r3.content) |
| 205 | |
| 206 | async def test_P204_no_cursor_shows_newest_first( |
| 207 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 208 | ) -> None: |
| 209 | """P204: first page shows most recent 10 entries (newest → oldest).""" |
| 210 | owner, slug, address = seed_symbol_with_26_history |
| 211 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 212 | # The newest commit message contains 'entry-25' (seeded newest-last, displayed newest-first) |
| 213 | assert b"entry-25" in resp.content |
| 214 | assert b"entry-0" not in resp.content |
| 215 | |
| 216 | async def test_P205_cursor_page_does_not_overlap_previous( |
| 217 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 218 | ) -> None: |
| 219 | """P205: page 2 entries are disjoint from page 1.""" |
| 220 | owner, slug, address = seed_symbol_with_26_history |
| 221 | import re |
| 222 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 223 | m = re.search(rb'history_cursor=([^"&]+)', r1.content) |
| 224 | cursor = m.group(1).decode() |
| 225 | r2 = await client.get(f"/{owner}/{slug}/symbol/{address}?history_cursor={cursor}") |
| 226 | # entry-15 should be on page 2, not page 1 |
| 227 | assert b"entry-15" not in r1.content |
| 228 | assert b"entry-15" in r2.content |
| 229 | |
| 230 | async def test_P206_10_or_fewer_entries_no_pagination( |
| 231 | self, client: AsyncClient, seed_symbol: tuple[str, str, str] |
| 232 | ) -> None: |
| 233 | """P206: symbol with 1 entry shows no history_cursor link.""" |
| 234 | owner, slug, address = seed_symbol |
| 235 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 236 | assert resp.status_code == 200 |
| 237 | assert b"history_cursor=" not in resp.content |
| 238 | |
| 239 | async def test_P207_history_total_count_in_context( |
| 240 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 241 | ) -> None: |
| 242 | """P207: page renders total provenance count for 'showing X of N' display.""" |
| 243 | owner, slug, address = seed_symbol_with_26_history |
| 244 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 245 | # change_count drives the vitals quad and narrative |
| 246 | assert b"26" in resp.content |
| 247 | |
| 248 | async def test_P208_invalid_history_cursor_returns_200_first_page( |
| 249 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 250 | ) -> None: |
| 251 | """P208: malformed cursor falls back to first page gracefully.""" |
| 252 | owner, slug, address = seed_symbol_with_26_history |
| 253 | resp = await client.get( |
| 254 | f"/{owner}/{slug}/symbol/{address}?history_cursor=not-a-date" |
| 255 | ) |
| 256 | assert resp.status_code == 200 |
| 257 | # Should render first page normally |
| 258 | assert resp.content.count(b"sym2-tl-entry") == HISTORY_PAGE |
| 259 | |
| 260 | |
| 261 | @pytest.mark.asyncio |
| 262 | class TestCouplingPagination: |
| 263 | """P211–P218: coupling partners pagination via coupling_cursor query param.""" |
| 264 | |
| 265 | async def test_P211_first_page_returns_15_partners( |
| 266 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 267 | ) -> None: |
| 268 | """P211: no cursor → exactly 15 coupling rows.""" |
| 269 | owner, slug, address = seed_symbol_high_coupling_40 |
| 270 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 271 | assert resp.status_code == 200 |
| 272 | assert resp.content.count(b"sym2-blast-row") == COUPLING_PAGE |
| 273 | |
| 274 | async def test_P212_first_page_has_coupling_next_link( |
| 275 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 276 | ) -> None: |
| 277 | """P212: coupling_cursor link present when more partners exist.""" |
| 278 | owner, slug, address = seed_symbol_high_coupling_40 |
| 279 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 280 | assert b"coupling_cursor=" in resp.content |
| 281 | |
| 282 | async def test_P213_second_page_returns_remaining( |
| 283 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 284 | ) -> None: |
| 285 | """P213: page 2 returns the remaining 25 partners (capped at 15).""" |
| 286 | owner, slug, address = seed_symbol_high_coupling_40 |
| 287 | import re |
| 288 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 289 | m = re.search(rb'coupling_cursor=([^"&]+)', r1.content) |
| 290 | assert m |
| 291 | cursor = m.group(1).decode() |
| 292 | r2 = await client.get(f"/{owner}/{slug}/symbol/{address}?coupling_cursor={cursor}") |
| 293 | assert r2.status_code == 200 |
| 294 | assert r2.content.count(b"sym2-blast-row") == COUPLING_PAGE |
| 295 | |
| 296 | async def test_P214_last_coupling_page_no_next_link( |
| 297 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 298 | ) -> None: |
| 299 | """P214: final coupling page has no coupling_cursor link.""" |
| 300 | owner, slug, address = seed_symbol_high_coupling_40 |
| 301 | import re |
| 302 | # 40 partners: p1=15 (offset 0), p2=15 (offset 15), p3=10 (offset 30) |
| 303 | r3 = await client.get(f"/{owner}/{slug}/symbol/{address}?coupling_cursor=30") |
| 304 | assert r3.status_code == 200 |
| 305 | assert r3.content.count(b"sym2-blast-row") == 10 |
| 306 | # Coupling section has no "Next ›" link (history may still have "Older ›") |
| 307 | assert b"Next \xe2\x80\xba" not in r3.content |
| 308 | |
| 309 | async def test_P215_fewer_than_15_partners_no_pagination( |
| 310 | self, client: AsyncClient, seed_symbol: tuple[str, str, str] |
| 311 | ) -> None: |
| 312 | """P215: symbol with 0 coupling partners shows no coupling_cursor link.""" |
| 313 | owner, slug, address = seed_symbol |
| 314 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 315 | assert b"coupling_cursor=" not in resp.content |
| 316 | |
| 317 | async def test_P216_both_cursors_independent( |
| 318 | self, client: AsyncClient, seed_symbol_with_26_history_and_40_coupling: tuple[str, str, str] |
| 319 | ) -> None: |
| 320 | """P216: history_cursor and coupling_cursor paginate independently.""" |
| 321 | owner, slug, address = seed_symbol_with_26_history_and_40_coupling |
| 322 | import re |
| 323 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 324 | hc = re.search(rb'history_cursor=([^"&]+)', r1.content) |
| 325 | cc = re.search(rb'coupling_cursor=([^"&]+)', r1.content) |
| 326 | assert hc and cc |
| 327 | # Advance only history cursor |
| 328 | r2 = await client.get( |
| 329 | f"/{owner}/{slug}/symbol/{address}" |
| 330 | f"?history_cursor={hc.group(1).decode()}" |
| 331 | ) |
| 332 | assert r2.status_code == 200 |
| 333 | assert r2.content.count(b"sym2-blast-row") == COUPLING_PAGE |
| 334 | |
| 335 | async def test_P217_invalid_coupling_cursor_returns_200_first_page( |
| 336 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 337 | ) -> None: |
| 338 | """P217: malformed coupling cursor falls back to first page.""" |
| 339 | owner, slug, address = seed_symbol_high_coupling_40 |
| 340 | resp = await client.get( |
| 341 | f"/{owner}/{slug}/symbol/{address}?coupling_cursor=garbage" |
| 342 | ) |
| 343 | assert resp.status_code == 200 |
| 344 | assert resp.content.count(b"sym2-blast-row") == COUPLING_PAGE |
| 345 | |
| 346 | async def test_P218_coupling_ordered_by_shared_commits_desc( |
| 347 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 348 | ) -> None: |
| 349 | """P218: coupling page 1 contains highest-shared partners first.""" |
| 350 | owner, slug, address = seed_symbol_high_coupling_40 |
| 351 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 352 | # The fixture seeds partners with shared counts 40..1; highest first |
| 353 | assert b"40\xc3\x97" in resp.content or b"40" in resp.content # top partner count |
| 354 | |
| 355 | |
| 356 | # --------------------------------------------------------------------------- |
| 357 | # P3xx — HTML structural tests |
| 358 | # --------------------------------------------------------------------------- |
| 359 | |
| 360 | |
| 361 | @pytest.mark.asyncio |
| 362 | class TestPaginationHTML: |
| 363 | """P301–P306: template renders pagination controls correctly.""" |
| 364 | |
| 365 | async def test_P301_next_history_link_is_anchor( |
| 366 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 367 | ) -> None: |
| 368 | """P301: 'Load more' history link is an <a> tag with history_cursor param.""" |
| 369 | owner, slug, address = seed_symbol_with_26_history |
| 370 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 371 | assert b'href=' in resp.content |
| 372 | assert b'history_cursor=' in resp.content |
| 373 | |
| 374 | async def test_P302_next_coupling_link_is_anchor( |
| 375 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 376 | ) -> None: |
| 377 | """P302: 'Load more' coupling link is an <a> tag with coupling_cursor param.""" |
| 378 | owner, slug, address = seed_symbol_high_coupling_40 |
| 379 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 380 | assert b'href=' in resp.content |
| 381 | assert b'coupling_cursor=' in resp.content |
| 382 | |
| 383 | async def test_P303_page_indicator_present( |
| 384 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 385 | ) -> None: |
| 386 | """P303: provenance section shows current count and total.""" |
| 387 | owner, slug, address = seed_symbol_with_26_history |
| 388 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 389 | # e.g. "10 of 26" or "Showing 10" |
| 390 | assert b"10" in resp.content and b"26" in resp.content |
| 391 | |
| 392 | async def test_P304_no_controls_when_single_page(self, client: AsyncClient, seed_symbol: tuple[str, str, str]) -> None: |
| 393 | """P304: no pagination controls rendered for single-page symbol.""" |
| 394 | owner, slug, address = seed_symbol |
| 395 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 396 | assert b"history_cursor=" not in resp.content |
| 397 | assert b"coupling_cursor=" not in resp.content |
| 398 | |
| 399 | async def test_P305_cursor_preserved_in_coupling_next_link( |
| 400 | self, client: AsyncClient, seed_symbol_with_26_history_and_40_coupling: tuple[str, str, str] |
| 401 | ) -> None: |
| 402 | """P305: coupling next link preserves active history_cursor in href.""" |
| 403 | owner, slug, address = seed_symbol_with_26_history_and_40_coupling |
| 404 | import re |
| 405 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 406 | hc = re.search(rb'history_cursor=([^"&]+)', r1.content).group(1).decode() |
| 407 | # Navigate to history page 2 while on coupling page 1 |
| 408 | r2 = await client.get( |
| 409 | f"/{owner}/{slug}/symbol/{address}?history_cursor={hc}" |
| 410 | ) |
| 411 | assert r2.status_code == 200 |
| 412 | # coupling next link in r2 must carry history_cursor forward |
| 413 | cc_links = [m.group() for m in re.finditer(rb'href="[^"]*coupling_cursor=[^"]*"', r2.content)] |
| 414 | assert any(hc.encode() in link for link in cc_links) |
| 415 | |
| 416 | async def test_P306_history_next_link_preserves_coupling_cursor( |
| 417 | self, client: AsyncClient, seed_symbol_with_26_history_and_40_coupling: tuple[str, str, str] |
| 418 | ) -> None: |
| 419 | """P306: history next link preserves active coupling_cursor in href.""" |
| 420 | owner, slug, address = seed_symbol_with_26_history_and_40_coupling |
| 421 | import re |
| 422 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 423 | cc = re.search(rb'coupling_cursor=([^"&]+)', r1.content).group(1).decode() |
| 424 | r2 = await client.get( |
| 425 | f"/{owner}/{slug}/symbol/{address}?coupling_cursor={cc}" |
| 426 | ) |
| 427 | assert r2.status_code == 200 |
| 428 | hc_links = [m.group() for m in re.finditer(rb'href="[^"]*history_cursor=[^"]*"', r2.content)] |
| 429 | assert any(cc.encode() in link for link in hc_links) |
| 430 | |
| 431 | |
| 432 | # --------------------------------------------------------------------------- |
| 433 | # P4xx — Edge cases |
| 434 | # --------------------------------------------------------------------------- |
| 435 | |
| 436 | |
| 437 | @pytest.mark.asyncio |
| 438 | class TestPaginationEdgeCases: |
| 439 | """P401–P406: boundary conditions.""" |
| 440 | |
| 441 | async def test_P401_exactly_10_history_no_next(self, client: AsyncClient, seed_symbol_with_exactly_10_history: tuple[str, str, str]) -> None: |
| 442 | """P401: exactly 10 history entries → no history_cursor link.""" |
| 443 | owner, slug, address = seed_symbol_with_exactly_10_history |
| 444 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 445 | assert resp.status_code == 200 |
| 446 | assert resp.content.count(b"sym2-tl-entry") == HISTORY_PAGE |
| 447 | assert b"history_cursor=" not in resp.content |
| 448 | |
| 449 | async def test_P402_exactly_11_history_has_next(self, client: AsyncClient, seed_symbol_with_11_history: tuple[str, str, str]) -> None: |
| 450 | """P402: 11 history entries → first page has next link.""" |
| 451 | owner, slug, address = seed_symbol_with_11_history |
| 452 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 453 | assert resp.content.count(b"sym2-tl-entry") == HISTORY_PAGE |
| 454 | assert b"history_cursor=" in resp.content |
| 455 | |
| 456 | async def test_P403_exactly_15_coupling_no_next(self, client: AsyncClient, seed_symbol_with_exactly_15_coupling: tuple[str, str, str]) -> None: |
| 457 | """P403: exactly 15 coupling partners → no coupling_cursor link.""" |
| 458 | owner, slug, address = seed_symbol_with_exactly_15_coupling |
| 459 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 460 | assert resp.content.count(b"sym2-blast-row") == COUPLING_PAGE |
| 461 | assert b"coupling_cursor=" not in resp.content |
| 462 | |
| 463 | async def test_P404_exactly_16_coupling_has_next(self, client: AsyncClient, seed_symbol_with_16_coupling: tuple[str, str, str]) -> None: |
| 464 | """P404: 16 coupling partners → first page has next link.""" |
| 465 | owner, slug, address = seed_symbol_with_16_coupling |
| 466 | resp = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 467 | assert resp.content.count(b"sym2-blast-row") == COUPLING_PAGE |
| 468 | assert b"coupling_cursor=" in resp.content |
| 469 | |
| 470 | async def test_P405_past_cursor_returns_empty_page( |
| 471 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 472 | ) -> None: |
| 473 | """P405: offset past the end of history returns empty timeline.""" |
| 474 | owner, slug, address = seed_symbol_with_26_history |
| 475 | resp = await client.get( |
| 476 | f"/{owner}/{slug}/symbol/{address}?history_cursor=10000" |
| 477 | ) |
| 478 | assert resp.status_code == 200 |
| 479 | assert resp.content.count(b"sym2-tl-entry") == 0 |
| 480 | |
| 481 | async def test_P406_both_cursors_on_last_pages_no_links( |
| 482 | self, client: AsyncClient, seed_symbol_with_26_history_and_40_coupling: tuple[str, str, str] |
| 483 | ) -> None: |
| 484 | """P406: when both paginations are on final pages, no cursor links appear. |
| 485 | |
| 486 | Fixture: 26 history entries (3 pages: 10+10+6) + 26 coupling partners |
| 487 | (2 pages: 15+11). Exhaust both to confirm no pagination links remain. |
| 488 | """ |
| 489 | owner, slug, address = seed_symbol_with_26_history_and_40_coupling |
| 490 | import re |
| 491 | # Page 1: has both cursors |
| 492 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 493 | # Fixture: 26 history (3 pages: 0,10,20) + 26 coupling (2 pages: 0,15) |
| 494 | # Final request: history page 3 (last) + coupling page 2 (last) |
| 495 | r_final = await client.get( |
| 496 | f"/{owner}/{slug}/symbol/{address}?history_cursor=20&coupling_cursor=15" |
| 497 | ) |
| 498 | assert r_final.status_code == 200 |
| 499 | # Neither section has a next button |
| 500 | assert b"Older \xe2\x80\xba" not in r_final.content # no history next |
| 501 | assert b"Next \xe2\x80\xba" not in r_final.content # no coupling next |
| 502 | |
| 503 | |
| 504 | # --------------------------------------------------------------------------- |
| 505 | # P5xx — Performance |
| 506 | # --------------------------------------------------------------------------- |
| 507 | |
| 508 | |
| 509 | @pytest.mark.asyncio |
| 510 | class TestPaginationPerformance: |
| 511 | """P501–P503: pagination does not increase DB round-trips.""" |
| 512 | |
| 513 | async def test_P501_history_page2_same_query_count_as_page1( |
| 514 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str], db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch |
| 515 | ) -> None: |
| 516 | """P501: paginated request makes no more DB calls than page 1.""" |
| 517 | import re |
| 518 | r1 = await client.get( |
| 519 | f"/{seed_symbol_with_26_history[0]}/{seed_symbol_with_26_history[1]}" |
| 520 | f"/symbol/{seed_symbol_with_26_history[2]}" |
| 521 | ) |
| 522 | hc = re.search(rb'history_cursor=([^"&]+)', r1.content).group(1).decode() |
| 523 | |
| 524 | call_counts: list[int] = [] |
| 525 | for cursor in [None, hc]: |
| 526 | count = {"n": 0} |
| 527 | orig = db_session.execute |
| 528 | async def spy(*a: Executable | typing.Any, _c: MutableMapping[str, int] = count, _o: typing.Any = orig, **kw: typing.Any) -> CursorResult[typing.Any]: |
| 529 | _c["n"] += 1 |
| 530 | return await _o(*a, **kw) |
| 531 | monkeypatch.setattr(db_session, "execute", spy) |
| 532 | url = (f"/{seed_symbol_with_26_history[0]}/{seed_symbol_with_26_history[1]}" |
| 533 | f"/symbol/{seed_symbol_with_26_history[2]}") |
| 534 | if cursor: |
| 535 | url += f"?history_cursor={cursor}" |
| 536 | await client.get(url) |
| 537 | call_counts.append(count["n"]) |
| 538 | monkeypatch.undo() |
| 539 | |
| 540 | assert call_counts[1] <= call_counts[0] + 1 # at most 1 extra call |
| 541 | |
| 542 | async def test_P502_coupling_page_uses_offset_not_python_scan(self) -> None: |
| 543 | """P502: coupling cursor pagination uses SQL WHERE, not Python list slice.""" |
| 544 | from sqlalchemy import select, func as sa_func |
| 545 | from musehub.db.musehub_intel_models import MusehubSymbolHistoryEntry |
| 546 | # Verify that the query accepts a LIMIT and the architecture allows WHERE for cursor |
| 547 | stmt = ( |
| 548 | select( |
| 549 | MusehubSymbolHistoryEntry.address, |
| 550 | sa_func.count().label("shared"), |
| 551 | ) |
| 552 | .where(MusehubSymbolHistoryEntry.repo_id == "x") |
| 553 | .group_by(MusehubSymbolHistoryEntry.address) |
| 554 | .order_by(sa_func.count().desc()) |
| 555 | .limit(COUPLING_PAGE + 1) |
| 556 | ) |
| 557 | compiled = str(stmt.compile(compile_kwargs={"literal_binds": False})) |
| 558 | assert "LIMIT" in compiled.upper() |
| 559 | assert "GROUP BY" in compiled.upper() |
| 560 | |
| 561 | async def test_P503_render_time_under_300ms( |
| 562 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str], benchmark_timer: typing.Callable[[float], typing.ContextManager[None]] |
| 563 | ) -> None: |
| 564 | """P503: paginated page renders in < 300ms.""" |
| 565 | owner, slug, address = seed_symbol_with_26_history |
| 566 | import re |
| 567 | r1 = await client.get(f"/{owner}/{slug}/symbol/{address}") |
| 568 | hc = re.search(rb'history_cursor=([^"&]+)', r1.content).group(1).decode() |
| 569 | with benchmark_timer(max_ms=300): |
| 570 | resp = await client.get( |
| 571 | f"/{owner}/{slug}/symbol/{address}?history_cursor={hc}" |
| 572 | ) |
| 573 | assert resp.status_code == 200 |
| 574 | |
| 575 | |
| 576 | # --------------------------------------------------------------------------- |
| 577 | # P6xx — Security |
| 578 | # --------------------------------------------------------------------------- |
| 579 | |
| 580 | |
| 581 | @pytest.mark.asyncio |
| 582 | class TestPaginationSecurity: |
| 583 | """P601–P604: cursor values cannot leak data or cause server errors.""" |
| 584 | |
| 585 | async def test_P601_sql_injection_in_history_cursor( |
| 586 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 587 | ) -> None: |
| 588 | """P601: SQL injection in history_cursor param → 200 first page, no 500.""" |
| 589 | owner, slug, address = seed_symbol_with_26_history |
| 590 | resp = await client.get( |
| 591 | f"/{owner}/{slug}/symbol/{address}" |
| 592 | "?history_cursor='; DROP TABLE musehub_symbol_history_entries; --" |
| 593 | ) |
| 594 | assert resp.status_code == 200 |
| 595 | |
| 596 | async def test_P602_sql_injection_in_coupling_cursor( |
| 597 | self, client: AsyncClient, seed_symbol_high_coupling_40: tuple[str, str, str] |
| 598 | ) -> None: |
| 599 | """P602: SQL injection in coupling_cursor param → 200 first page, no 500.""" |
| 600 | owner, slug, address = seed_symbol_high_coupling_40 |
| 601 | resp = await client.get( |
| 602 | f"/{owner}/{slug}/symbol/{address}" |
| 603 | "?coupling_cursor=0:x' OR '1'='1" |
| 604 | ) |
| 605 | assert resp.status_code == 200 |
| 606 | |
| 607 | async def test_P603_xss_in_history_cursor_escaped( |
| 608 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 609 | ) -> None: |
| 610 | """P603: XSS payload in history_cursor is never reflected raw in HTML.""" |
| 611 | owner, slug, address = seed_symbol_with_26_history |
| 612 | resp = await client.get( |
| 613 | f"/{owner}/{slug}/symbol/{address}" |
| 614 | "?history_cursor=<script>alert(1)</script>" |
| 615 | ) |
| 616 | assert b"<script>alert(1)</script>" not in resp.content |
| 617 | |
| 618 | async def test_P604_very_long_cursor_no_500( |
| 619 | self, client: AsyncClient, seed_symbol_with_26_history: tuple[str, str, str] |
| 620 | ) -> None: |
| 621 | """P604: cursor > 512 chars returns 200 (graceful fallback) never 500.""" |
| 622 | owner, slug, address = seed_symbol_with_26_history |
| 623 | long_cursor = "x" * 600 |
| 624 | resp = await client.get( |
| 625 | f"/{owner}/{slug}/symbol/{address}?history_cursor={long_cursor}" |
| 626 | ) |
| 627 | assert resp.status_code == 200 |
| 628 | |
| 629 | |
| 630 | # --------------------------------------------------------------------------- |
| 631 | # New fixtures (appended to conftest.py separately) |
| 632 | # --------------------------------------------------------------------------- |
| 633 | # The fixtures below are declared here as documentation of what conftest.py |
| 634 | # must provide. They are moved to conftest.py by the implementation step. |
| 635 | |
| 636 | """ |
| 637 | Required new fixtures: |
| 638 | |
| 639 | seed_symbol_with_26_history |
| 640 | → repo + symbol with 26 history entries spaced 1h apart, newest last. |
| 641 | Commit messages contain 'entry-{i}' for i in 0..25. |
| 642 | |
| 643 | seed_symbol_high_coupling_40 |
| 644 | → repo + symbol with 1 history entry + 40 partner symbols each sharing |
| 645 | that 1 commit, with shared_commits values 40..1 (descending by address). |
| 646 | |
| 647 | seed_symbol_with_26_history_and_40_coupling |
| 648 | → combines both: 26 history entries + 40 coupling partners. |
| 649 | |
| 650 | seed_symbol_with_exactly_10_history |
| 651 | → repo + symbol with exactly 10 history entries. |
| 652 | |
| 653 | seed_symbol_with_11_history |
| 654 | → repo + symbol with exactly 11 history entries. |
| 655 | |
| 656 | seed_symbol_with_exactly_15_coupling |
| 657 | → repo + symbol with 15 coupling partners. |
| 658 | |
| 659 | seed_symbol_with_16_coupling |
| 660 | → repo + symbol with 16 coupling partners. |
| 661 | """ |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
22 days ago