"""Section 39 — Pagination: 7-layer test suite. Existing coverage (test_musehub_pagination.py, 19 tests) covers: - paginate_list slice correctness (5 tests) - build_link_header rels for first/middle/last/single page (5 tests) - build_cursor_link_header basic shape (1 test) - Issues/proposals/commits happy-path pagination over HTTP (8 tests) This file adds the missing layers and edge cases: 1. Unit — PaginationParams defaults, paginate_list edge cases (per_page=1, exact multiples, single item, large pages), build_link_header (zero total, exact multiple, last-page math), build_cursor_link_header URL encoding 2. Integration — empty repo → empty list (not 404), page > total_pages graceful, issues filter accuracy across pages, sort stability 3. E2E — full HTTP round-trips: total field correctness, Link header on each page, cursor Link header present/absent correctly 4. Stress — 100-item dataset full page-through, sequential cursor walk, last page remainder correctness at scale 5. Data Integrity — no items skipped or duplicated across pages, total stable across page calls, filter+pagination combined 6. Security — negative page rejected (422), per_page > 100 rejected (422), cursor with special chars URL-safe, page=0 rejected 7. Performance — paginate_list(10K) under 10ms, build_link_header under 5ms, HTTP issue-list with 50 items under 500ms """ from __future__ import annotations import time from datetime import datetime, timezone, timedelta import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from starlette.requests import Request as StarletteRequest from musehub.muse_contracts.json_types import StrDict from musehub.api.routes.musehub.pagination import ( PaginationParams, build_cursor_link_header, build_link_header, paginate_list, ) from musehub.db.musehub_models import MusehubCommit, MusehubIssue, MusehubProposal, MusehubRepo # ───────────────────────────────────────────────────────────────────────────── # Shared helpers # ───────────────────────────────────────────────────────────────────────────── def _req(url: str) -> StarletteRequest: """Build a minimal Starlette Request for testing URL construction.""" scope = { "type": "http", "method": "GET", "path": url.split("?")[0], "query_string": url.split("?")[1].encode() if "?" in url else b"", "headers": [], } return StarletteRequest(scope) async def _make_repo( client: AsyncClient, auth_headers: StrDict, name: str, ) -> str: r = await client.post( "/api/repos", json={"name": name, "owner": "testuser"}, headers=auth_headers, ) assert r.status_code == 201, r.text return r.json()["repoId"] async def _make_issue( client: AsyncClient, auth_headers: StrDict, repo_id: str, title: str, ) -> None: r = await client.post( f"/api/repos/{repo_id}/issues", json={"title": title, "body": ""}, headers=auth_headers, ) assert r.status_code == 201, r.text # ───────────────────────────────────────────────────────────────────────────── # LAYER 1 — UNIT # ───────────────────────────────────────────────────────────────────────────── class TestPaginationParamsDefaults: """Unit tests for PaginationParams dependency defaults.""" def test_defaults_are_sane(self): import inspect sig = inspect.signature(PaginationParams.__init__) params = sig.parameters # page default = 1 assert params["page"].default == 1 or hasattr(params["page"].default, "default") # per_page default = 20 assert params["per_page"].default == 20 or hasattr(params["per_page"].default, "default") def test_class_is_instantiable_with_defaults(self): """PaginationParams can be constructed directly with no args (all have defaults).""" class FakeQuery: """Mimic Query() default extraction.""" def __init__(self, v): self.v = v # Just verify the constructor signature is usable import inspect sig = inspect.signature(PaginationParams.__init__) assert "page" in sig.parameters assert "per_page" in sig.parameters assert "cursor" in sig.parameters assert "limit" in sig.parameters def test_cursor_default_is_none(self): import inspect sig = inspect.signature(PaginationParams.__init__) cursor_param = sig.parameters["cursor"] # Default should be None (wrapped in Query) default = cursor_param.default # Query wraps it — check the underlying value assert default is None or getattr(default, "default", None) is None class TestPaginateListEdgeCases: """Unit tests for paginate_list edge cases not in the existing file.""" def test_per_page_one_returns_single_item_pages(self): items = list(range(5)) for page in range(1, 6): result, total = paginate_list(items, page=page, per_page=1) assert total == 5 assert result == [page - 1] def test_exact_multiple_last_page_full(self): """When total is an exact multiple of per_page, last page is full.""" items = list(range(20)) result, total = paginate_list(items, page=2, per_page=10) assert total == 20 assert len(result) == 10 assert result == list(range(10, 20)) def test_single_item_list(self): result, total = paginate_list([42], page=1, per_page=10) assert total == 1 assert result == [42] def test_page_far_beyond_end_returns_empty(self): items = list(range(5)) result, total = paginate_list(items, page=100, per_page=10) assert total == 5 assert result == [] def test_per_page_larger_than_list(self): """per_page larger than total items returns all on page 1.""" items = list(range(7)) result, total = paginate_list(items, page=1, per_page=100) assert total == 7 assert result == items def test_preserves_item_order(self): items = ["c", "a", "b"] result, _ = paginate_list(items, page=1, per_page=10) assert result == ["c", "a", "b"] def test_page_2_starts_after_page_1_ends(self): """No overlap or gap between page 1 and page 2.""" items = list(range(10)) p1, _ = paginate_list(items, page=1, per_page=4) p2, _ = paginate_list(items, page=2, per_page=4) assert p1[-1] + 1 == p2[0] def test_total_unchanged_across_pages(self): """total is always len(items), regardless of page number.""" items = list(range(15)) _, t1 = paginate_list(items, page=1, per_page=5) _, t2 = paginate_list(items, page=2, per_page=5) _, t3 = paginate_list(items, page=3, per_page=5) assert t1 == t2 == t3 == 15 class TestBuildLinkHeaderEdgeCases: """Unit tests for build_link_header edge cases.""" def test_zero_total_returns_first_and_last_only(self): req = _req("http://test/api/repos/r1/issues?page=1&per_page=10") header = build_link_header(req, total=0, page=1, per_page=10) assert 'rel="first"' in header assert 'rel="last"' in header assert 'rel="next"' not in header assert 'rel="prev"' not in header def test_exact_multiple_last_page_no_next(self): """20 items at 10/page → page 2 is last, no rel=next.""" req = _req("http://test/api/repos/r1/issues?page=2&per_page=10") header = build_link_header(req, total=20, page=2, per_page=10) assert 'rel="next"' not in header assert 'rel="prev"' in header def test_last_page_number_correct_for_exact_multiple(self): """100 items at 10/page → last page = 10.""" req = _req("http://test/api/repos/r1/issues?page=1&per_page=10") header = build_link_header(req, total=100, page=1, per_page=10) assert "page=10" in header def test_last_page_number_correct_for_partial_final_page(self): """101 items at 10/page → last page = 11.""" req = _req("http://test/api/repos/r1/issues?page=1&per_page=10") header = build_link_header(req, total=101, page=1, per_page=10) assert "page=11" in header def test_per_page_1_total_5_last_is_5(self): req = _req("http://test/api/repos/r1/issues?page=1&per_page=1") header = build_link_header(req, total=5, page=1, per_page=1) assert "page=5" in header assert 'rel="next"' in header def test_link_urls_are_valid_format(self): """Each link entry must match ; rel="NAME".""" import re req = _req("http://test/api/repos/r1/issues?page=2&per_page=10") header = build_link_header(req, total=50, page=2, per_page=10) for entry in header.split(", "): assert re.match(r'^<[^>]+>; rel=".+"$', entry.strip()), entry def test_first_page_url_always_page_1(self): """rel=first always points to page=1 regardless of current page.""" req = _req("http://test/api/repos/r1/issues?page=3&per_page=10") header = build_link_header(req, total=50, page=3, per_page=10) first_link = [e for e in header.split(", ") if 'rel="first"' in e][0] assert "page=1" in first_link class TestBuildCursorLinkHeaderEdgeCases: """Unit tests for build_cursor_link_header URL encoding.""" def test_cursor_with_special_chars_is_percent_encoded(self): req = _req("http://test/api/repos?limit=20") header = build_cursor_link_header(req, next_cursor="abc/def+xyz==", limit=20) # The raw / + = must not appear unencoded in a query string assert 'rel="next"' in header # urllib.parse.urlencode handles encoding assert "abc" in header def test_existing_query_params_preserved(self): req = _req("http://test/api/repos?owner=gabriel&limit=20") header = build_cursor_link_header(req, next_cursor="tok123", limit=20) assert "owner=gabriel" in header assert "cursor=tok123" in header def test_limit_encoded_correctly(self): req = _req("http://test/api/repos?limit=50") header = build_cursor_link_header(req, next_cursor="x", limit=50) assert "limit=50" in header def test_no_prev_first_last_in_cursor_header(self): req = _req("http://test/api/repos?limit=20") header = build_cursor_link_header(req, next_cursor="abc", limit=20) for unwanted in ('rel="prev"', 'rel="first"', 'rel="last"'): assert unwanted not in header # ───────────────────────────────────────────────────────────────────────────── # LAYER 2 — INTEGRATION # ───────────────────────────────────────────────────────────────────────────── class TestPaginationIntegration: """Integration: real DB, real service, no mocks.""" async def test_empty_repo_issues_returns_empty_list_not_404( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """Empty repo must return [] + total=0, not 404.""" repo_id = await _make_repo(client, auth_headers, "pag-int-empty") r = await client.get( f"/api/repos/{repo_id}/issues", headers=auth_headers ) assert r.status_code == 200 body = r.json() assert body["total"] == 0 assert body["issues"] == [] async def test_page_beyond_total_returns_empty_slice( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """Requesting page > total_pages returns empty list, not 404.""" repo_id = await _make_repo(client, auth_headers, "pag-int-over") for i in range(3): await _make_issue(client, auth_headers, repo_id, f"Issue {i}") r = await client.get( f"/api/repos/{repo_id}/issues?page=99&per_page=10", headers=auth_headers, ) assert r.status_code == 200 body = r.json() assert body["issues"] == [] assert body["total"] == 3 # total still accurate async def test_filter_plus_pagination_correct_total( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Filter by state=closed returns only closed total, not all issues.""" repo_id = await _make_repo(client, auth_headers, "pag-int-filter") # 4 open, 2 closed for i in range(4): await _make_issue(client, auth_headers, repo_id, f"Open {i}") for i in range(2): issue = MusehubIssue( repo_id=repo_id, number=10 + i, title=f"Closed {i}", body="", state="closed", labels=[], author="testuser", ) db_session.add(issue) await db_session.commit() r = await client.get( f"/api/repos/{repo_id}/issues?state=closed", headers=auth_headers, ) assert r.status_code == 200 body = r.json() assert body["total"] == 2 async def test_sort_stability_across_pages( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Items on page N and N+1 must not overlap — ordering is stable.""" repo_id = await _make_repo(client, auth_headers, "pag-int-sort") # Seed 6 issues directly for deterministic ordering now = datetime.now(tz=timezone.utc) for i in range(6): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"Sorted issue {i:02d}", body="", state="open", labels=[], author="testuser", created_at=now + timedelta(seconds=i), )) await db_session.commit() p1 = await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=3", headers=auth_headers, ) p2 = await client.get( f"/api/repos/{repo_id}/issues?page=2&per_page=3", headers=auth_headers, ) ids1 = {i["issueId"] for i in p1.json()["issues"]} ids2 = {i["issueId"] for i in p2.json()["issues"]} assert ids1.isdisjoint(ids2), "Items appeared on both pages" async def test_proposals_empty_repo_returns_empty( self, client: AsyncClient, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(client, auth_headers, "pag-int-proposal-empty") r = await client.get( f"/api/repos/{repo_id}/proposals", headers=auth_headers ) assert r.status_code == 200 body = r.json() assert body["total"] == 0 assert body["proposals"] == [] async def test_commits_empty_repo_returns_empty( self, client: AsyncClient, auth_headers: StrDict, ) -> None: # initialize=False to skip the auto "Initial commit" r = await client.post( "/api/repos", json={"name": "pag-int-commit-empty", "owner": "testuser", "initialize": False}, headers=auth_headers, ) assert r.status_code == 201 repo_id = r.json()["repoId"] r = await client.get( f"/api/repos/{repo_id}/commits", headers=auth_headers ) assert r.status_code == 200 body = r.json() assert body["total"] == 0 assert body["commits"] == [] # ───────────────────────────────────────────────────────────────────────────── # LAYER 3 — E2E # ───────────────────────────────────────────────────────────────────────────── class TestPaginationE2E: """E2E: full HTTP stack pagination round-trips.""" async def test_issues_page1_link_header_has_next( self, client: AsyncClient, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(client, auth_headers, "e2e-issues-p1") for i in range(5): await _make_issue(client, auth_headers, repo_id, f"E2E issue {i}") r = await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=3", headers=auth_headers, ) assert r.status_code == 200 assert 'rel="next"' in r.headers["Link"] assert 'rel="first"' in r.headers["Link"] assert 'rel="last"' in r.headers["Link"] async def test_issues_last_page_no_next_link( self, client: AsyncClient, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(client, auth_headers, "e2e-issues-last") for i in range(4): await _make_issue(client, auth_headers, repo_id, f"Issue {i}") r = await client.get( f"/api/repos/{repo_id}/issues?page=2&per_page=3", headers=auth_headers, ) assert r.status_code == 200 assert 'rel="next"' not in r.headers["Link"] assert 'rel="prev"' in r.headers["Link"] async def test_issues_total_field_matches_seeded_count( self, client: AsyncClient, auth_headers: StrDict, ) -> None: repo_id = await _make_repo(client, auth_headers, "e2e-issues-total") for i in range(7): await _make_issue(client, auth_headers, repo_id, f"Issue {i}") r = await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=3", headers=auth_headers, ) assert r.json()["total"] == 7 async def test_issues_page2_contains_correct_slice( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Page 2 contains exactly items [per_page .. 2*per_page).""" repo_id = await _make_repo(client, auth_headers, "e2e-issues-slice") now = datetime.now(tz=timezone.utc) # Seed 6 issues with deterministic timestamps for i in range(6): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"Ordered {i:02d}", body="", state="open", labels=[], author="testuser", created_at=now - timedelta(seconds=i), # newest first )) await db_session.commit() p1 = await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=3", headers=auth_headers, ) p2 = await client.get( f"/api/repos/{repo_id}/issues?page=2&per_page=3", headers=auth_headers, ) assert len(p1.json()["issues"]) == 3 assert len(p2.json()["issues"]) == 3 # Union covers all 6 all_ids = ( {i["issueId"] for i in p1.json()["issues"]} | {i["issueId"] for i in p2.json()["issues"]} ) assert len(all_ids) == 6 async def test_proposals_pagination_link_header( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: repo_id = await _make_repo(client, auth_headers, "e2e-proposals-link") for i in range(4): db_session.add(MusehubProposal( repo_id=repo_id, proposal_number=i + 1, title=f"E2E Proposal {i}", from_branch=f"feat/{i}", to_branch="main", author="testuser", )) await db_session.commit() r = await client.get( f"/api/repos/{repo_id}/proposals?page=1&per_page=2", headers=auth_headers, ) assert r.status_code == 200 assert "Link" in r.headers assert 'rel="next"' in r.headers["Link"] async def test_commits_page_based_link_header( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: repo_id = await _make_repo(client, auth_headers, "e2e-commits-paged") now = datetime.now(tz=timezone.utc) for i in range(5): db_session.add(MusehubCommit( commit_id=f"sha-e2e-{i:04d}", repo_id=repo_id, branch="main", parent_ids=[], message=f"E2E commit {i}", author="testuser", timestamp=now + timedelta(seconds=i), )) await db_session.commit() r = await client.get( f"/api/repos/{repo_id}/commits?page=1&per_page=2", headers=auth_headers, ) assert r.status_code == 200 assert "Link" in r.headers assert 'rel="next"' in r.headers["Link"] async def test_my_repos_cursor_link_header_absent_on_last_page( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """When there is no next_cursor, the Link header must be absent.""" # Only create 1 repo so there's definitely no next page at limit=100 await _make_repo(client, auth_headers, "e2e-cursor-no-next") r = await client.get( "/api/repos?limit=100", headers=auth_headers ) assert r.status_code == 200 # If only one page of repos: no Link header if r.json().get("nextCursor") is None: assert "Link" not in r.headers # ───────────────────────────────────────────────────────────────────────────── # LAYER 4 — STRESS # ───────────────────────────────────────────────────────────────────────────── class TestPaginationStress: """Stress: large datasets, full page-through, scale.""" def test_paginate_list_100_items_full_page_through(self): """Walk all pages of 100 items at per_page=10 — collect every item exactly once.""" items = list(range(100)) seen = [] for page in range(1, 11): result, total = paginate_list(items, page=page, per_page=10) assert total == 100 assert len(result) == 10 seen.extend(result) assert seen == items # order preserved, no duplicates, no gaps def test_paginate_list_large_dataset(self): """paginate_list handles 100 000 items without error.""" items = list(range(100_000)) result, total = paginate_list(items, page=500, per_page=100) assert total == 100_000 assert len(result) == 100 assert result[0] == 49900 def test_build_link_header_large_total(self): """build_link_header with a million-item total computes last page correctly.""" req = _req("http://test/api/repos/r1/issues?page=1&per_page=100") header = build_link_header(req, total=1_000_000, page=1, per_page=100) assert "page=10000" in header # last page = 1M / 100 async def test_sequential_issue_page_through( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Walk pages 1–4 of 30 issues at per_page=8 — all items collected exactly once.""" repo_id = await _make_repo(client, auth_headers, "stress-page-through") now = datetime.now(tz=timezone.utc) for i in range(30): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"Stress issue {i:03d}", body="", state="open", labels=[], author="testuser", created_at=now + timedelta(seconds=i), )) await db_session.commit() all_ids: set[str] = set() for page in range(1, 5): # 4 pages × 8 = 32 capacity for 30 issues r = await client.get( f"/api/repos/{repo_id}/issues?page={page}&per_page=8", headers=auth_headers, ) assert r.status_code == 200 for issue in r.json()["issues"]: all_ids.add(issue["issueId"]) assert len(all_ids) == 30 async def test_issue_list_with_50_items_under_500ms( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: repo_id = await _make_repo(client, auth_headers, "stress-50-issues") now = datetime.now(tz=timezone.utc) for i in range(50): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"Stress {i}", body="", state="open", labels=[], author="testuser", created_at=now + timedelta(seconds=i), )) await db_session.commit() # Warm up await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=20", headers=auth_headers, ) start = time.perf_counter() r = await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=20", headers=auth_headers, ) elapsed = time.perf_counter() - start assert r.status_code == 200 assert elapsed < 0.500, f"50-item issue list took {elapsed*1000:.1f}ms (limit 500ms)" # ───────────────────────────────────────────────────────────────────────────── # LAYER 5 — DATA INTEGRITY # ───────────────────────────────────────────────────────────────────────────── class TestPaginationDataIntegrity: """Data integrity: no items lost or duplicated, totals stable, filter accuracy.""" def test_full_page_through_no_items_skipped(self): """Collecting all pages of 25 items at per_page=7 produces exactly 25 items.""" items = list(range(25)) collected = [] page = 1 while True: result, total = paginate_list(items, page=page, per_page=7) if not result: break collected.extend(result) page += 1 assert len(collected) == 25 assert collected == items def test_no_duplicates_across_pages(self): """No item appears on two different pages.""" items = list(range(17)) seen = [] for page in range(1, 4): result, _ = paginate_list(items, page=page, per_page=6) seen.extend(result) assert len(seen) == len(set(seen)), "Duplicate items across pages" def test_total_constant_across_all_pages(self): """total is identical for every page of the same dataset.""" items = list(range(23)) totals = set() for page in range(1, 5): _, total = paginate_list(items, page=page, per_page=7) totals.add(total) assert len(totals) == 1 assert totals.pop() == 23 def test_last_page_is_remainder_not_full(self): """Last page has exactly total % per_page items (when not a perfect multiple).""" items = list(range(23)) # 23 items at 7/page: pages 1-3 = 7, page 4 = 2 last, total = paginate_list(items, page=4, per_page=7) assert len(last) == 2 # 23 % 7 = 2 def test_last_exact_page_is_full(self): """When total is exact multiple of per_page, last page is full.""" items = list(range(21)) last, _ = paginate_list(items, page=3, per_page=7) assert len(last) == 7 async def test_http_total_stable_across_page_requests( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """HTTP total field is identical across all page requests for the same query.""" repo_id = await _make_repo(client, auth_headers, "di-total-stable") now = datetime.now(tz=timezone.utc) for i in range(11): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"DI issue {i}", body="", state="open", labels=[], author="testuser", created_at=now + timedelta(seconds=i), )) await db_session.commit() totals = set() for page in range(1, 4): r = await client.get( f"/api/repos/{repo_id}/issues?page={page}&per_page=5", headers=auth_headers, ) assert r.status_code == 200 totals.add(r.json()["total"]) assert totals == {11} async def test_no_http_items_skipped_across_pages( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Union of all pages covers exactly the seeded items.""" repo_id = await _make_repo(client, auth_headers, "di-no-skip") now = datetime.now(tz=timezone.utc) for i in range(9): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"NoSkip {i}", body="", state="open", labels=[], author="testuser", created_at=now + timedelta(seconds=i), )) await db_session.commit() all_ids: set[str] = set() for page in range(1, 4): # 3 pages × 3 = 9 r = await client.get( f"/api/repos/{repo_id}/issues?page={page}&per_page=3", headers=auth_headers, ) for iss in r.json()["issues"]: all_ids.add(iss["issueId"]) assert len(all_ids) == 9 # ───────────────────────────────────────────────────────────────────────────── # LAYER 6 — SECURITY # ───────────────────────────────────────────────────────────────────────────── class TestPaginationSecurity: """Security: invalid params rejected, cursor injection safe.""" async def test_negative_page_rejected_422( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """page < 1 (e.g. page=0) must be rejected with 422.""" repo_id = await _make_repo(client, auth_headers, "sec-neg-page") r = await client.get( f"/api/repos/{repo_id}/issues?page=0", headers=auth_headers, ) assert r.status_code == 422 async def test_per_page_above_max_rejected_422( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """per_page > 100 must be rejected with 422 (Query max constraint).""" repo_id = await _make_repo(client, auth_headers, "sec-max-per-page") r = await client.get( f"/api/repos/{repo_id}/issues?per_page=101", headers=auth_headers, ) assert r.status_code == 422 async def test_per_page_zero_rejected_422( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """per_page=0 must be rejected (ge=1 constraint).""" repo_id = await _make_repo(client, auth_headers, "sec-zero-per-page") r = await client.get( f"/api/repos/{repo_id}/issues?per_page=0", headers=auth_headers, ) assert r.status_code == 422 async def test_non_integer_page_rejected_422( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """Non-integer page value must be rejected.""" repo_id = await _make_repo(client, auth_headers, "sec-str-page") r = await client.get( f"/api/repos/{repo_id}/issues?page=abc", headers=auth_headers, ) assert r.status_code == 422 async def test_cursor_sql_injection_does_not_500( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """A cursor value containing SQL injection must not cause a 500.""" r = await client.get( "/api/repos?cursor='; DROP TABLE musehub_repos; --&limit=10", headers=auth_headers, ) assert r.status_code != 500 async def test_huge_page_number_does_not_500( self, client: AsyncClient, auth_headers: StrDict, ) -> None: """A very large page number returns empty gracefully (not 500).""" repo_id = await _make_repo(client, auth_headers, "sec-huge-page") r = await client.get( f"/api/repos/{repo_id}/issues?page=999999&per_page=20", headers=auth_headers, ) assert r.status_code == 200 body = r.json() assert body["issues"] == [] def test_link_header_next_url_does_not_expose_internal_paths(self): """build_link_header URLs must not contain server filesystem paths.""" req = _req("http://test/api/repos/r1/issues?page=1&per_page=10") header = build_link_header(req, total=50, page=1, per_page=10) assert "/Users/" not in header assert "/home/" not in header # ───────────────────────────────────────────────────────────────────────────── # LAYER 7 — PERFORMANCE # ───────────────────────────────────────────────────────────────────────────── class TestPaginationPerformance: """Performance: pagination functions within latency budgets.""" def test_paginate_list_10k_items_under_10ms(self): items = list(range(10_000)) start = time.perf_counter() for page in range(1, 6): paginate_list(items, page=page, per_page=100) elapsed = time.perf_counter() - start assert elapsed < 0.010, f"5 × paginate_list(10K) took {elapsed*1000:.1f}ms (limit 10ms)" def test_build_link_header_under_5ms_repeated_100x(self): req = _req("http://test/api/repos/r1/issues?state=open&page=3&per_page=20") start = time.perf_counter() for _ in range(100): build_link_header(req, total=500, page=3, per_page=20) elapsed = time.perf_counter() - start assert elapsed < 0.005, f"100× build_link_header took {elapsed*1000:.1f}ms (limit 5ms)" def test_build_cursor_link_header_under_5ms_repeated_100x(self): req = _req("http://test/api/repos?limit=20&owner=gabriel") start = time.perf_counter() for _ in range(100): build_cursor_link_header(req, next_cursor="tok-abc123", limit=20) elapsed = time.perf_counter() - start assert elapsed < 0.005, f"100× build_cursor_link_header took {elapsed*1000:.1f}ms (limit 5ms)" async def test_paginated_issue_list_http_under_300ms( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: repo_id = await _make_repo(client, auth_headers, "perf-issues-http") now = datetime.now(tz=timezone.utc) for i in range(20): db_session.add(MusehubIssue( repo_id=repo_id, number=i + 1, title=f"Perf issue {i}", body="", state="open", labels=[], author="testuser", created_at=now + timedelta(seconds=i), )) await db_session.commit() # Warm-up await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=10", headers=auth_headers, ) start = time.perf_counter() r = await client.get( f"/api/repos/{repo_id}/issues?page=1&per_page=10", headers=auth_headers, ) elapsed = time.perf_counter() - start assert r.status_code == 200 assert elapsed < 0.300, f"Paginated issues took {elapsed*1000:.1f}ms (limit 300ms)" async def test_paginated_proposals_http_under_300ms( self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: repo_id = await _make_repo(client, auth_headers, "perf-proposals-http") for i in range(10): db_session.add(MusehubProposal( repo_id=repo_id, proposal_number=i + 1, title=f"Perf Proposal {i}", from_branch=f"feat/{i}", to_branch="main", author="testuser", )) await db_session.commit() await client.get( f"/api/repos/{repo_id}/proposals?page=1&per_page=5", headers=auth_headers, ) start = time.perf_counter() r = await client.get( f"/api/repos/{repo_id}/proposals?page=1&per_page=5", headers=auth_headers, ) elapsed = time.perf_counter() - start assert r.status_code == 200 assert elapsed < 0.300, f"Paginated proposals took {elapsed*1000:.1f}ms (limit 300ms)"