gabriel / musehub public
test_musehub_pagination.py python
254 lines 8.6 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for cursor-based pagination on MuseHub list endpoints.
2
3 Covers acceptance criteria:
4 - PaginationParams dependency parses cursor/limit query params
5 - build_cursor_link_header emits a correct RFC 8288 rel="next" Link header
6 - GET /repos/{repo_id}/issues returns next_cursor, total, and Link header
7 - GET /repos/{repo_id}/proposals returns next_cursor, total, and Link header
8 - GET /musehub/repos returns next_cursor when more repos exist
9
10 All tests use fixtures from conftest.py. No live external APIs are called.
11 """
12 from __future__ import annotations
13
14 import pytest
15 from httpx import AsyncClient
16 from starlette.requests import Request as StarletteRequest
17
18 from musehub.types.json_types import StrDict
19
20 type _IssueJson = dict[str, str | int]
21 from musehub.api.routes.musehub.pagination import (
22 PaginationParams,
23 build_cursor_link_header,
24 )
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31
32 def _make_request(url: str) -> StarletteRequest:
33 """Build a minimal Starlette Request for testing URL construction."""
34 scope = {
35 "type": "http",
36 "method": "GET",
37 "path": url.split("?")[0],
38 "query_string": url.split("?")[1].encode() if "?" in url else b"",
39 "headers": [],
40 }
41 return StarletteRequest(scope)
42
43
44 async def _create_repo(
45 client: AsyncClient, auth_headers: StrDict, name: str = "test-repo"
46 ) -> str:
47 """Create a repo via the API and return its repo_id."""
48 response = await client.post(
49 "/api/repos",
50 json={"name": name, "owner": "testuser"},
51 headers=auth_headers,
52 )
53 assert response.status_code == 201
54 repo_id: str = response.json()["repoId"]
55 return repo_id
56
57
58 async def _create_issue(
59 client: AsyncClient,
60 auth_headers: StrDict,
61 repo_id: str,
62 title: str = "Issue",
63 ) -> _IssueJson:
64 response = await client.post(
65 f"/api/repos/{repo_id}/issues",
66 json={"title": title, "body": "", "labels": []},
67 headers=auth_headers,
68 )
69 assert response.status_code == 201
70 return response.json()
71
72
73 # ---------------------------------------------------------------------------
74 # Unit tests — PaginationParams
75 # ---------------------------------------------------------------------------
76
77
78 def test_pagination_params_defaults() -> None:
79 """PaginationParams stores cursor and limit as provided."""
80 params = PaginationParams(cursor=None, limit=20)
81 assert params.cursor is None
82 assert params.limit == 20
83
84
85 def test_pagination_params_custom() -> None:
86 """PaginationParams accepts explicit cursor and limit."""
87 params = PaginationParams(cursor="abc123", limit=50)
88 assert params.cursor == "abc123"
89 assert params.limit == 50
90
91
92 # ---------------------------------------------------------------------------
93 # Unit tests — build_cursor_link_header
94 # ---------------------------------------------------------------------------
95
96
97 def test_build_cursor_link_header_shape() -> None:
98 """build_cursor_link_header emits a rel='next' link with cursor and limit."""
99 req = _make_request("http://test/api/repos/r1/issues")
100 header = build_cursor_link_header(req, next_cursor="abc123", limit=20)
101 assert 'rel="next"' in header
102 assert "cursor=abc123" in header
103 assert "limit=20" in header
104
105
106 def test_build_cursor_link_header_preserves_existing_params() -> None:
107 """build_cursor_link_header carries forward existing query params."""
108 req = _make_request("http://test/api/repos/r1/issues?state=open")
109 header = build_cursor_link_header(req, next_cursor="xyz", limit=10)
110 assert "state=open" in header
111 assert "cursor=xyz" in header
112 assert "limit=10" in header
113
114
115 def test_build_cursor_link_header_overrides_existing_cursor() -> None:
116 """build_cursor_link_header replaces the existing cursor query param."""
117 req = _make_request("http://test/api/repos/r1/issues?cursor=old")
118 header = build_cursor_link_header(req, next_cursor="new", limit=20)
119 assert "cursor=new" in header
120 assert "cursor=old" not in header
121
122
123 # ---------------------------------------------------------------------------
124 # Integration — issues list with cursor pagination
125 # ---------------------------------------------------------------------------
126
127
128 async def test_list_issues_empty_repo_has_no_cursor(
129 client: AsyncClient,
130 auth_headers: StrDict,
131 ) -> None:
132 """An empty repo returns an empty list, total=0, next_cursor=null."""
133 repo_id = await _create_repo(client, auth_headers, "empty-repo")
134 response = await client.get(
135 f"/api/repos/{repo_id}/issues",
136 headers=auth_headers,
137 )
138 assert response.status_code == 200
139 body = response.json()
140 assert body["issues"] == []
141 assert body["total"] == 0
142 assert body["nextCursor"] is None
143 assert "Link" not in response.headers
144
145
146 async def test_list_issues_single_page_no_next_cursor(
147 client: AsyncClient,
148 auth_headers: StrDict,
149 ) -> None:
150 """When all results fit on one page, next_cursor is null."""
151 repo_id = await _create_repo(client, auth_headers, "single-page-repo")
152 for i in range(3):
153 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
154 response = await client.get(
155 f"/api/repos/{repo_id}/issues?limit=20",
156 headers=auth_headers,
157 )
158 assert response.status_code == 200
159 body = response.json()
160 assert len(body["issues"]) == 3
161 assert body["total"] == 3
162 assert body["nextCursor"] is None
163 assert "Link" not in response.headers
164
165
166 async def test_list_issues_returns_next_cursor_when_more_exist(
167 client: AsyncClient,
168 auth_headers: StrDict,
169 ) -> None:
170 """When results exceed limit, next_cursor is set and Link header is present."""
171 repo_id = await _create_repo(client, auth_headers, "multi-page-repo")
172 for i in range(5):
173 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
174 response = await client.get(
175 f"/api/repos/{repo_id}/issues?limit=3",
176 headers=auth_headers,
177 )
178 assert response.status_code == 200
179 body = response.json()
180 assert len(body["issues"]) == 3
181 assert body["total"] == 5
182 assert body["nextCursor"] is not None
183 assert 'rel="next"' in response.headers.get("Link", "")
184
185
186 async def test_list_issues_cursor_advances_to_next_page(
187 client: AsyncClient,
188 auth_headers: StrDict,
189 ) -> None:
190 """Passing next_cursor from page 1 returns a non-overlapping page 2."""
191 repo_id = await _create_repo(client, auth_headers, "cursor-advance-repo")
192 for i in range(5):
193 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
194
195 # Page 1
196 r1 = await client.get(
197 f"/api/repos/{repo_id}/issues?limit=3",
198 headers=auth_headers,
199 )
200 assert r1.status_code == 200
201 body1 = r1.json()
202 cursor = body1["nextCursor"]
203 ids_page1 = {i["number"] for i in body1["issues"]}
204
205 # Page 2
206 r2 = await client.get(
207 f"/api/repos/{repo_id}/issues?limit=3&cursor={cursor}",
208 headers=auth_headers,
209 )
210 assert r2.status_code == 200
211 body2 = r2.json()
212 ids_page2 = {i["number"] for i in body2["issues"]}
213
214 assert ids_page1.isdisjoint(ids_page2), "Pages must not overlap"
215 assert body2["nextCursor"] is None # reached the last page
216
217
218 async def test_list_issues_total_stable_across_pages(
219 client: AsyncClient,
220 auth_headers: StrDict,
221 ) -> None:
222 """total is constant across all pages."""
223 repo_id = await _create_repo(client, auth_headers, "stable-total-repo")
224 for i in range(6):
225 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
226
227 r1 = await client.get(f"/api/repos/{repo_id}/issues?limit=4", headers=auth_headers)
228 r2 = await client.get(
229 f"/api/repos/{repo_id}/issues?limit=4&cursor={r1.json()['nextCursor']}",
230 headers=auth_headers,
231 )
232 assert r1.json()["total"] == r2.json()["total"] == 6
233
234
235 # ---------------------------------------------------------------------------
236 # Integration — proposals list with cursor pagination
237 # ---------------------------------------------------------------------------
238
239
240 async def test_list_proposals_empty_repo_has_no_cursor(
241 client: AsyncClient,
242 auth_headers: StrDict,
243 ) -> None:
244 """An empty repo returns an empty proposal list with next_cursor=null."""
245 repo_id = await _create_repo(client, auth_headers, "no-proposals-repo")
246 response = await client.get(
247 f"/api/repos/{repo_id}/proposals",
248 headers=auth_headers,
249 )
250 assert response.status_code == 200
251 body = response.json()
252 assert body["proposals"] == []
253 assert body["total"] == 0
254 assert body["nextCursor"] is None
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago