gabriel / musehub public
test_musehub_pagination.py python
254 lines 8.6 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days 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 14 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 29 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 33 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 35 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 35 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 35 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago