From 9bd5710acb159f9aa027c94d689ba2883cc6f1ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 May 2026 23:56:26 +0000 Subject: [PATCH 1/3] Add asset preview management endpoints (PUT/DELETE /api/assets/{id}/preview) - PUT /api/assets/{id}/preview: sets the preview asset for an existing asset, returns the full updated Asset object. Returns 404 if asset ID doesn't exist. - DELETE /api/assets/{id}/preview: clears the preview asset link, returns 204. Returns 404 if asset ID doesn't exist. Added OpenAPI spec entries under the assets tag with no x-runtime restriction. Implemented handlers using the existing set_asset_preview service function. Added integration tests covering happy paths and 404 cases. Co-authored-by: Matt Miller --- app/assets/api/routes.py | 65 +++++++++++++++ openapi.yaml | 57 +++++++++++++ tests-unit/assets_test/test_preview.py | 111 +++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 tests-unit/assets_test/test_preview.py diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index 68126b6a5..bc7b697a8 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -36,6 +36,7 @@ from app.assets.services import ( list_tags, remove_tags, resolve_asset_for_download, + set_asset_preview, update_asset_metadata, upload_from_temp_path, ) @@ -545,6 +546,70 @@ async def delete_asset_route(request: web.Request) -> web.Response: return web.Response(status=204) +@ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}/preview") +@_require_assets_feature_enabled +async def set_asset_preview_route(request: web.Request) -> web.Response: + reference_id = str(uuid.UUID(request.match_info["id"])) + try: + payload = await request.json() + except Exception: + return _build_error_response( + 400, "INVALID_JSON", "Request body must be valid JSON." + ) + + preview_id = payload.get("preview_id") + if not preview_id: + return _build_error_response( + 400, "INVALID_BODY", "preview_id is required." + ) + + try: + result = set_asset_preview( + reference_id=reference_id, + preview_reference_id=preview_id, + owner_id=USER_MANAGER.get_request_user_id(request), + ) + response_payload = _build_asset_response(result) + except PermissionError as pe: + return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id}) + except ValueError as ve: + return _build_error_response( + 404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id} + ) + except Exception: + logging.exception( + "set_asset_preview failed for reference_id=%s", + reference_id, + ) + return _build_error_response(500, "INTERNAL", "Unexpected server error.") + return web.json_response(response_payload.model_dump(mode="json", exclude_none=True), status=200) + + +@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/preview") +@_require_assets_feature_enabled +async def clear_asset_preview_route(request: web.Request) -> web.Response: + reference_id = str(uuid.UUID(request.match_info["id"])) + try: + result = set_asset_preview( + reference_id=reference_id, + preview_reference_id=None, + owner_id=USER_MANAGER.get_request_user_id(request), + ) + except PermissionError as pe: + return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id}) + except ValueError as ve: + return _build_error_response( + 404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id} + ) + except Exception: + logging.exception( + "clear_asset_preview failed for reference_id=%s", + reference_id, + ) + return _build_error_response(500, "INTERNAL", "Unexpected server error.") + return web.Response(status=204) + + @ROUTES.get("/api/tags") @_require_assets_feature_enabled async def get_tags(request: web.Request) -> web.Response: diff --git a/openapi.yaml b/openapi.yaml index 4216c1a6c..e667fd67e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1821,6 +1821,63 @@ paths: "404": description: Asset not found + /api/assets/{id}/preview: + put: + operationId: setAssetPreview + tags: [assets] + summary: Set preview asset + description: Sets the preview asset for an existing asset. The preview_id must reference an existing asset. + x-feature-gate: enable-assets + parameters: + - name: id + in: path + description: The asset ID. + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - preview_id + properties: + preview_id: + type: string + format: uuid + description: ID of the asset to use as the preview + responses: + "200": + description: Preview set successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Asset" + "404": + description: Asset not found + delete: + operationId: clearAssetPreview + tags: [assets] + summary: Clear preview asset + description: Clears the preview asset link for an existing asset. + x-feature-gate: enable-assets + parameters: + - name: id + in: path + description: The asset ID. + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Preview cleared + "404": + description: Asset not found + /api/assets/{id}/tags: post: operationId: addAssetTags diff --git a/tests-unit/assets_test/test_preview.py b/tests-unit/assets_test/test_preview.py new file mode 100644 index 000000000..391de741c --- /dev/null +++ b/tests-unit/assets_test/test_preview.py @@ -0,0 +1,111 @@ +import uuid + +import pytest +import requests + + +def test_set_preview_success( + http: requests.Session, api_base: str, asset_factory, make_asset_bytes +): + """PUT /api/assets/{id}/preview sets the preview and returns the full Asset.""" + main_data = make_asset_bytes("main_asset.png", 2048) + preview_data = make_asset_bytes("preview_asset.png", 1024) + + main = asset_factory("main_asset.png", ["input", "unit-tests"], {}, main_data) + preview = asset_factory("preview_asset.png", ["input", "unit-tests"], {}, preview_data) + + r = http.put( + f"{api_base}/api/assets/{main['id']}/preview", + json={"preview_id": preview["id"]}, + timeout=120, + ) + body = r.json() + assert r.status_code == 200, body + assert body["id"] == main["id"] + assert body["preview_id"] == preview["id"] + + +def test_clear_preview_success( + http: requests.Session, api_base: str, asset_factory, make_asset_bytes +): + """DELETE /api/assets/{id}/preview clears the preview and returns 204.""" + main_data = make_asset_bytes("main_clear.png", 2048) + preview_data = make_asset_bytes("prev_clear.png", 1024) + + main = asset_factory("main_clear.png", ["input", "unit-tests"], {}, main_data) + preview = asset_factory("prev_clear.png", ["input", "unit-tests"], {}, preview_data) + + # First set a preview + r_set = http.put( + f"{api_base}/api/assets/{main['id']}/preview", + json={"preview_id": preview["id"]}, + timeout=120, + ) + assert r_set.status_code == 200 + + # Now clear it + r_del = http.delete(f"{api_base}/api/assets/{main['id']}/preview", timeout=120) + assert r_del.status_code == 204 + + # Verify preview_id is cleared + r_get = http.get(f"{api_base}/api/assets/{main['id']}", timeout=120) + detail = r_get.json() + assert r_get.status_code == 200, detail + assert detail.get("preview_id") is None + + +def test_set_preview_asset_not_found(http: requests.Session, api_base: str): + """PUT /api/assets/{id}/preview returns 404 for non-existent asset ID.""" + fake_id = str(uuid.uuid4()) + r = http.put( + f"{api_base}/api/assets/{fake_id}/preview", + json={"preview_id": str(uuid.uuid4())}, + timeout=120, + ) + body = r.json() + assert r.status_code == 404 + assert body["error"]["code"] == "ASSET_NOT_FOUND" + + +def test_clear_preview_asset_not_found(http: requests.Session, api_base: str): + """DELETE /api/assets/{id}/preview returns 404 for non-existent asset ID.""" + fake_id = str(uuid.uuid4()) + r = http.delete(f"{api_base}/api/assets/{fake_id}/preview", timeout=120) + body = r.json() + assert r.status_code == 404 + assert body["error"]["code"] == "ASSET_NOT_FOUND" + + +def test_set_preview_missing_body( + http: requests.Session, api_base: str, asset_factory, make_asset_bytes +): + """PUT /api/assets/{id}/preview returns 400 when preview_id is not provided.""" + main_data = make_asset_bytes("main_nobody.png", 2048) + main = asset_factory("main_nobody.png", ["input", "unit-tests"], {}, main_data) + + r = http.put( + f"{api_base}/api/assets/{main['id']}/preview", + json={}, + timeout=120, + ) + body = r.json() + assert r.status_code == 400 + assert body["error"]["code"] == "INVALID_BODY" + + +def test_set_preview_invalid_preview_id( + http: requests.Session, api_base: str, asset_factory, make_asset_bytes +): + """PUT /api/assets/{id}/preview returns 404 when preview_id references non-existent asset.""" + main_data = make_asset_bytes("main_badprev.png", 2048) + main = asset_factory("main_badprev.png", ["input", "unit-tests"], {}, main_data) + + fake_preview_id = str(uuid.uuid4()) + r = http.put( + f"{api_base}/api/assets/{main['id']}/preview", + json={"preview_id": fake_preview_id}, + timeout=120, + ) + body = r.json() + assert r.status_code == 404 + assert body["error"]["code"] == "ASSET_NOT_FOUND" From 05939232b12d2b04267f1627d2b36f25e79c4766 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Wed, 6 May 2026 17:32:06 -0700 Subject: [PATCH 2/3] Fix Ruff lint failures - Drop unused 'result' assignment in clear_asset_preview_route (DELETE returns 204, the result isn't needed). - Remove unused 'pytest' import from test_preview.py. --- app/assets/api/routes.py | 2 +- tests-unit/assets_test/test_preview.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index bc7b697a8..ebd0e5f69 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -590,7 +590,7 @@ async def set_asset_preview_route(request: web.Request) -> web.Response: async def clear_asset_preview_route(request: web.Request) -> web.Response: reference_id = str(uuid.UUID(request.match_info["id"])) try: - result = set_asset_preview( + set_asset_preview( reference_id=reference_id, preview_reference_id=None, owner_id=USER_MANAGER.get_request_user_id(request), diff --git a/tests-unit/assets_test/test_preview.py b/tests-unit/assets_test/test_preview.py index 391de741c..a30359393 100644 --- a/tests-unit/assets_test/test_preview.py +++ b/tests-unit/assets_test/test_preview.py @@ -1,6 +1,5 @@ import uuid -import pytest import requests From 9557a41c83753aa43c071915d1dea147006ceee3 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 7 May 2026 11:21:12 -0700 Subject: [PATCH 3/3] Add idempotence tests for asset preview endpoints PUT with the same preview_id twice should be a 200 no-op, and DELETE on an asset with no preview set should still return 204. The existing test file covered set/clear happy paths and the 404/400 error cases but did not exercise idempotence, which is part of the documented contract for both verbs. --- tests-unit/assets_test/test_preview.py | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests-unit/assets_test/test_preview.py b/tests-unit/assets_test/test_preview.py index a30359393..7fa87d52f 100644 --- a/tests-unit/assets_test/test_preview.py +++ b/tests-unit/assets_test/test_preview.py @@ -108,3 +108,46 @@ def test_set_preview_invalid_preview_id( body = r.json() assert r.status_code == 404 assert body["error"]["code"] == "ASSET_NOT_FOUND" + + +def test_clear_preview_idempotent_when_no_preview_set( + http: requests.Session, api_base: str, asset_factory, make_asset_bytes +): + """DELETE /api/assets/{id}/preview returns 204 even if no preview was previously set.""" + main_data = make_asset_bytes("main_idem_clear.png", 2048) + main = asset_factory("main_idem_clear.png", ["input", "unit-tests"], {}, main_data) + + # Asset has no preview at this point — DELETE should still succeed. + r_first = http.delete(f"{api_base}/api/assets/{main['id']}/preview", timeout=120) + assert r_first.status_code == 204, r_first.text + + # And a second DELETE on the same asset should also be a 204 no-op. + r_second = http.delete(f"{api_base}/api/assets/{main['id']}/preview", timeout=120) + assert r_second.status_code == 204, r_second.text + + +def test_set_preview_idempotent_with_same_id( + http: requests.Session, api_base: str, asset_factory, make_asset_bytes +): + """PUT /api/assets/{id}/preview with the same preview_id twice is a 200 no-op.""" + main_data = make_asset_bytes("main_idem_set.png", 2048) + preview_data = make_asset_bytes("prev_idem_set.png", 1024) + + main = asset_factory("main_idem_set.png", ["input", "unit-tests"], {}, main_data) + preview = asset_factory("prev_idem_set.png", ["input", "unit-tests"], {}, preview_data) + + r_first = http.put( + f"{api_base}/api/assets/{main['id']}/preview", + json={"preview_id": preview["id"]}, + timeout=120, + ) + assert r_first.status_code == 200, r_first.text + assert r_first.json()["preview_id"] == preview["id"] + + r_second = http.put( + f"{api_base}/api/assets/{main['id']}/preview", + json={"preview_id": preview["id"]}, + timeout=120, + ) + assert r_second.status_code == 200, r_second.text + assert r_second.json()["preview_id"] == preview["id"]