This commit is contained in:
Matt Miller 2026-05-08 20:02:40 -07:00 committed by GitHub
commit bbb6dcc621
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 275 additions and 0 deletions

View File

@ -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:
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:

View File

@ -1823,6 +1823,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

View File

@ -0,0 +1,153 @@
import uuid
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"
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"]