mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-06-01 11:57:24 +08:00
feat(assets): wire cursor pagination through GET /api/assets handler
Adds integration tests for: full cursor walk, invalid-cursor 400, sort/cursor mismatch 400, cursor-wins-over-offset, absent next_cursor when no more results, and pagination stability across deletes.
This commit is contained in:
parent
39abd769b1
commit
df1f6a7fcc
@ -39,6 +39,7 @@ from app.assets.services import (
|
|||||||
update_asset_metadata,
|
update_asset_metadata,
|
||||||
upload_from_temp_path,
|
upload_from_temp_path,
|
||||||
)
|
)
|
||||||
|
from app.assets.services.cursor import InvalidCursorError
|
||||||
from app.assets.services.tagging import list_tag_histogram
|
from app.assets.services.tagging import list_tag_histogram
|
||||||
|
|
||||||
ROUTES = web.RouteTableDef()
|
ROUTES = web.RouteTableDef()
|
||||||
@ -209,24 +210,40 @@ async def list_assets_route(request: web.Request) -> web.Response:
|
|||||||
order_candidate = (q.order or "desc").lower()
|
order_candidate = (q.order or "desc").lower()
|
||||||
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
|
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"
|
||||||
|
|
||||||
result = list_assets_page(
|
try:
|
||||||
owner_id=USER_MANAGER.get_request_user_id(request),
|
result = list_assets_page(
|
||||||
include_tags=q.include_tags,
|
owner_id=USER_MANAGER.get_request_user_id(request),
|
||||||
exclude_tags=q.exclude_tags,
|
include_tags=q.include_tags,
|
||||||
name_contains=q.name_contains,
|
exclude_tags=q.exclude_tags,
|
||||||
metadata_filter=q.metadata_filter,
|
name_contains=q.name_contains,
|
||||||
limit=q.limit,
|
metadata_filter=q.metadata_filter,
|
||||||
offset=q.offset,
|
limit=q.limit,
|
||||||
sort=sort,
|
offset=q.offset,
|
||||||
order=order,
|
sort=sort,
|
||||||
)
|
order=order,
|
||||||
|
after=q.after,
|
||||||
|
)
|
||||||
|
except InvalidCursorError as e:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": {"code": "INVALID_CURSOR", "message": str(e)}},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
summaries = [_build_asset_response(item) for item in result.items]
|
summaries = [_build_asset_response(item) for item in result.items]
|
||||||
|
|
||||||
|
# has_more semantics differ by mode:
|
||||||
|
# - cursor mode: a non-empty next_cursor means there are more results.
|
||||||
|
# - offset mode: derived from total - (offset + page size).
|
||||||
|
if q.after is not None:
|
||||||
|
has_more = result.next_cursor is not None
|
||||||
|
else:
|
||||||
|
has_more = (q.offset + len(summaries)) < result.total
|
||||||
|
|
||||||
payload = schemas_out.AssetsList(
|
payload = schemas_out.AssetsList(
|
||||||
assets=summaries,
|
assets=summaries,
|
||||||
total=result.total,
|
total=result.total,
|
||||||
has_more=(q.offset + len(summaries)) < result.total,
|
has_more=has_more,
|
||||||
|
next_cursor=result.next_cursor,
|
||||||
)
|
)
|
||||||
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
|
return web.json_response(payload.model_dump(mode="json", exclude_none=True))
|
||||||
|
|
||||||
|
|||||||
189
tests-unit/assets_test/test_list_cursor.py
Normal file
189
tests-unit/assets_test/test_list_cursor.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"""Integration tests for cursor-based pagination on GET /api/assets.
|
||||||
|
|
||||||
|
Wire contract is shared with cloud's Go implementation (BE-893). 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 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", "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}"
|
||||||
|
# Last page should have has_more False
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Take a real cursor minted for sort=name.
|
||||||
|
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
|
||||||
|
|
||||||
|
# Replay against sort=created_at — should fail with INVALID_CURSOR.
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Take a cursor that points past the first item.
|
||||||
|
r = http.get(
|
||||||
|
api_base + "/api/assets",
|
||||||
|
params={
|
||||||
|
"include_tags": "unit-tests,cursor-vs-offset",
|
||||||
|
"sort": "name",
|
||||||
|
"order": "asc",
|
||||||
|
"limit": "1",
|
||||||
|
},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
cursor = r.json()["next_cursor"]
|
||||||
|
assert cursor is not None
|
||||||
|
|
||||||
|
# Pass both 'after' and a large offset. Cursor must win; offset is ignored.
|
||||||
|
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()
|
||||||
|
# Should land on the second name in sorted order — not skip ahead by 999.
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
assert body["has_more"] is False
|
||||||
|
assert "next_cursor" not in body
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Page 1.
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Delete an item from page 1 (already returned) — cursor should still
|
||||||
|
# locate the next page from where it was minted, not re-index.
|
||||||
|
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
|
||||||
|
|
||||||
|
# Page 2 via cursor.
|
||||||
|
r2 = http.get(
|
||||||
|
api_base + "/api/assets",
|
||||||
|
params={
|
||||||
|
"include_tags": "unit-tests,cursor-delete",
|
||||||
|
"sort": "name",
|
||||||
|
"order": "asc",
|
||||||
|
"limit": "2",
|
||||||
|
"after": cursor,
|
||||||
|
},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
body2 = r2.json()
|
||||||
|
assert [a["name"] for a in body2["assets"]] == names[2:]
|
||||||
Loading…
Reference in New Issue
Block a user