"""Tests for cursor-based pagination on MuseHub list endpoints. Covers acceptance criteria: - PaginationParams dependency parses cursor/limit query params - build_cursor_link_header emits a correct RFC 8288 rel="next" Link header - GET /repos/{repo_id}/issues returns next_cursor, total, and Link header - GET /repos/{repo_id}/proposals returns next_cursor, total, and Link header - GET /musehub/repos returns next_cursor when more repos exist All tests use fixtures from conftest.py. No live external APIs are called. """ from __future__ import annotations import pytest from httpx import AsyncClient from starlette.requests import Request as StarletteRequest from musehub.types.json_types import StrDict type _IssueJson = dict[str, str | int] from musehub.api.routes.musehub.pagination import ( PaginationParams, build_cursor_link_header, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_request(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 _create_repo( client: AsyncClient, auth_headers: StrDict, name: str = "test-repo" ) -> str: """Create a repo via the API and return its repo_id.""" response = await client.post( "/api/repos", json={"name": name, "owner": "testuser"}, headers=auth_headers, ) assert response.status_code == 201 repo_id: str = response.json()["repoId"] return repo_id async def _create_issue( client: AsyncClient, auth_headers: StrDict, repo_id: str, title: str = "Issue", ) -> _IssueJson: response = await client.post( f"/api/repos/{repo_id}/issues", json={"title": title, "body": "", "labels": []}, headers=auth_headers, ) assert response.status_code == 201 return response.json() # --------------------------------------------------------------------------- # Unit tests — PaginationParams # --------------------------------------------------------------------------- def test_pagination_params_defaults() -> None: """PaginationParams stores cursor and limit as provided.""" params = PaginationParams(cursor=None, limit=20) assert params.cursor is None assert params.limit == 20 def test_pagination_params_custom() -> None: """PaginationParams accepts explicit cursor and limit.""" params = PaginationParams(cursor="abc123", limit=50) assert params.cursor == "abc123" assert params.limit == 50 # --------------------------------------------------------------------------- # Unit tests — build_cursor_link_header # --------------------------------------------------------------------------- def test_build_cursor_link_header_shape() -> None: """build_cursor_link_header emits a rel='next' link with cursor and limit.""" req = _make_request("http://test/api/repos/r1/issues") header = build_cursor_link_header(req, next_cursor="abc123", limit=20) assert 'rel="next"' in header assert "cursor=abc123" in header assert "limit=20" in header def test_build_cursor_link_header_preserves_existing_params() -> None: """build_cursor_link_header carries forward existing query params.""" req = _make_request("http://test/api/repos/r1/issues?state=open") header = build_cursor_link_header(req, next_cursor="xyz", limit=10) assert "state=open" in header assert "cursor=xyz" in header assert "limit=10" in header def test_build_cursor_link_header_overrides_existing_cursor() -> None: """build_cursor_link_header replaces the existing cursor query param.""" req = _make_request("http://test/api/repos/r1/issues?cursor=old") header = build_cursor_link_header(req, next_cursor="new", limit=20) assert "cursor=new" in header assert "cursor=old" not in header # --------------------------------------------------------------------------- # Integration — issues list with cursor pagination # --------------------------------------------------------------------------- async def test_list_issues_empty_repo_has_no_cursor( client: AsyncClient, auth_headers: StrDict, ) -> None: """An empty repo returns an empty list, total=0, next_cursor=null.""" repo_id = await _create_repo(client, auth_headers, "empty-repo") response = await client.get( f"/api/repos/{repo_id}/issues", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["issues"] == [] assert body["total"] == 0 assert body["nextCursor"] is None assert "Link" not in response.headers async def test_list_issues_single_page_no_next_cursor( client: AsyncClient, auth_headers: StrDict, ) -> None: """When all results fit on one page, next_cursor is null.""" repo_id = await _create_repo(client, auth_headers, "single-page-repo") for i in range(3): await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}") response = await client.get( f"/api/repos/{repo_id}/issues?limit=20", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert len(body["issues"]) == 3 assert body["total"] == 3 assert body["nextCursor"] is None assert "Link" not in response.headers async def test_list_issues_returns_next_cursor_when_more_exist( client: AsyncClient, auth_headers: StrDict, ) -> None: """When results exceed limit, next_cursor is set and Link header is present.""" repo_id = await _create_repo(client, auth_headers, "multi-page-repo") for i in range(5): await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}") response = await client.get( f"/api/repos/{repo_id}/issues?limit=3", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert len(body["issues"]) == 3 assert body["total"] == 5 assert body["nextCursor"] is not None assert 'rel="next"' in response.headers.get("Link", "") async def test_list_issues_cursor_advances_to_next_page( client: AsyncClient, auth_headers: StrDict, ) -> None: """Passing next_cursor from page 1 returns a non-overlapping page 2.""" repo_id = await _create_repo(client, auth_headers, "cursor-advance-repo") for i in range(5): await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}") # Page 1 r1 = await client.get( f"/api/repos/{repo_id}/issues?limit=3", headers=auth_headers, ) assert r1.status_code == 200 body1 = r1.json() cursor = body1["nextCursor"] ids_page1 = {i["number"] for i in body1["issues"]} # Page 2 r2 = await client.get( f"/api/repos/{repo_id}/issues?limit=3&cursor={cursor}", headers=auth_headers, ) assert r2.status_code == 200 body2 = r2.json() ids_page2 = {i["number"] for i in body2["issues"]} assert ids_page1.isdisjoint(ids_page2), "Pages must not overlap" assert body2["nextCursor"] is None # reached the last page async def test_list_issues_total_stable_across_pages( client: AsyncClient, auth_headers: StrDict, ) -> None: """total is constant across all pages.""" repo_id = await _create_repo(client, auth_headers, "stable-total-repo") for i in range(6): await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}") r1 = await client.get(f"/api/repos/{repo_id}/issues?limit=4", headers=auth_headers) r2 = await client.get( f"/api/repos/{repo_id}/issues?limit=4&cursor={r1.json()['nextCursor']}", headers=auth_headers, ) assert r1.json()["total"] == r2.json()["total"] == 6 # --------------------------------------------------------------------------- # Integration — proposals list with cursor pagination # --------------------------------------------------------------------------- async def test_list_proposals_empty_repo_has_no_cursor( client: AsyncClient, auth_headers: StrDict, ) -> None: """An empty repo returns an empty proposal list with next_cursor=null.""" repo_id = await _create_repo(client, auth_headers, "no-proposals-repo") response = await client.get( f"/api/repos/{repo_id}/proposals", headers=auth_headers, ) assert response.status_code == 200 body = response.json() assert body["proposals"] == [] assert body["total"] == 0 assert body["nextCursor"] is None