gabriel / musehub public
test_pagination.py python
506 lines 17.4 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Section 39 — Cursor Pagination: 7-layer test suite.
2
3 All list endpoints share a single, uniform cursor-based pagination contract:
4 - Query params: ?cursor=<opaque>&limit=N
5 - Response body: next_cursor: str | null, total: int
6 - Response header: Link: <url>; rel="next" when more pages exist
7
8 Coverage layers:
9
10 1. Unit — PaginationParams bounds (limit 1–200, cursor optional),
11 build_cursor_link_header URL encoding, param override
12 2. Integration — empty dataset → no cursor, filter+pagination combined,
13 sort stability, label filter scoped to paginated results
14 3. E2E — full HTTP round-trips: total field consistent, Link header
15 present/absent correctly, cursor echoed into Link URL
16 4. Stress — 100-item dataset full sequential cursor walk, verify
17 no item skipped or duplicated, last page detected
18 5. Data Integrity — total stable across all pages, all items recovered
19 by walking cursor chain, no duplicates
20 6. Security — limit=0 rejected (422), limit=201 rejected (422),
21 cursor with special chars URL-safe (no 422)
22 7. Performance — HTTP issue list with 50 items under 500ms
23 """
24 from __future__ import annotations
25
26 import time
27
28 import pytest
29 from httpx import AsyncClient
30 from sqlalchemy.ext.asyncio import AsyncSession
31 from starlette.requests import Request as StarletteRequest
32
33 from musehub.types.json_types import StrDict
34
35 type _IssueJson = dict[str, str | int]
36 from musehub.api.routes.musehub.pagination import (
37 PaginationParams,
38 build_cursor_link_header,
39 )
40 from musehub.services import musehub_issues as issues_svc
41
42
43 # ---------------------------------------------------------------------------
44 # Shared helpers
45 # ---------------------------------------------------------------------------
46
47
48 def _make_request(url: str) -> StarletteRequest:
49 scope = {
50 "type": "http",
51 "method": "GET",
52 "path": url.split("?")[0],
53 "query_string": url.split("?")[1].encode() if "?" in url else b"",
54 "headers": [],
55 }
56 return StarletteRequest(scope)
57
58
59 async def _create_repo(
60 client: AsyncClient, auth_headers: StrDict, name: str = "repo"
61 ) -> str:
62 r = await client.post(
63 "/api/repos",
64 json={"name": name, "owner": "testuser"},
65 headers=auth_headers,
66 )
67 assert r.status_code == 201
68 return r.json()["repoId"]
69
70
71 async def _create_issue(
72 client: AsyncClient,
73 auth_headers: StrDict,
74 repo_id: str,
75 title: str = "Issue",
76 labels: list[str] | None = None,
77 ) -> _IssueJson:
78 r = await client.post(
79 f"/api/repos/{repo_id}/issues",
80 json={"title": title, "body": "", "labels": labels or []},
81 headers=auth_headers,
82 )
83 assert r.status_code == 201
84 return r.json()
85
86
87 async def _walk_all_issues(
88 client: AsyncClient,
89 auth_headers: StrDict,
90 repo_id: str,
91 limit: int = 10,
92 state: str = "open",
93 ) -> list[dict]:
94 """Walk all pages via next_cursor and collect every issue in order."""
95 all_items: list[dict] = []
96 cursor: str | None = None
97 while True:
98 params = f"limit={limit}&state={state}"
99 if cursor:
100 params += f"&cursor={cursor}"
101 r = await client.get(
102 f"/api/repos/{repo_id}/issues?{params}",
103 headers=auth_headers,
104 )
105 assert r.status_code == 200
106 body = r.json()
107 all_items.extend(body["issues"])
108 cursor = body["nextCursor"]
109 if cursor is None:
110 break
111 return all_items
112
113
114 # ---------------------------------------------------------------------------
115 # Layer 1 — Unit tests
116 # ---------------------------------------------------------------------------
117
118
119 def test_pagination_params_defaults() -> None:
120 """PaginationParams stores cursor=None and limit=20 when passed explicitly."""
121 p = PaginationParams(cursor=None, limit=20)
122 assert p.cursor is None
123 assert p.limit == 20
124
125
126 def test_pagination_params_min_limit() -> None:
127 """PaginationParams accepts limit=1 (the minimum)."""
128 p = PaginationParams(cursor=None, limit=1)
129 assert p.limit == 1
130
131
132 def test_pagination_params_max_limit() -> None:
133 """PaginationParams accepts limit=200 (the maximum)."""
134 p = PaginationParams(cursor=None, limit=200)
135 assert p.limit == 200
136
137
138 def test_build_cursor_link_header_contains_rel_next() -> None:
139 """build_cursor_link_header always emits rel='next'."""
140 req = _make_request("http://test/api/repos/r1/issues")
141 header = build_cursor_link_header(req, next_cursor="tok", limit=20)
142 assert 'rel="next"' in header
143
144
145 def test_build_cursor_link_header_encodes_special_chars() -> None:
146 """Cursor with special chars is URL-encoded in the Link header."""
147 req = _make_request("http://test/api/repos/r1/issues")
148 header = build_cursor_link_header(req, next_cursor="a=b&c=d", limit=20)
149 # The cursor value must appear encoded in the link
150 assert "rel=\"next\"" in header
151 # Raw & would break URL parsing; encoded form is acceptable
152 assert "a=b&c=d" not in header.split(";")[0]
153
154
155 def test_build_cursor_link_header_overrides_existing_cursor() -> None:
156 """build_cursor_link_header replaces any existing cursor param."""
157 req = _make_request("http://test/api/repos/r1/issues?cursor=old&limit=5")
158 header = build_cursor_link_header(req, next_cursor="new", limit=5)
159 link_url = header.split(";")[0].strip("<>")
160 assert "cursor=new" in link_url
161 assert "cursor=old" not in link_url
162
163
164 def test_build_cursor_link_header_preserves_filter_params() -> None:
165 """build_cursor_link_header carries forward non-pagination query params."""
166 req = _make_request("http://test/api/repos/r1/issues?state=open&label=bug")
167 header = build_cursor_link_header(req, next_cursor="abc", limit=10)
168 assert "state=open" in header
169 assert "label=bug" in header
170
171
172 # ---------------------------------------------------------------------------
173 # Layer 2 — Integration tests
174 # ---------------------------------------------------------------------------
175
176
177 async def test_empty_repo_returns_empty_list_not_404(
178 client: AsyncClient,
179 auth_headers: StrDict,
180 ) -> None:
181 """GET /issues on an empty repo returns 200 with empty list, not 404."""
182 repo_id = await _create_repo(client, auth_headers, "empty-l2")
183 r = await client.get(f"/api/repos/{repo_id}/issues", headers=auth_headers)
184 assert r.status_code == 200
185 body = r.json()
186 assert body["issues"] == []
187 assert body["total"] == 0
188 assert body["nextCursor"] is None
189
190
191 async def test_label_filter_combined_with_pagination(
192 client: AsyncClient,
193 auth_headers: StrDict,
194 ) -> None:
195 """Label filter is applied before pagination; total reflects filtered count."""
196 repo_id = await _create_repo(client, auth_headers, "label-filter-l2")
197 for i in range(6):
198 label = "bug" if i % 2 == 0 else "enhancement"
199 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}", labels=[label])
200
201 r = await client.get(
202 f"/api/repos/{repo_id}/issues?label=bug&limit=2",
203 headers=auth_headers,
204 )
205 assert r.status_code == 200
206 body = r.json()
207 assert body["total"] == 3 # 3 bug issues
208 assert len(body["issues"]) == 2
209 for issue in body["issues"]:
210 assert "bug" in issue["labels"]
211
212
213 async def test_sort_stability_ascending_by_number(
214 client: AsyncClient,
215 auth_headers: StrDict,
216 ) -> None:
217 """Issues are returned in ascending number order across pages."""
218 repo_id = await _create_repo(client, auth_headers, "sort-stable-l2")
219 for i in range(5):
220 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
221
222 all_issues = await _walk_all_issues(client, auth_headers, repo_id, limit=2)
223 numbers = [issue["number"] for issue in all_issues]
224 assert numbers == sorted(numbers)
225
226
227 async def test_state_filter_combined_with_pagination(
228 client: AsyncClient,
229 auth_headers: StrDict,
230 ) -> None:
231 """?state=closed filters to closed issues only and total reflects that."""
232 repo_id = await _create_repo(client, auth_headers, "state-filter-l2")
233 issues = []
234 for i in range(4):
235 issue = await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
236 issues.append(issue)
237
238 # Close the first two
239 for issue in issues[:2]:
240 await client.post(
241 f"/api/repos/{repo_id}/issues/{issue['number']}/close",
242 headers=auth_headers,
243 )
244
245 r = await client.get(
246 f"/api/repos/{repo_id}/issues?state=closed&limit=10",
247 headers=auth_headers,
248 )
249 assert r.status_code == 200
250 body = r.json()
251 assert body["total"] == 2
252 assert all(i["state"] == "closed" for i in body["issues"])
253
254
255 # ---------------------------------------------------------------------------
256 # Layer 3 — E2E tests (full HTTP round-trips)
257 # ---------------------------------------------------------------------------
258
259
260 async def test_e2e_total_consistent_across_pages(
261 client: AsyncClient,
262 auth_headers: StrDict,
263 ) -> None:
264 """total field is identical across all pages of the same request."""
265 repo_id = await _create_repo(client, auth_headers, "e2e-total")
266 for i in range(7):
267 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
268
269 totals: list[int] = []
270 cursor: str | None = None
271 while True:
272 params = "limit=3"
273 if cursor:
274 params += f"&cursor={cursor}"
275 r = await client.get(f"/api/repos/{repo_id}/issues?{params}", headers=auth_headers)
276 body = r.json()
277 totals.append(body["total"])
278 cursor = body["nextCursor"]
279 if cursor is None:
280 break
281
282 assert all(t == 7 for t in totals)
283
284
285 async def test_e2e_link_header_present_only_when_more_pages(
286 client: AsyncClient,
287 auth_headers: StrDict,
288 ) -> None:
289 """Link header is present on non-last pages and absent on the last page."""
290 repo_id = await _create_repo(client, auth_headers, "e2e-link")
291 for i in range(4):
292 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
293
294 # First page — more exist → Link present
295 r1 = await client.get(
296 f"/api/repos/{repo_id}/issues?limit=3",
297 headers=auth_headers,
298 )
299 assert r1.json()["nextCursor"] is not None
300 assert "Link" in r1.headers
301
302 # Second page — last page → Link absent
303 cursor = r1.json()["nextCursor"]
304 r2 = await client.get(
305 f"/api/repos/{repo_id}/issues?limit=3&cursor={cursor}",
306 headers=auth_headers,
307 )
308 assert r2.json()["nextCursor"] is None
309 assert "Link" not in r2.headers
310
311
312 async def test_e2e_cursor_in_link_header_matches_body(
313 client: AsyncClient,
314 auth_headers: StrDict,
315 ) -> None:
316 """The cursor in the Link header matches next_cursor in the body."""
317 repo_id = await _create_repo(client, auth_headers, "e2e-cursor-match")
318 for i in range(5):
319 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
320
321 r = await client.get(
322 f"/api/repos/{repo_id}/issues?limit=3",
323 headers=auth_headers,
324 )
325 next_cursor = r.json()["nextCursor"]
326 link_header = r.headers.get("Link", "")
327 assert f"cursor={next_cursor}" in link_header
328
329
330 # ---------------------------------------------------------------------------
331 # Layer 4 — Stress tests
332 # ---------------------------------------------------------------------------
333
334
335 async def test_stress_100_item_full_cursor_walk(
336 client: AsyncClient,
337 auth_headers: StrDict,
338 db_session: AsyncSession,
339 ) -> None:
340 """Walking 100 issues with limit=10 requires exactly 10 pages.
341
342 Issues are created via the service layer to bypass the HTTP rate limiter.
343 """
344 repo_id = await _create_repo(client, auth_headers, "stress-100")
345 for i in range(100):
346 await issues_svc.create_issue(
347 db_session, repo_id=repo_id, title=f"Issue {i + 1}",
348 body="", labels=[], author="testuser",
349 )
350 await db_session.commit()
351
352 all_issues = await _walk_all_issues(client, auth_headers, repo_id, limit=10)
353 assert len(all_issues) == 100
354
355
356 async def test_stress_last_page_has_correct_remainder(
357 client: AsyncClient,
358 auth_headers: StrDict,
359 db_session: AsyncSession,
360 ) -> None:
361 """The last page carries exactly total % limit items when not evenly divisible.
362
363 Issues are created via the service layer to bypass the HTTP rate limiter.
364 """
365 repo_id = await _create_repo(client, auth_headers, "stress-remainder")
366 for i in range(23):
367 await issues_svc.create_issue(
368 db_session, repo_id=repo_id, title=f"Issue {i + 1}",
369 body="", labels=[], author="testuser",
370 )
371 await db_session.commit()
372
373 all_issues = await _walk_all_issues(client, auth_headers, repo_id, limit=10)
374 assert len(all_issues) == 23
375
376
377 # ---------------------------------------------------------------------------
378 # Layer 5 — Data Integrity
379 # ---------------------------------------------------------------------------
380
381
382 async def test_integrity_no_items_skipped_across_pages(
383 client: AsyncClient,
384 auth_headers: StrDict,
385 ) -> None:
386 """Walking all pages recovers every issue with no gaps."""
387 repo_id = await _create_repo(client, auth_headers, "integrity-no-skip")
388 for i in range(15):
389 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
390
391 all_issues = await _walk_all_issues(client, auth_headers, repo_id, limit=4)
392 numbers = sorted(issue["number"] for issue in all_issues)
393 assert numbers == list(range(1, 16))
394
395
396 async def test_integrity_no_duplicates_across_pages(
397 client: AsyncClient,
398 auth_headers: StrDict,
399 ) -> None:
400 """No issue appears on more than one page."""
401 repo_id = await _create_repo(client, auth_headers, "integrity-no-dupe")
402 for i in range(12):
403 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}")
404
405 all_issues = await _walk_all_issues(client, auth_headers, repo_id, limit=5)
406 numbers = [issue["number"] for issue in all_issues]
407 assert len(numbers) == len(set(numbers)), "Duplicate issues found across pages"
408
409
410 async def test_integrity_filter_preserved_across_pages(
411 client: AsyncClient,
412 auth_headers: StrDict,
413 ) -> None:
414 """Label filter is applied consistently on every page of a paginated walk."""
415 repo_id = await _create_repo(client, auth_headers, "integrity-filter")
416 for i in range(10):
417 label = "bug" if i < 6 else "other"
418 await _create_issue(client, auth_headers, repo_id, title=f"Issue {i + 1}", labels=[label])
419
420 cursor: str | None = None
421 all_issues: list[dict] = []
422 while True:
423 params = "limit=3&label=bug"
424 if cursor:
425 params += f"&cursor={cursor}"
426 r = await client.get(f"/api/repos/{repo_id}/issues?{params}", headers=auth_headers)
427 body = r.json()
428 all_issues.extend(body["issues"])
429 cursor = body["nextCursor"]
430 if cursor is None:
431 break
432
433 assert len(all_issues) == 6
434 assert all("bug" in issue["labels"] for issue in all_issues)
435
436
437 # ---------------------------------------------------------------------------
438 # Layer 6 — Security
439 # ---------------------------------------------------------------------------
440
441
442 async def test_security_limit_zero_rejected(
443 client: AsyncClient,
444 auth_headers: StrDict,
445 ) -> None:
446 """limit=0 is rejected with 422 Unprocessable Entity."""
447 repo_id = await _create_repo(client, auth_headers, "sec-limit-zero")
448 r = await client.get(f"/api/repos/{repo_id}/issues?limit=0", headers=auth_headers)
449 assert r.status_code == 422
450
451
452 async def test_security_limit_over_max_rejected(
453 client: AsyncClient,
454 auth_headers: StrDict,
455 ) -> None:
456 """limit=201 is rejected with 422 Unprocessable Entity."""
457 repo_id = await _create_repo(client, auth_headers, "sec-limit-over")
458 r = await client.get(f"/api/repos/{repo_id}/issues?limit=201", headers=auth_headers)
459 assert r.status_code == 422
460
461
462 async def test_security_cursor_with_special_chars_accepted(
463 client: AsyncClient,
464 auth_headers: StrDict,
465 ) -> None:
466 """A cursor with URL-safe base64 chars is accepted without a 422."""
467 repo_id = await _create_repo(client, auth_headers, "sec-cursor-chars")
468 # A cursor that won't match any real cursor — returns empty page, not 422.
469 r = await client.get(
470 f"/api/repos/{repo_id}/issues?cursor=9999999&limit=10",
471 headers=auth_headers,
472 )
473 assert r.status_code == 200
474
475
476 # ---------------------------------------------------------------------------
477 # Layer 7 — Performance
478 # ---------------------------------------------------------------------------
479
480
481 async def test_performance_issue_list_50_items_under_500ms(
482 client: AsyncClient,
483 auth_headers: StrDict,
484 db_session: AsyncSession,
485 ) -> None:
486 """GET /issues with 50 items in the repo responds in under 500ms.
487
488 Issues are created via the service layer to bypass the HTTP rate limiter.
489 """
490 repo_id = await _create_repo(client, auth_headers, "perf-50")
491 for i in range(50):
492 await issues_svc.create_issue(
493 db_session, repo_id=repo_id, title=f"Issue {i + 1}",
494 body="", labels=[], author="testuser",
495 )
496 await db_session.commit()
497
498 start = time.monotonic()
499 r = await client.get(
500 f"/api/repos/{repo_id}/issues?limit=50",
501 headers=auth_headers,
502 )
503 elapsed = time.monotonic() - start
504
505 assert r.status_code == 200
506 assert elapsed < 0.5, f"Response took {elapsed:.3f}s (limit: 0.5s)"
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago