# ============================================================= # OxiCloud – An UPLOADED thumbnail survives both copy paths # ============================================================= # A user-supplied preview is not derivable from the file's content, so # nothing can regenerate it. If a copy loses it, it is gone — and the loss # is silent, because the server quietly falls back to rendering one from # the source (or to 204 for a PDF, which has no render path at all). # # That was the behaviour before `storage.file_attached_blobs`: the PUT # wrote `ext-{file_id}.jpg`, keyed by file id, which no copy path # duplicates and no other instance can see. # # The test distinguishes "preserved" from "re-rendered" by making the two # visibly different: the FILE is red-image.png, the uploaded thumbnail is # derived from green-image.png. A server-side render of the file could # only ever produce the red one. So byte-equality with the post-upload # bytes proves the copy served the ATTACHMENT, not a fresh render. # # Step 4 is what makes that airtight — it captures the rendered thumbnail # BEFORE the upload and requires the upload to change it. Without that, # byte-equality across copies could be satisfied by three identical # renders. # # Prerequisites: setup.hurl must have run (admin user exists). # ============================================================= # ───────────────────────────────────────────────────────────── # Step 1 – Login # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/login Content-Type: application/json { "username": "{{username}}", "password": "{{password}}" } HTTP 200 [Captures] token: jsonpath "$.access_token" # ───────────────────────────────────────────────────────────── # Step 2 – Source and destination folders # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/folders Authorization: Bearer {{token}} Content-Type: application/json { "name": "hurl-attach-src" } HTTP 201 [Captures] src_folder_id: jsonpath "$.id" POST {{base_url}}/api/folders Authorization: Bearer {{token}} Content-Type: application/json { "name": "hurl-attach-dst" } HTTP 201 [Captures] dst_folder_id: jsonpath "$.id" # ───────────────────────────────────────────────────────────── # Step 3 – Upload the file (RED) # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/files/upload Authorization: Bearer {{token}} [MultipartFormData] folder_id: {{src_folder_id}} file: file,fixtures/red-image.png; image/png HTTP 201 [Captures] orig_file_id: jsonpath "$.id" orig_file_name: jsonpath "$.name" # ───────────────────────────────────────────────────────────── # Step 4 – The server-rendered thumbnail, before any upload. # Captured so the upload can be shown to have replaced it. # ───────────────────────────────────────────────────────────── GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview Authorization: Bearer {{token}} HTTP 200 [Captures] rendered_thumb: bytes # ───────────────────────────────────────────────────────────── # Step 5 – Upload a custom thumbnail (GREEN) for that file # ───────────────────────────────────────────────────────────── PUT {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview Authorization: Bearer {{token}} Content-Type: image/png file,fixtures/green-image.png; HTTP 201 # It must now serve the upload, not the render. GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview Authorization: Bearer {{token}} HTTP 200 [Captures] uploaded_thumb: bytes [Asserts] bytes != {{rendered_thumb}} # ───────────────────────────────────────────────────────────── # Step 6 – Single-file copy → the attachment comes with it. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/batch/files/copy Authorization: Bearer {{token}} Content-Type: application/json { "file_ids": ["{{orig_file_id}}"], "target_folder_id": "{{dst_folder_id}}" } HTTP 200 [Captures] file_copy_id: jsonpath "$.successful[0].id" [Asserts] jsonpath "$.successful[0].id" != "{{orig_file_id}}" GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview Authorization: Bearer {{token}} HTTP 200 [Asserts] bytes == {{uploaded_thumb}} bytes != {{rendered_thumb}} # ───────────────────────────────────────────────────────────── # Step 7 – Folder copy → same, through storage.copy_folder_tree. # # The other copy path. It reaches the attachment through the same # `copy_file_satellites` call, and this is the leg that would break if # the tree path ever grew its own fan-out again. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/batch/folders/copy Authorization: Bearer {{token}} Content-Type: application/json { "folder_ids": ["{{src_folder_id}}"], "target_folder_id": "{{dst_folder_id}}" } HTTP 200 [Captures] tree_root_id: jsonpath "$.successful[0].new_root_folder_id" [Asserts] jsonpath "$.stats.failed" == 0 GET {{base_url}}/api/files?folder_id={{tree_root_id}} Authorization: Bearer {{token}} HTTP 200 [Captures] tree_copy_id: jsonpath "$[0].id" [Asserts] jsonpath "$" count == 1 jsonpath "$[0].name" == "{{orig_file_name}}" jsonpath "$[0].id" != "{{orig_file_id}}" GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview Authorization: Bearer {{token}} HTTP 200 [Asserts] bytes == {{uploaded_thumb}} bytes != {{rendered_thumb}} # ───────────────────────────────────────────────────────────── # Step 8 – Delete the ORIGINAL, run GC, and require both copies to keep # serving the upload. # # Each copy holds its own reference on the attached blob — the rows are # duplicated, not shared, because the table is file-keyed. If the copy # had failed to take one, deleting the original would walk the count to # zero and GC would reap bytes that cannot be regenerated. # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/api/files/{{orig_file_id}} Authorization: Bearer {{token}} HTTP 204 GET {{base_url}}/api/trash/resources Authorization: Bearer {{token}} HTTP 200 [Captures] trash_orig_id: jsonpath "$.items[?(@.resource.id == '{{orig_file_id}}')].resource.id" DELETE {{base_url}}/api/trash/{{trash_orig_id}} Authorization: Bearer {{token}} HTTP 200 POST {{base_url}}/api/admin/jobs/dedup_gc/trigger Authorization: Bearer {{token}} [Options] delay: 500ms HTTP 200 GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview Authorization: Bearer {{token}} HTTP 200 [Asserts] bytes == {{uploaded_thumb}} GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview Authorization: Bearer {{token}} HTTP 200 [Asserts] bytes == {{uploaded_thumb}} # ───────────────────────────────────────────────────────────── # Step 9 – Teardown. Hurl files share one database within run.sh. # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/api/folders/{{src_folder_id}} Authorization: Bearer {{token}} HTTP 204 DELETE {{base_url}}/api/folders/{{dst_folder_id}} Authorization: Bearer {{token}} HTTP 204 GET {{base_url}}/api/trash/resources Authorization: Bearer {{token}} HTTP 200 [Captures] trash_src_id: jsonpath "$.items[?(@.resource.id == '{{src_folder_id}}')].resource.id" trash_dst_id: jsonpath "$.items[?(@.resource.id == '{{dst_folder_id}}')].resource.id" DELETE {{base_url}}/api/trash/{{trash_src_id}} Authorization: Bearer {{token}} HTTP 200 DELETE {{base_url}}/api/trash/{{trash_dst_id}} Authorization: Bearer {{token}} HTTP 200