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