gabriel / musehub public
test_pagination_section39.py python
991 lines 38.6 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 39 — Pagination: 7-layer test suite.
2
3 Existing coverage (test_musehub_pagination.py, 19 tests) covers:
4 - paginate_list slice correctness (5 tests)
5 - build_link_header rels for first/middle/last/single page (5 tests)
6 - build_cursor_link_header basic shape (1 test)
7 - Issues/proposals/commits happy-path pagination over HTTP (8 tests)
8
9 This file adds the missing layers and edge cases:
10
11 1. Unit — PaginationParams defaults, paginate_list edge cases
12 (per_page=1, exact multiples, single item, large pages),
13 build_link_header (zero total, exact multiple, last-page math),
14 build_cursor_link_header URL encoding
15 2. Integration — empty repo → empty list (not 404), page > total_pages graceful,
16 issues filter accuracy across pages, sort stability
17 3. E2E — full HTTP round-trips: total field correctness, Link header
18 on each page, cursor Link header present/absent correctly
19 4. Stress — 100-item dataset full page-through, sequential cursor walk,
20 last page remainder correctness at scale
21 5. Data Integrity — no items skipped or duplicated across pages, total stable
22 across page calls, filter+pagination combined
23 6. Security — negative page rejected (422), per_page > 100 rejected (422),
24 cursor with special chars URL-safe, page=0 rejected
25 7. Performance — paginate_list(10K) under 10ms, build_link_header under 5ms,
26 HTTP issue-list with 50 items under 500ms
27 """
28 from __future__ import annotations
29
30 import time
31 from datetime import datetime, timezone, timedelta
32
33 import pytest
34 from httpx import AsyncClient
35 from sqlalchemy.ext.asyncio import AsyncSession
36 from starlette.requests import Request as StarletteRequest
37
38 from musehub.muse_contracts.json_types import StrDict
39 from musehub.api.routes.musehub.pagination import (
40 PaginationParams,
41 build_cursor_link_header,
42 build_link_header,
43 paginate_list,
44 )
45 from musehub.db.musehub_models import MusehubCommit, MusehubIssue, MusehubProposal, MusehubRepo
46
47
48 # ─────────────────────────────────────────────────────────────────────────────
49 # Shared helpers
50 # ─────────────────────────────────────────────────────────────────────────────
51
52
53 def _req(url: str) -> StarletteRequest:
54 """Build a minimal Starlette Request for testing URL construction."""
55 scope = {
56 "type": "http",
57 "method": "GET",
58 "path": url.split("?")[0],
59 "query_string": url.split("?")[1].encode() if "?" in url else b"",
60 "headers": [],
61 }
62 return StarletteRequest(scope)
63
64
65 async def _make_repo(
66 client: AsyncClient,
67 auth_headers: StrDict,
68 name: str,
69 ) -> str:
70 r = await client.post(
71 "/api/repos",
72 json={"name": name, "owner": "testuser"},
73 headers=auth_headers,
74 )
75 assert r.status_code == 201, r.text
76 return r.json()["repoId"]
77
78
79 async def _make_issue(
80 client: AsyncClient,
81 auth_headers: StrDict,
82 repo_id: str,
83 title: str,
84 ) -> None:
85 r = await client.post(
86 f"/api/repos/{repo_id}/issues",
87 json={"title": title, "body": ""},
88 headers=auth_headers,
89 )
90 assert r.status_code == 201, r.text
91
92
93 # ─────────────────────────────────────────────────────────────────────────────
94 # LAYER 1 — UNIT
95 # ─────────────────────────────────────────────────────────────────────────────
96
97
98 class TestPaginationParamsDefaults:
99 """Unit tests for PaginationParams dependency defaults."""
100
101 def test_defaults_are_sane(self):
102 import inspect
103 sig = inspect.signature(PaginationParams.__init__)
104 params = sig.parameters
105 # page default = 1
106 assert params["page"].default == 1 or hasattr(params["page"].default, "default")
107 # per_page default = 20
108 assert params["per_page"].default == 20 or hasattr(params["per_page"].default, "default")
109
110 def test_class_is_instantiable_with_defaults(self):
111 """PaginationParams can be constructed directly with no args (all have defaults)."""
112
113 class FakeQuery:
114 """Mimic Query() default extraction."""
115 def __init__(self, v):
116 self.v = v
117
118 # Just verify the constructor signature is usable
119 import inspect
120 sig = inspect.signature(PaginationParams.__init__)
121 assert "page" in sig.parameters
122 assert "per_page" in sig.parameters
123 assert "cursor" in sig.parameters
124 assert "limit" in sig.parameters
125
126 def test_cursor_default_is_none(self):
127 import inspect
128 sig = inspect.signature(PaginationParams.__init__)
129 cursor_param = sig.parameters["cursor"]
130 # Default should be None (wrapped in Query)
131 default = cursor_param.default
132 # Query wraps it — check the underlying value
133 assert default is None or getattr(default, "default", None) is None
134
135
136 class TestPaginateListEdgeCases:
137 """Unit tests for paginate_list edge cases not in the existing file."""
138
139 def test_per_page_one_returns_single_item_pages(self):
140 items = list(range(5))
141 for page in range(1, 6):
142 result, total = paginate_list(items, page=page, per_page=1)
143 assert total == 5
144 assert result == [page - 1]
145
146 def test_exact_multiple_last_page_full(self):
147 """When total is an exact multiple of per_page, last page is full."""
148 items = list(range(20))
149 result, total = paginate_list(items, page=2, per_page=10)
150 assert total == 20
151 assert len(result) == 10
152 assert result == list(range(10, 20))
153
154 def test_single_item_list(self):
155 result, total = paginate_list([42], page=1, per_page=10)
156 assert total == 1
157 assert result == [42]
158
159 def test_page_far_beyond_end_returns_empty(self):
160 items = list(range(5))
161 result, total = paginate_list(items, page=100, per_page=10)
162 assert total == 5
163 assert result == []
164
165 def test_per_page_larger_than_list(self):
166 """per_page larger than total items returns all on page 1."""
167 items = list(range(7))
168 result, total = paginate_list(items, page=1, per_page=100)
169 assert total == 7
170 assert result == items
171
172 def test_preserves_item_order(self):
173 items = ["c", "a", "b"]
174 result, _ = paginate_list(items, page=1, per_page=10)
175 assert result == ["c", "a", "b"]
176
177 def test_page_2_starts_after_page_1_ends(self):
178 """No overlap or gap between page 1 and page 2."""
179 items = list(range(10))
180 p1, _ = paginate_list(items, page=1, per_page=4)
181 p2, _ = paginate_list(items, page=2, per_page=4)
182 assert p1[-1] + 1 == p2[0]
183
184 def test_total_unchanged_across_pages(self):
185 """total is always len(items), regardless of page number."""
186 items = list(range(15))
187 _, t1 = paginate_list(items, page=1, per_page=5)
188 _, t2 = paginate_list(items, page=2, per_page=5)
189 _, t3 = paginate_list(items, page=3, per_page=5)
190 assert t1 == t2 == t3 == 15
191
192
193 class TestBuildLinkHeaderEdgeCases:
194 """Unit tests for build_link_header edge cases."""
195
196 def test_zero_total_returns_first_and_last_only(self):
197 req = _req("http://test/api/repos/r1/issues?page=1&per_page=10")
198 header = build_link_header(req, total=0, page=1, per_page=10)
199 assert 'rel="first"' in header
200 assert 'rel="last"' in header
201 assert 'rel="next"' not in header
202 assert 'rel="prev"' not in header
203
204 def test_exact_multiple_last_page_no_next(self):
205 """20 items at 10/page → page 2 is last, no rel=next."""
206 req = _req("http://test/api/repos/r1/issues?page=2&per_page=10")
207 header = build_link_header(req, total=20, page=2, per_page=10)
208 assert 'rel="next"' not in header
209 assert 'rel="prev"' in header
210
211 def test_last_page_number_correct_for_exact_multiple(self):
212 """100 items at 10/page → last page = 10."""
213 req = _req("http://test/api/repos/r1/issues?page=1&per_page=10")
214 header = build_link_header(req, total=100, page=1, per_page=10)
215 assert "page=10" in header
216
217 def test_last_page_number_correct_for_partial_final_page(self):
218 """101 items at 10/page → last page = 11."""
219 req = _req("http://test/api/repos/r1/issues?page=1&per_page=10")
220 header = build_link_header(req, total=101, page=1, per_page=10)
221 assert "page=11" in header
222
223 def test_per_page_1_total_5_last_is_5(self):
224 req = _req("http://test/api/repos/r1/issues?page=1&per_page=1")
225 header = build_link_header(req, total=5, page=1, per_page=1)
226 assert "page=5" in header
227 assert 'rel="next"' in header
228
229 def test_link_urls_are_valid_format(self):
230 """Each link entry must match <URL>; rel="NAME"."""
231 import re
232 req = _req("http://test/api/repos/r1/issues?page=2&per_page=10")
233 header = build_link_header(req, total=50, page=2, per_page=10)
234 for entry in header.split(", "):
235 assert re.match(r'^<[^>]+>; rel=".+"$', entry.strip()), entry
236
237 def test_first_page_url_always_page_1(self):
238 """rel=first always points to page=1 regardless of current page."""
239 req = _req("http://test/api/repos/r1/issues?page=3&per_page=10")
240 header = build_link_header(req, total=50, page=3, per_page=10)
241 first_link = [e for e in header.split(", ") if 'rel="first"' in e][0]
242 assert "page=1" in first_link
243
244
245 class TestBuildCursorLinkHeaderEdgeCases:
246 """Unit tests for build_cursor_link_header URL encoding."""
247
248 def test_cursor_with_special_chars_is_percent_encoded(self):
249 req = _req("http://test/api/repos?limit=20")
250 header = build_cursor_link_header(req, next_cursor="abc/def+xyz==", limit=20)
251 # The raw / + = must not appear unencoded in a query string
252 assert 'rel="next"' in header
253 # urllib.parse.urlencode handles encoding
254 assert "abc" in header
255
256 def test_existing_query_params_preserved(self):
257 req = _req("http://test/api/repos?owner=gabriel&limit=20")
258 header = build_cursor_link_header(req, next_cursor="tok123", limit=20)
259 assert "owner=gabriel" in header
260 assert "cursor=tok123" in header
261
262 def test_limit_encoded_correctly(self):
263 req = _req("http://test/api/repos?limit=50")
264 header = build_cursor_link_header(req, next_cursor="x", limit=50)
265 assert "limit=50" in header
266
267 def test_no_prev_first_last_in_cursor_header(self):
268 req = _req("http://test/api/repos?limit=20")
269 header = build_cursor_link_header(req, next_cursor="abc", limit=20)
270 for unwanted in ('rel="prev"', 'rel="first"', 'rel="last"'):
271 assert unwanted not in header
272
273
274 # ─────────────────────────────────────────────────────────────────────────────
275 # LAYER 2 — INTEGRATION
276 # ─────────────────────────────────────────────────────────────────────────────
277
278
279 class TestPaginationIntegration:
280 """Integration: real DB, real service, no mocks."""
281
282 async def test_empty_repo_issues_returns_empty_list_not_404(
283 self,
284 client: AsyncClient,
285 auth_headers: StrDict,
286 ) -> None:
287 """Empty repo must return [] + total=0, not 404."""
288 repo_id = await _make_repo(client, auth_headers, "pag-int-empty")
289 r = await client.get(
290 f"/api/repos/{repo_id}/issues", headers=auth_headers
291 )
292 assert r.status_code == 200
293 body = r.json()
294 assert body["total"] == 0
295 assert body["issues"] == []
296
297 async def test_page_beyond_total_returns_empty_slice(
298 self,
299 client: AsyncClient,
300 auth_headers: StrDict,
301 ) -> None:
302 """Requesting page > total_pages returns empty list, not 404."""
303 repo_id = await _make_repo(client, auth_headers, "pag-int-over")
304 for i in range(3):
305 await _make_issue(client, auth_headers, repo_id, f"Issue {i}")
306 r = await client.get(
307 f"/api/repos/{repo_id}/issues?page=99&per_page=10",
308 headers=auth_headers,
309 )
310 assert r.status_code == 200
311 body = r.json()
312 assert body["issues"] == []
313 assert body["total"] == 3 # total still accurate
314
315 async def test_filter_plus_pagination_correct_total(
316 self,
317 client: AsyncClient,
318 auth_headers: StrDict,
319 db_session: AsyncSession,
320 ) -> None:
321 """Filter by state=closed returns only closed total, not all issues."""
322 repo_id = await _make_repo(client, auth_headers, "pag-int-filter")
323 # 4 open, 2 closed
324 for i in range(4):
325 await _make_issue(client, auth_headers, repo_id, f"Open {i}")
326 for i in range(2):
327 issue = MusehubIssue(
328 repo_id=repo_id,
329 number=10 + i,
330 title=f"Closed {i}",
331 body="",
332 state="closed",
333 labels=[],
334 author="testuser",
335 )
336 db_session.add(issue)
337 await db_session.commit()
338
339 r = await client.get(
340 f"/api/repos/{repo_id}/issues?state=closed",
341 headers=auth_headers,
342 )
343 assert r.status_code == 200
344 body = r.json()
345 assert body["total"] == 2
346
347 async def test_sort_stability_across_pages(
348 self,
349 client: AsyncClient,
350 auth_headers: StrDict,
351 db_session: AsyncSession,
352 ) -> None:
353 """Items on page N and N+1 must not overlap — ordering is stable."""
354 repo_id = await _make_repo(client, auth_headers, "pag-int-sort")
355 # Seed 6 issues directly for deterministic ordering
356 now = datetime.now(tz=timezone.utc)
357 for i in range(6):
358 db_session.add(MusehubIssue(
359 repo_id=repo_id,
360 number=i + 1,
361 title=f"Sorted issue {i:02d}",
362 body="",
363 state="open",
364 labels=[],
365 author="testuser",
366 created_at=now + timedelta(seconds=i),
367 ))
368 await db_session.commit()
369
370 p1 = await client.get(
371 f"/api/repos/{repo_id}/issues?page=1&per_page=3",
372 headers=auth_headers,
373 )
374 p2 = await client.get(
375 f"/api/repos/{repo_id}/issues?page=2&per_page=3",
376 headers=auth_headers,
377 )
378 ids1 = {i["issueId"] for i in p1.json()["issues"]}
379 ids2 = {i["issueId"] for i in p2.json()["issues"]}
380 assert ids1.isdisjoint(ids2), "Items appeared on both pages"
381
382 async def test_proposals_empty_repo_returns_empty(
383 self,
384 client: AsyncClient,
385 auth_headers: StrDict,
386 ) -> None:
387 repo_id = await _make_repo(client, auth_headers, "pag-int-proposal-empty")
388 r = await client.get(
389 f"/api/repos/{repo_id}/proposals", headers=auth_headers
390 )
391 assert r.status_code == 200
392 body = r.json()
393 assert body["total"] == 0
394 assert body["proposals"] == []
395
396 async def test_commits_empty_repo_returns_empty(
397 self,
398 client: AsyncClient,
399 auth_headers: StrDict,
400 ) -> None:
401 # initialize=False to skip the auto "Initial commit"
402 r = await client.post(
403 "/api/repos",
404 json={"name": "pag-int-commit-empty", "owner": "testuser", "initialize": False},
405 headers=auth_headers,
406 )
407 assert r.status_code == 201
408 repo_id = r.json()["repoId"]
409 r = await client.get(
410 f"/api/repos/{repo_id}/commits", headers=auth_headers
411 )
412 assert r.status_code == 200
413 body = r.json()
414 assert body["total"] == 0
415 assert body["commits"] == []
416
417
418 # ─────────────────────────────────────────────────────────────────────────────
419 # LAYER 3 — E2E
420 # ─────────────────────────────────────────────────────────────────────────────
421
422
423 class TestPaginationE2E:
424 """E2E: full HTTP stack pagination round-trips."""
425
426 async def test_issues_page1_link_header_has_next(
427 self,
428 client: AsyncClient,
429 auth_headers: StrDict,
430 ) -> None:
431 repo_id = await _make_repo(client, auth_headers, "e2e-issues-p1")
432 for i in range(5):
433 await _make_issue(client, auth_headers, repo_id, f"E2E issue {i}")
434 r = await client.get(
435 f"/api/repos/{repo_id}/issues?page=1&per_page=3",
436 headers=auth_headers,
437 )
438 assert r.status_code == 200
439 assert 'rel="next"' in r.headers["Link"]
440 assert 'rel="first"' in r.headers["Link"]
441 assert 'rel="last"' in r.headers["Link"]
442
443 async def test_issues_last_page_no_next_link(
444 self,
445 client: AsyncClient,
446 auth_headers: StrDict,
447 ) -> None:
448 repo_id = await _make_repo(client, auth_headers, "e2e-issues-last")
449 for i in range(4):
450 await _make_issue(client, auth_headers, repo_id, f"Issue {i}")
451 r = await client.get(
452 f"/api/repos/{repo_id}/issues?page=2&per_page=3",
453 headers=auth_headers,
454 )
455 assert r.status_code == 200
456 assert 'rel="next"' not in r.headers["Link"]
457 assert 'rel="prev"' in r.headers["Link"]
458
459 async def test_issues_total_field_matches_seeded_count(
460 self,
461 client: AsyncClient,
462 auth_headers: StrDict,
463 ) -> None:
464 repo_id = await _make_repo(client, auth_headers, "e2e-issues-total")
465 for i in range(7):
466 await _make_issue(client, auth_headers, repo_id, f"Issue {i}")
467 r = await client.get(
468 f"/api/repos/{repo_id}/issues?page=1&per_page=3",
469 headers=auth_headers,
470 )
471 assert r.json()["total"] == 7
472
473 async def test_issues_page2_contains_correct_slice(
474 self,
475 client: AsyncClient,
476 auth_headers: StrDict,
477 db_session: AsyncSession,
478 ) -> None:
479 """Page 2 contains exactly items [per_page .. 2*per_page)."""
480 repo_id = await _make_repo(client, auth_headers, "e2e-issues-slice")
481 now = datetime.now(tz=timezone.utc)
482 # Seed 6 issues with deterministic timestamps
483 for i in range(6):
484 db_session.add(MusehubIssue(
485 repo_id=repo_id,
486 number=i + 1,
487 title=f"Ordered {i:02d}",
488 body="",
489 state="open",
490 labels=[],
491 author="testuser",
492 created_at=now - timedelta(seconds=i), # newest first
493 ))
494 await db_session.commit()
495
496 p1 = await client.get(
497 f"/api/repos/{repo_id}/issues?page=1&per_page=3",
498 headers=auth_headers,
499 )
500 p2 = await client.get(
501 f"/api/repos/{repo_id}/issues?page=2&per_page=3",
502 headers=auth_headers,
503 )
504 assert len(p1.json()["issues"]) == 3
505 assert len(p2.json()["issues"]) == 3
506 # Union covers all 6
507 all_ids = (
508 {i["issueId"] for i in p1.json()["issues"]}
509 | {i["issueId"] for i in p2.json()["issues"]}
510 )
511 assert len(all_ids) == 6
512
513 async def test_proposals_pagination_link_header(
514 self,
515 client: AsyncClient,
516 auth_headers: StrDict,
517 db_session: AsyncSession,
518 ) -> None:
519 repo_id = await _make_repo(client, auth_headers, "e2e-proposals-link")
520 for i in range(4):
521 db_session.add(MusehubProposal(
522 repo_id=repo_id,
523 proposal_number=i + 1,
524 title=f"E2E Proposal {i}",
525 from_branch=f"feat/{i}",
526 to_branch="main",
527 author="testuser",
528 ))
529 await db_session.commit()
530 r = await client.get(
531 f"/api/repos/{repo_id}/proposals?page=1&per_page=2",
532 headers=auth_headers,
533 )
534 assert r.status_code == 200
535 assert "Link" in r.headers
536 assert 'rel="next"' in r.headers["Link"]
537
538 async def test_commits_page_based_link_header(
539 self,
540 client: AsyncClient,
541 auth_headers: StrDict,
542 db_session: AsyncSession,
543 ) -> None:
544 repo_id = await _make_repo(client, auth_headers, "e2e-commits-paged")
545 now = datetime.now(tz=timezone.utc)
546 for i in range(5):
547 db_session.add(MusehubCommit(
548 commit_id=f"sha-e2e-{i:04d}",
549 repo_id=repo_id,
550 branch="main",
551 parent_ids=[],
552 message=f"E2E commit {i}",
553 author="testuser",
554 timestamp=now + timedelta(seconds=i),
555 ))
556 await db_session.commit()
557 r = await client.get(
558 f"/api/repos/{repo_id}/commits?page=1&per_page=2",
559 headers=auth_headers,
560 )
561 assert r.status_code == 200
562 assert "Link" in r.headers
563 assert 'rel="next"' in r.headers["Link"]
564
565 async def test_my_repos_cursor_link_header_absent_on_last_page(
566 self,
567 client: AsyncClient,
568 auth_headers: StrDict,
569 ) -> None:
570 """When there is no next_cursor, the Link header must be absent."""
571 # Only create 1 repo so there's definitely no next page at limit=100
572 await _make_repo(client, auth_headers, "e2e-cursor-no-next")
573 r = await client.get(
574 "/api/repos?limit=100", headers=auth_headers
575 )
576 assert r.status_code == 200
577 # If only one page of repos: no Link header
578 if r.json().get("nextCursor") is None:
579 assert "Link" not in r.headers
580
581
582 # ─────────────────────────────────────────────────────────────────────────────
583 # LAYER 4 — STRESS
584 # ─────────────────────────────────────────────────────────────────────────────
585
586
587 class TestPaginationStress:
588 """Stress: large datasets, full page-through, scale."""
589
590 def test_paginate_list_100_items_full_page_through(self):
591 """Walk all pages of 100 items at per_page=10 — collect every item exactly once."""
592 items = list(range(100))
593 seen = []
594 for page in range(1, 11):
595 result, total = paginate_list(items, page=page, per_page=10)
596 assert total == 100
597 assert len(result) == 10
598 seen.extend(result)
599 assert seen == items # order preserved, no duplicates, no gaps
600
601 def test_paginate_list_large_dataset(self):
602 """paginate_list handles 100 000 items without error."""
603 items = list(range(100_000))
604 result, total = paginate_list(items, page=500, per_page=100)
605 assert total == 100_000
606 assert len(result) == 100
607 assert result[0] == 49900
608
609 def test_build_link_header_large_total(self):
610 """build_link_header with a million-item total computes last page correctly."""
611 req = _req("http://test/api/repos/r1/issues?page=1&per_page=100")
612 header = build_link_header(req, total=1_000_000, page=1, per_page=100)
613 assert "page=10000" in header # last page = 1M / 100
614
615 async def test_sequential_issue_page_through(
616 self,
617 client: AsyncClient,
618 auth_headers: StrDict,
619 db_session: AsyncSession,
620 ) -> None:
621 """Walk pages 1–4 of 30 issues at per_page=8 — all items collected exactly once."""
622 repo_id = await _make_repo(client, auth_headers, "stress-page-through")
623 now = datetime.now(tz=timezone.utc)
624 for i in range(30):
625 db_session.add(MusehubIssue(
626 repo_id=repo_id,
627 number=i + 1,
628 title=f"Stress issue {i:03d}",
629 body="",
630 state="open",
631 labels=[],
632 author="testuser",
633 created_at=now + timedelta(seconds=i),
634 ))
635 await db_session.commit()
636
637 all_ids: set[str] = set()
638 for page in range(1, 5): # 4 pages × 8 = 32 capacity for 30 issues
639 r = await client.get(
640 f"/api/repos/{repo_id}/issues?page={page}&per_page=8",
641 headers=auth_headers,
642 )
643 assert r.status_code == 200
644 for issue in r.json()["issues"]:
645 all_ids.add(issue["issueId"])
646 assert len(all_ids) == 30
647
648 async def test_issue_list_with_50_items_under_500ms(
649 self,
650 client: AsyncClient,
651 auth_headers: StrDict,
652 db_session: AsyncSession,
653 ) -> None:
654 repo_id = await _make_repo(client, auth_headers, "stress-50-issues")
655 now = datetime.now(tz=timezone.utc)
656 for i in range(50):
657 db_session.add(MusehubIssue(
658 repo_id=repo_id,
659 number=i + 1,
660 title=f"Stress {i}",
661 body="",
662 state="open",
663 labels=[],
664 author="testuser",
665 created_at=now + timedelta(seconds=i),
666 ))
667 await db_session.commit()
668 # Warm up
669 await client.get(
670 f"/api/repos/{repo_id}/issues?page=1&per_page=20",
671 headers=auth_headers,
672 )
673 start = time.perf_counter()
674 r = await client.get(
675 f"/api/repos/{repo_id}/issues?page=1&per_page=20",
676 headers=auth_headers,
677 )
678 elapsed = time.perf_counter() - start
679 assert r.status_code == 200
680 assert elapsed < 0.500, f"50-item issue list took {elapsed*1000:.1f}ms (limit 500ms)"
681
682
683 # ─────────────────────────────────────────────────────────────────────────────
684 # LAYER 5 — DATA INTEGRITY
685 # ─────────────────────────────────────────────────────────────────────────────
686
687
688 class TestPaginationDataIntegrity:
689 """Data integrity: no items lost or duplicated, totals stable, filter accuracy."""
690
691 def test_full_page_through_no_items_skipped(self):
692 """Collecting all pages of 25 items at per_page=7 produces exactly 25 items."""
693 items = list(range(25))
694 collected = []
695 page = 1
696 while True:
697 result, total = paginate_list(items, page=page, per_page=7)
698 if not result:
699 break
700 collected.extend(result)
701 page += 1
702 assert len(collected) == 25
703 assert collected == items
704
705 def test_no_duplicates_across_pages(self):
706 """No item appears on two different pages."""
707 items = list(range(17))
708 seen = []
709 for page in range(1, 4):
710 result, _ = paginate_list(items, page=page, per_page=6)
711 seen.extend(result)
712 assert len(seen) == len(set(seen)), "Duplicate items across pages"
713
714 def test_total_constant_across_all_pages(self):
715 """total is identical for every page of the same dataset."""
716 items = list(range(23))
717 totals = set()
718 for page in range(1, 5):
719 _, total = paginate_list(items, page=page, per_page=7)
720 totals.add(total)
721 assert len(totals) == 1
722 assert totals.pop() == 23
723
724 def test_last_page_is_remainder_not_full(self):
725 """Last page has exactly total % per_page items (when not a perfect multiple)."""
726 items = list(range(23))
727 # 23 items at 7/page: pages 1-3 = 7, page 4 = 2
728 last, total = paginate_list(items, page=4, per_page=7)
729 assert len(last) == 2 # 23 % 7 = 2
730
731 def test_last_exact_page_is_full(self):
732 """When total is exact multiple of per_page, last page is full."""
733 items = list(range(21))
734 last, _ = paginate_list(items, page=3, per_page=7)
735 assert len(last) == 7
736
737 async def test_http_total_stable_across_page_requests(
738 self,
739 client: AsyncClient,
740 auth_headers: StrDict,
741 db_session: AsyncSession,
742 ) -> None:
743 """HTTP total field is identical across all page requests for the same query."""
744 repo_id = await _make_repo(client, auth_headers, "di-total-stable")
745 now = datetime.now(tz=timezone.utc)
746 for i in range(11):
747 db_session.add(MusehubIssue(
748 repo_id=repo_id,
749 number=i + 1,
750 title=f"DI issue {i}",
751 body="",
752 state="open",
753 labels=[],
754 author="testuser",
755 created_at=now + timedelta(seconds=i),
756 ))
757 await db_session.commit()
758
759 totals = set()
760 for page in range(1, 4):
761 r = await client.get(
762 f"/api/repos/{repo_id}/issues?page={page}&per_page=5",
763 headers=auth_headers,
764 )
765 assert r.status_code == 200
766 totals.add(r.json()["total"])
767 assert totals == {11}
768
769 async def test_no_http_items_skipped_across_pages(
770 self,
771 client: AsyncClient,
772 auth_headers: StrDict,
773 db_session: AsyncSession,
774 ) -> None:
775 """Union of all pages covers exactly the seeded items."""
776 repo_id = await _make_repo(client, auth_headers, "di-no-skip")
777 now = datetime.now(tz=timezone.utc)
778 for i in range(9):
779 db_session.add(MusehubIssue(
780 repo_id=repo_id,
781 number=i + 1,
782 title=f"NoSkip {i}",
783 body="",
784 state="open",
785 labels=[],
786 author="testuser",
787 created_at=now + timedelta(seconds=i),
788 ))
789 await db_session.commit()
790
791 all_ids: set[str] = set()
792 for page in range(1, 4): # 3 pages × 3 = 9
793 r = await client.get(
794 f"/api/repos/{repo_id}/issues?page={page}&per_page=3",
795 headers=auth_headers,
796 )
797 for iss in r.json()["issues"]:
798 all_ids.add(iss["issueId"])
799 assert len(all_ids) == 9
800
801
802 # ─────────────────────────────────────────────────────────────────────────────
803 # LAYER 6 — SECURITY
804 # ─────────────────────────────────────────────────────────────────────────────
805
806
807 class TestPaginationSecurity:
808 """Security: invalid params rejected, cursor injection safe."""
809
810 async def test_negative_page_rejected_422(
811 self,
812 client: AsyncClient,
813 auth_headers: StrDict,
814 ) -> None:
815 """page < 1 (e.g. page=0) must be rejected with 422."""
816 repo_id = await _make_repo(client, auth_headers, "sec-neg-page")
817 r = await client.get(
818 f"/api/repos/{repo_id}/issues?page=0",
819 headers=auth_headers,
820 )
821 assert r.status_code == 422
822
823 async def test_per_page_above_max_rejected_422(
824 self,
825 client: AsyncClient,
826 auth_headers: StrDict,
827 ) -> None:
828 """per_page > 100 must be rejected with 422 (Query max constraint)."""
829 repo_id = await _make_repo(client, auth_headers, "sec-max-per-page")
830 r = await client.get(
831 f"/api/repos/{repo_id}/issues?per_page=101",
832 headers=auth_headers,
833 )
834 assert r.status_code == 422
835
836 async def test_per_page_zero_rejected_422(
837 self,
838 client: AsyncClient,
839 auth_headers: StrDict,
840 ) -> None:
841 """per_page=0 must be rejected (ge=1 constraint)."""
842 repo_id = await _make_repo(client, auth_headers, "sec-zero-per-page")
843 r = await client.get(
844 f"/api/repos/{repo_id}/issues?per_page=0",
845 headers=auth_headers,
846 )
847 assert r.status_code == 422
848
849 async def test_non_integer_page_rejected_422(
850 self,
851 client: AsyncClient,
852 auth_headers: StrDict,
853 ) -> None:
854 """Non-integer page value must be rejected."""
855 repo_id = await _make_repo(client, auth_headers, "sec-str-page")
856 r = await client.get(
857 f"/api/repos/{repo_id}/issues?page=abc",
858 headers=auth_headers,
859 )
860 assert r.status_code == 422
861
862 async def test_cursor_sql_injection_does_not_500(
863 self,
864 client: AsyncClient,
865 auth_headers: StrDict,
866 ) -> None:
867 """A cursor value containing SQL injection must not cause a 500."""
868 r = await client.get(
869 "/api/repos?cursor='; DROP TABLE musehub_repos; --&limit=10",
870 headers=auth_headers,
871 )
872 assert r.status_code != 500
873
874 async def test_huge_page_number_does_not_500(
875 self,
876 client: AsyncClient,
877 auth_headers: StrDict,
878 ) -> None:
879 """A very large page number returns empty gracefully (not 500)."""
880 repo_id = await _make_repo(client, auth_headers, "sec-huge-page")
881 r = await client.get(
882 f"/api/repos/{repo_id}/issues?page=999999&per_page=20",
883 headers=auth_headers,
884 )
885 assert r.status_code == 200
886 body = r.json()
887 assert body["issues"] == []
888
889 def test_link_header_next_url_does_not_expose_internal_paths(self):
890 """build_link_header URLs must not contain server filesystem paths."""
891 req = _req("http://test/api/repos/r1/issues?page=1&per_page=10")
892 header = build_link_header(req, total=50, page=1, per_page=10)
893 assert "/Users/" not in header
894 assert "/home/" not in header
895
896
897 # ─────────────────────────────────────────────────────────────────────────────
898 # LAYER 7 — PERFORMANCE
899 # ─────────────────────────────────────────────────────────────────────────────
900
901
902 class TestPaginationPerformance:
903 """Performance: pagination functions within latency budgets."""
904
905 def test_paginate_list_10k_items_under_10ms(self):
906 items = list(range(10_000))
907 start = time.perf_counter()
908 for page in range(1, 6):
909 paginate_list(items, page=page, per_page=100)
910 elapsed = time.perf_counter() - start
911 assert elapsed < 0.010, f"5 × paginate_list(10K) took {elapsed*1000:.1f}ms (limit 10ms)"
912
913 def test_build_link_header_under_5ms_repeated_100x(self):
914 req = _req("http://test/api/repos/r1/issues?state=open&page=3&per_page=20")
915 start = time.perf_counter()
916 for _ in range(100):
917 build_link_header(req, total=500, page=3, per_page=20)
918 elapsed = time.perf_counter() - start
919 assert elapsed < 0.005, f"100× build_link_header took {elapsed*1000:.1f}ms (limit 5ms)"
920
921 def test_build_cursor_link_header_under_5ms_repeated_100x(self):
922 req = _req("http://test/api/repos?limit=20&owner=gabriel")
923 start = time.perf_counter()
924 for _ in range(100):
925 build_cursor_link_header(req, next_cursor="tok-abc123", limit=20)
926 elapsed = time.perf_counter() - start
927 assert elapsed < 0.005, f"100× build_cursor_link_header took {elapsed*1000:.1f}ms (limit 5ms)"
928
929 async def test_paginated_issue_list_http_under_300ms(
930 self,
931 client: AsyncClient,
932 auth_headers: StrDict,
933 db_session: AsyncSession,
934 ) -> None:
935 repo_id = await _make_repo(client, auth_headers, "perf-issues-http")
936 now = datetime.now(tz=timezone.utc)
937 for i in range(20):
938 db_session.add(MusehubIssue(
939 repo_id=repo_id,
940 number=i + 1,
941 title=f"Perf issue {i}",
942 body="",
943 state="open",
944 labels=[],
945 author="testuser",
946 created_at=now + timedelta(seconds=i),
947 ))
948 await db_session.commit()
949 # Warm-up
950 await client.get(
951 f"/api/repos/{repo_id}/issues?page=1&per_page=10",
952 headers=auth_headers,
953 )
954 start = time.perf_counter()
955 r = await client.get(
956 f"/api/repos/{repo_id}/issues?page=1&per_page=10",
957 headers=auth_headers,
958 )
959 elapsed = time.perf_counter() - start
960 assert r.status_code == 200
961 assert elapsed < 0.300, f"Paginated issues took {elapsed*1000:.1f}ms (limit 300ms)"
962
963 async def test_paginated_proposals_http_under_300ms(
964 self,
965 client: AsyncClient,
966 auth_headers: StrDict,
967 db_session: AsyncSession,
968 ) -> None:
969 repo_id = await _make_repo(client, auth_headers, "perf-proposals-http")
970 for i in range(10):
971 db_session.add(MusehubProposal(
972 repo_id=repo_id,
973 proposal_number=i + 1,
974 title=f"Perf Proposal {i}",
975 from_branch=f"feat/{i}",
976 to_branch="main",
977 author="testuser",
978 ))
979 await db_session.commit()
980 await client.get(
981 f"/api/repos/{repo_id}/proposals?page=1&per_page=5",
982 headers=auth_headers,
983 )
984 start = time.perf_counter()
985 r = await client.get(
986 f"/api/repos/{repo_id}/proposals?page=1&per_page=5",
987 headers=auth_headers,
988 )
989 elapsed = time.perf_counter() - start
990 assert r.status_code == 200
991 assert elapsed < 0.300, f"Paginated proposals took {elapsed*1000:.1f}ms (limit 300ms)"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago