| """Integration tests for cursor-based pagination on GET /api/assets. |
| |
| These tests exercise the handler/service/query path end-to-end; |
| cursor-encoding-level tests live in |
| tests-unit/assets_test/services/test_cursor.py. |
| """ |
| import pytest |
| import requests |
|
|
|
|
| def _seed(asset_factory, make_asset_bytes, count: int, tag: str) -> list[str]: |
| names = [f"cursor_{i:02d}.safetensors" for i in range(count)] |
| for n in names: |
| asset_factory( |
| n, |
| ["models", "model_type:checkpoints", "unit-tests", tag], |
| {}, |
| make_asset_bytes(n, size=2048), |
| ) |
| return sorted(names) |
|
|
|
|
| def test_cursor_pages_all_items_in_order(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| names = _seed(asset_factory, make_asset_bytes, count=5, tag="cursor-walk") |
|
|
| params = { |
| "include_tags": "unit-tests,cursor-walk", |
| "sort": "name", |
| "order": "asc", |
| "limit": "2", |
| } |
|
|
| seen: list[str] = [] |
| after: str | None = None |
| pages = 0 |
| while True: |
| page_params = dict(params) |
| if after is not None: |
| page_params["after"] = after |
| r = http.get(api_base + "/api/assets", params=page_params, timeout=120) |
| assert r.status_code == 200, r.text |
| body = r.json() |
| seen.extend(a["name"] for a in body["assets"]) |
| pages += 1 |
| after = body.get("next_cursor") |
| if after is None: |
| break |
| assert body["has_more"] is True |
| assert pages < 10, "guard against runaway cursor loop" |
|
|
| assert seen == names, f"expected {names}, got {seen}" |
| |
| assert body["has_more"] is False |
| assert "next_cursor" not in body |
|
|
|
|
| def test_cursor_invalid_returns_400(http: requests.Session, api_base: str): |
| r = http.get( |
| api_base + "/api/assets", |
| params={"after": "not-a-real-cursor", "sort": "created_at"}, |
| timeout=120, |
| ) |
| assert r.status_code == 400, r.text |
| body = r.json() |
| assert body["error"]["code"] == "INVALID_CURSOR" |
|
|
|
|
| def test_cursor_sort_mismatch_returns_400(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| _seed(asset_factory, make_asset_bytes, count=2, tag="cursor-mismatch") |
|
|
| |
| r = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-mismatch", |
| "sort": "name", |
| "order": "asc", |
| "limit": "1", |
| }, |
| timeout=120, |
| ) |
| assert r.status_code == 200 |
| cursor = r.json()["next_cursor"] |
| assert cursor is not None |
|
|
| |
| r2 = http.get( |
| api_base + "/api/assets", |
| params={"after": cursor, "sort": "created_at"}, |
| timeout=120, |
| ) |
| assert r2.status_code == 400, r2.text |
| assert r2.json()["error"]["code"] == "INVALID_CURSOR" |
|
|
|
|
| def test_cursor_wins_over_offset(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| names = _seed(asset_factory, make_asset_bytes, count=4, tag="cursor-vs-offset") |
|
|
| |
| r = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-vs-offset", |
| "sort": "name", |
| "order": "asc", |
| "limit": "1", |
| }, |
| timeout=120, |
| ) |
| assert r.status_code == 200, r.text |
| cursor = r.json()["next_cursor"] |
| assert cursor is not None |
|
|
| |
| r2 = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-vs-offset", |
| "sort": "name", |
| "order": "asc", |
| "limit": "1", |
| "after": cursor, |
| "offset": "999", |
| }, |
| timeout=120, |
| ) |
| assert r2.status_code == 200 |
| body = r2.json() |
| |
| assert [a["name"] for a in body["assets"]] == [names[1]] |
|
|
|
|
| def test_next_cursor_absent_when_no_more_results(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| _seed(asset_factory, make_asset_bytes, count=2, tag="cursor-exhaust") |
|
|
| r = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-exhaust", |
| "sort": "name", |
| "order": "asc", |
| "limit": "50", |
| }, |
| timeout=120, |
| ) |
| assert r.status_code == 200, r.text |
| body = r.json() |
| assert body["has_more"] is False |
| assert "next_cursor" not in body |
|
|
|
|
| def test_cursor_pagination_first_page_mints_cursor(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| """First-page request (no `after`) must still return `next_cursor` when |
| more rows exist, or pagination is unreachable from a cold start. |
| """ |
| _seed(asset_factory, make_asset_bytes, count=3, tag="cursor-first-page") |
| r = http.get( |
| api_base + "/api/assets", |
| params={"include_tags": "unit-tests,cursor-first-page", "sort": "name", "order": "asc", "limit": "2"}, |
| timeout=120, |
| ) |
| assert r.status_code == 200, r.text |
| body = r.json() |
| assert body["has_more"] is True |
| assert body.get("next_cursor"), "first page must mint a cursor when more rows exist" |
|
|
|
|
| def test_cursor_no_spurious_cursor_when_page_size_equals_remainder(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| """When `total` is an exact multiple of `limit`, the final page must |
| NOT carry a next_cursor — there is nothing past it. |
| """ |
| _seed(asset_factory, make_asset_bytes, count=4, tag="cursor-exact-multiple") |
| |
| r = http.get( |
| api_base + "/api/assets", |
| params={"include_tags": "unit-tests,cursor-exact-multiple", "sort": "name", "order": "asc", "limit": "2"}, |
| timeout=120, |
| ) |
| assert r.status_code == 200, r.text |
| cursor = r.json()["next_cursor"] |
| assert cursor is not None |
| |
| r2 = http.get( |
| api_base + "/api/assets", |
| params={"include_tags": "unit-tests,cursor-exact-multiple", "sort": "name", "order": "asc", "limit": "2", "after": cursor}, |
| timeout=120, |
| ) |
| assert r2.status_code == 200, r2.text |
| body = r2.json() |
| assert len(body["assets"]) == 2 |
| assert body["has_more"] is False |
| assert "next_cursor" not in body |
|
|
|
|
| @pytest.mark.parametrize("sort_field", ["created_at", "updated_at", "size"]) |
| def test_cursor_walks_for_non_name_sorts(sort_field, http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| """Cursor pagination must work for every sort field the contract claims. |
| |
| Without this, the `created_at` / `updated_at` (time-encoded micros) and |
| `size` (int-encoded) cursor paths go entirely unexercised end-to-end. |
| """ |
| |
| |
| |
| |
| |
| |
| names = [] |
| for i in range(4): |
| n = f"cursor_{sort_field}_{i:02d}.safetensors" |
| asset_factory(n, ["models", "model_type:checkpoints", "unit-tests", f"cursor-{sort_field}"], {}, make_asset_bytes(n, size=2048 + i)) |
| names.append(n) |
|
|
| params = { |
| "include_tags": f"unit-tests,cursor-{sort_field}", |
| "sort": sort_field, |
| "order": "desc", |
| "limit": "2", |
| } |
| seen: list[str] = [] |
| after: str | None = None |
| pages = 0 |
| while True: |
| page_params = dict(params) |
| if after is not None: |
| page_params["after"] = after |
| r = http.get(api_base + "/api/assets", params=page_params, timeout=120) |
| assert r.status_code == 200, r.text |
| body = r.json() |
| seen.extend(a["name"] for a in body["assets"]) |
| after = body.get("next_cursor") |
| pages += 1 |
| if after is None: |
| break |
| assert pages < 10, "guard against runaway cursor loop" |
|
|
| |
| |
| assert len(seen) == len(set(seen)), ( |
| f"cursor walk repeated rows for sort={sort_field}: {seen}" |
| ) |
| |
| assert set(seen) == set(names), ( |
| f"missing items for sort={sort_field}: expected {set(names)}, got {set(seen)}" |
| ) |
| |
| if sort_field == "size": |
| assert seen == list(reversed(names)), ( |
| f"size cursor walked out of order: got {seen}, expected {list(reversed(names))}" |
| ) |
|
|
|
|
| def test_cursor_order_mismatch_returns_400(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| """A cursor minted under desc order replayed against asc must 400, not |
| silently walk the wrong direction.""" |
| _seed(asset_factory, make_asset_bytes, count=3, tag="cursor-order-flip") |
|
|
| r = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-order-flip", |
| "sort": "name", |
| "order": "desc", |
| "limit": "1", |
| }, |
| timeout=120, |
| ) |
| assert r.status_code == 200, r.text |
| cursor = r.json()["next_cursor"] |
| assert cursor is not None |
|
|
| |
| r2 = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-order-flip", |
| "sort": "name", |
| "order": "asc", |
| "limit": "1", |
| "after": cursor, |
| }, |
| timeout=120, |
| ) |
| assert r2.status_code == 400, r2.text |
| assert r2.json()["error"]["code"] == "INVALID_CURSOR" |
|
|
|
|
| def test_cursor_invalid_cursor_at_microsecond_boundary(http: requests.Session, api_base: str): |
| """A cursor carrying an out-of-range microsecond timestamp must map to |
| 400 INVALID_CURSOR, not 500.""" |
| import base64 |
| import json |
| |
| |
| |
| payload = {"s": "created_at", "o": "desc", "v": "999999999999999999999", "id": "asset-x"} |
| raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") |
| cursor = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") |
| r = http.get( |
| api_base + "/api/assets", |
| params={"after": cursor, "sort": "created_at", "order": "desc"}, |
| timeout=120, |
| ) |
| assert r.status_code == 400, r.text |
| assert r.json()["error"]["code"] == "INVALID_CURSOR" |
|
|
|
|
| def test_cursor_pagination_stable_after_delete(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): |
| names = _seed(asset_factory, make_asset_bytes, count=4, tag="cursor-delete") |
|
|
| |
| r = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-delete", |
| "sort": "name", |
| "order": "asc", |
| "limit": "2", |
| }, |
| timeout=120, |
| ) |
| assert r.status_code == 200 |
| body = r.json() |
| page1_names = [a["name"] for a in body["assets"]] |
| cursor = body["next_cursor"] |
| assert cursor is not None |
| assert page1_names == names[:2] |
|
|
| |
| |
| target_id = body["assets"][0]["id"] |
| d = http.delete(api_base + f"/api/assets/{target_id}", timeout=120) |
| assert d.status_code in (200, 204), d.text |
|
|
| |
| r2 = http.get( |
| api_base + "/api/assets", |
| params={ |
| "include_tags": "unit-tests,cursor-delete", |
| "sort": "name", |
| "order": "asc", |
| "limit": "2", |
| "after": cursor, |
| }, |
| timeout=120, |
| ) |
| assert r2.status_code == 200, r2.text |
| body2 = r2.json() |
| assert [a["name"] for a in body2["assets"]] == names[2:] |
|
|