import { afterEach, describe, expect, it, vi } from "vitest"; import app from "../src/index"; import { makeEnv, sharedStores } from "./fakes"; const BASE = "https://attachment.local"; async function call( method: string, path: string, env: unknown, init: { body?: BodyInit; headers?: Record } = {}, ): Promise { const headers: Record = { ...init.headers }; // JSON body 校验要求 content-type: application/json(hono 的 json validator 按 content-type 取体)。 // 没有 body 的请求不带;有 body 但调用方显式给了 content-type 则尊重原值。 if (init.body !== undefined && !headers["content-type"] && !headers["Content-Type"]) { headers["content-type"] = "application/json"; } const request = new Request(`${BASE}${path}`, { method, body: init.body, headers, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any return app.fetch(request, env as any); } async function createAttachment( env: unknown, body: Record, ): Promise<{ attachment: { id: string; status: string; user_id: string } }> { const response = await call("POST", "/api/attachments", env, { body: JSON.stringify(body) }); expect(response.status).toBe(201); return (await response.json()) as { attachment: { id: string; status: string; user_id: string } }; } describe("cfw-attachment worker (core flow)", () => { afterEach(() => { vi.restoreAllMocks(); }); it("proxies /image without a session and forwards Cloudflare image options", async () => { const stores = sharedStores(); const env = makeEnv(stores, null); const origin = "https://cdn.example.com/photo.jpg"; const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response("image-bytes", { status: 200, headers: { "content-type": "image/webp" } }), ); const response = await call( "GET", `/image?url=${encodeURIComponent(origin)}&width=320&height=200&quality=70&fit=cover&format=auto&bg=white`, env, { headers: { Accept: "image/webp" } }, ); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("image/webp"); expect(await response.text()).toBe("image-bytes"); expect(env.AUTH.fetch).not.toHaveBeenCalled(); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy.mock.calls[0][0]).toBe(origin); expect(fetchSpy.mock.calls[0][1]).toEqual({ headers: { Accept: "image/avif, image/webp, image/*" }, cf: { image: { width: 320, height: 200, quality: 70, fit: "cover", format: "webp", background: "white", }, }, }); }); it("returns 400 for invalid /image requests before fetching upstream", async () => { const stores = sharedStores(); const env = makeEnv(stores, null); const fetchSpy = vi.spyOn(globalThis, "fetch"); const missingUrl = await call("GET", "/image", env); expect(missingUrl.status).toBe(400); expect(await missingUrl.json()).toEqual({ error: "Missing required parameter: url" }); const unsupported = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/vector.svg")}`, env); expect(unsupported.status).toBe(400); expect(await unsupported.json()).toEqual({ error: "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported", }); const invalidSize = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/photo.png")}&width=0`, env); expect(invalidSize.status).toBe(400); expect(await invalidSize.json()).toEqual({ error: "Invalid width. Must be a positive integer" }); expect(fetchSpy).not.toHaveBeenCalled(); expect(env.AUTH.fetch).not.toHaveBeenCalled(); }); it("creates an attachment for the current user and hides it from another user", async () => { const stores = sharedStores(); const userA = makeEnv(stores, "user_a"); const userB = makeEnv(stores, "user_b"); const created = await createAttachment(userA, { filename: "invoice.pdf", content_type: "application/pdf", }); expect(created.attachment.status).toBe("upload_pending"); expect(created.attachment.user_id).toBe("user_a"); const asOwner = await call("GET", `/api/attachments/${created.attachment.id}`, userA); expect(asOwner.status).toBe(200); const asOther = await call("GET", `/api/attachments/${created.attachment.id}`, userB); expect(asOther.status).toBe(404); expect(asOther.headers.get("content-type")).toBe("application/problem+json"); }); it("uploads content, finalizes the attachment, and downloads the same bytes", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const payload = "hello attachment bytes"; const created = await createAttachment(env, { filename: "note.txt", content_type: "text/plain", }); const uploaded = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: payload, headers: { "content-length": String(new TextEncoder().encode(payload).byteLength) }, }); expect(uploaded.status).toBe(200); const uploadedBody = (await uploaded.json()) as { attachment: { status: string; byte_size: number } }; expect(uploadedBody.attachment.status).toBe("uploaded"); expect(uploadedBody.attachment.byte_size).toBe(new TextEncoder().encode(payload).byteLength); const finalized = await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env); expect(finalized.status).toBe(200); expect(((await finalized.json()) as { attachment: { status: string } }).attachment.status).toBe("available"); const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env); expect(download.status).toBe(200); expect(download.headers.get("content-type")).toBe("text/plain"); expect(await download.text()).toBe(payload); }); it("does not download before finalize", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const payload = "not yet ready"; const created = await createAttachment(env, { filename: "draft.txt", content_type: "text/plain" }); await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: payload }); const download = await call("GET", `/api/attachments/${created.attachment.id}/download`, env); expect(download.status).toBe(409); const problem = (await download.json()) as { code: string }; expect(problem.code).toBe("object-not-available"); }); it("soft deletes an attachment and removes it from detail reads", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const created = await createAttachment(env, { filename: "temp.txt", content_type: "text/plain" }); const deleted = await call("DELETE", `/api/attachments/${created.attachment.id}`, env); expect(deleted.status).toBe(204); const detail = await call("GET", `/api/attachments/${created.attachment.id}`, env); expect(detail.status).toBe(404); }); it("rejects uploads that exceed the configured size limit", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a", "3"); const created = await createAttachment(env, { filename: "big.bin", content_type: "application/octet-stream" }); const tooLarge = await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: "more than three bytes", headers: { "content-length": "21" }, }); expect(tooLarge.status).toBe(413); }); it("returns 401 when there is no session", async () => { const stores = sharedStores(); const env = makeEnv(stores, null); const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ filename: "x.txt", content_type: "text/plain" }), }); expect(response.status).toBe(401); }); it("returns 422 problem+json when required fields are missing", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ content_type: "text/plain" }) }); expect(response.status).toBe(422); expect(response.headers.get("content-type")).toBe("application/problem+json"); const problem = (await response.json()) as { code: string; detail: { issues: Array<{ path: string }> } }; expect(problem.code).toBe("validation-failed"); expect(problem.detail.issues.some((issue) => issue.path === "filename")).toBe(true); }); it("serves an OpenAPI document at /openapi.json without a session", async () => { const stores = sharedStores(); const env = makeEnv(stores, null); const response = await call("GET", "/openapi.json", env); expect(response.status).toBe(200); const document = (await response.json()) as { openapi: string; tags?: Array<{ name: string; description?: string }>; paths: Record< string, Record< string, { tags?: string[]; summary?: string; description?: string; parameters?: Array<{ name: string; in: string; required?: boolean; schema?: { type?: string; enum?: string[] }; }>; } > >; components?: { schemas?: Record }; }; expect(document.openapi).toBe("3.1.0"); expect(document.tags).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "Attachments", description: expect.any(String) }), expect.objectContaining({ name: "Categories", description: expect.any(String) }), expect.objectContaining({ name: "Tags", description: expect.any(String) }), expect.objectContaining({ name: "Public files", description: expect.any(String) }), ]), ); expect(document.paths["/api/attachments"]).toBeTruthy(); expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy(); expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy(); expect(document.paths["/files/{id}"]).toBeTruthy(); expect(document.paths["/image"]).toBeTruthy(); expect(document.paths["/api/attachments"].post).toMatchObject({ tags: ["Attachments"], summary: expect.any(String), description: expect.any(String), }); expect(document.paths["/api/attachment-categories"].get).toMatchObject({ tags: ["Categories"], summary: expect.any(String), description: expect.any(String), }); expect(document.paths["/api/attachment-tags"].get).toMatchObject({ tags: ["Tags"], summary: expect.any(String), description: expect.any(String), }); expect(document.paths["/files/{id}"].get).toMatchObject({ tags: ["Public files"], summary: expect.any(String), description: expect.any(String), }); expect(document.paths["/image"].get).toMatchObject({ tags: ["Images"], summary: expect.any(String), description: expect.any(String), }); expect(document.paths["/image"].get.parameters).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "url", in: "query", required: true, schema: expect.objectContaining({ type: "string" }) }), expect.objectContaining({ name: "width", in: "query", schema: expect.objectContaining({ type: "integer" }) }), expect.objectContaining({ name: "height", in: "query", schema: expect.objectContaining({ type: "integer" }) }), expect.objectContaining({ name: "quality", in: "query", schema: expect.objectContaining({ type: "integer" }) }), expect.objectContaining({ name: "format", in: "query", schema: expect.objectContaining({ enum: expect.arrayContaining(["avif", "webp", "jpeg", "jpg", "png", "gif", "auto"]) }), }), expect.objectContaining({ name: "fit", in: "query", schema: expect.objectContaining({ enum: expect.arrayContaining(["scale-down", "scale-up", "contain", "cover", "crop", "pad", "squeeze"]) }), }), expect.objectContaining({ name: "bg", in: "query", schema: expect.objectContaining({ type: "string" }) }), ]), ); expect(document.components?.schemas?.Attachment).toBeTruthy(); }); }); async function makeAttachment( env: unknown, filename: string, contentType = "text/plain", ): Promise { const created = await createAttachment(env, { filename, content_type: contentType }); return created.attachment.id; } async function jsonBody(response: Response): Promise { expect(response.status).toBeLessThan(300); return response.json(); } describe("cfw-attachment worker (search, categories, tags)", () => { it("searches current-user attachments with page and pageSize", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); await makeAttachment(env, "one.txt"); await makeAttachment(env, "two.txt"); await makeAttachment(env, "three.txt"); const first = await jsonBody(await call("GET", "/api/attachments/search?page=1&pageSize=2", env)); expect(first.page).toBe(1); expect(first.pageSize).toBe(2); expect(first.total).toBe(3); expect(first.items).toHaveLength(2); const second = await jsonBody(await call("GET", "/api/attachments/search?page=2&pageSize=2", env)); expect(second.total).toBe(3); expect(second.items).toHaveLength(1); }); it("filters search by keyword, category id, and tag slug", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const category = await jsonBody(await call("POST", "/api/attachment-categories", env, { body: JSON.stringify({ name: "Invoices" }) })).then((b) => b.category); const tag = await jsonBody(await call("POST", "/api/attachment-tags", env, { body: JSON.stringify({ name: "Urgent" }) })).then((b) => b.tag); const tagged = await makeAttachment(env, "invoice-q1.pdf", "application/pdf"); await call("PATCH", `/api/attachments/${tagged}`, env, { body: JSON.stringify({ categoryId: category.id, tagIds: [tag.id], description: "first quarter" }), }); await makeAttachment(env, "photo.png", "image/png"); const byKeyword = await jsonBody(await call("GET", "/api/attachments/search?q=invoice", env)); expect(byKeyword.total).toBe(1); expect(byKeyword.items[0].id).toBe(tagged); const byCategory = await jsonBody(await call("GET", `/api/attachments/search?categoryId=${category.id}`, env)); expect(byCategory.total).toBe(1); expect(byCategory.items[0].id).toBe(tagged); const byTag = await jsonBody(await call("GET", `/api/attachments/search?tag=${tag.slug}`, env)); expect(byTag.total).toBe(1); expect(byTag.items[0].id).toBe(tagged); }); it("does not return another user's attachments in search", async () => { const stores = sharedStores(); const userA = makeEnv(stores, "user_a"); const userB = makeEnv(stores, "user_b"); await makeAttachment(userA, "secret.txt"); const result = await jsonBody(await call("GET", "/api/attachments/search", userB)); expect(result.total).toBe(0); expect(result.items).toEqual([]); }); it("creates, updates, lists, and deletes user-scoped categories", async () => { const stores = sharedStores(); const userA = makeEnv(stores, "user_a"); const userB = makeEnv(stores, "user_b"); const created = await call("POST", "/api/attachment-categories", userA, { body: JSON.stringify({ name: "Receipts" }) }); expect(created.status).toBe(201); const category = (await created.json()) as { category: { id: string; name: string; slug: string } }; expect(category.category.slug).toBe("receipts"); const updated = await jsonBody(await call("PATCH", `/api/attachment-categories/${category.category.id}`, userA, { body: JSON.stringify({ name: "Invoices" }) })); expect(updated.category.name).toBe("Invoices"); const listedByOwner = await jsonBody(await call("GET", "/api/attachment-categories", userA)); expect(listedByOwner.categories).toHaveLength(1); expect(listedByOwner.categories[0].id).toBe(category.category.id); const listedByOther = await jsonBody(await call("GET", "/api/attachment-categories", userB)); expect(listedByOther.categories).toEqual([]); const deletedByOther = await call("DELETE", `/api/attachment-categories/${category.category.id}`, userB); expect(deletedByOther.status).toBe(404); const deleted = await call("DELETE", `/api/attachment-categories/${category.category.id}`, userA); expect(deleted.status).toBe(204); const afterDelete = await jsonBody(await call("GET", "/api/attachment-categories", userA)); expect(afterDelete.categories).toEqual([]); }); it("creates, updates, lists, and deletes user-scoped tags", async () => { const stores = sharedStores(); const userA = makeEnv(stores, "user_a"); const userB = makeEnv(stores, "user_b"); const created = await call("POST", "/api/attachment-tags", userA, { body: JSON.stringify({ name: "Important" }) }); expect(created.status).toBe(201); const tag = (await created.json()) as { tag: { id: string; name: string; slug: string } }; expect(tag.tag.slug).toBe("important"); const updated = await jsonBody(await call("PATCH", `/api/attachment-tags/${tag.tag.id}`, userA, { body: JSON.stringify({ name: "Critical" }) })); expect(updated.tag.name).toBe("Critical"); const listedByOwner = await jsonBody(await call("GET", "/api/attachment-tags", userA)); expect(listedByOwner.tags).toHaveLength(1); const listedByOther = await jsonBody(await call("GET", "/api/attachment-tags", userB)); expect(listedByOther.tags).toEqual([]); const deletedByOther = await call("DELETE", `/api/attachment-tags/${tag.tag.id}`, userB); expect(deletedByOther.status).toBe(404); const deleted = await call("DELETE", `/api/attachment-tags/${tag.tag.id}`, userA); expect(deleted.status).toBe(204); const afterDelete = await jsonBody(await call("GET", "/api/attachment-tags", userA)); expect(afterDelete.tags).toEqual([]); }); it("rejects duplicate category slugs for the same user", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const first = await call("POST", "/api/attachment-categories", env, { body: JSON.stringify({ name: "Shared" }) }); expect(first.status).toBe(201); const duplicate = await call("POST", "/api/attachment-categories", env, { body: JSON.stringify({ name: "Shared" }) }); expect(duplicate.status).toBe(409); }); }); async function initMultipart(env: unknown, id: string): Promise<{ upload_id: string }> { const response = await call("POST", `/api/attachments/${id}/multipart`, env); expect(response.status).toBe(200); return (await response.json()) as { upload_id: string }; } async function uploadPartOk( env: unknown, id: string, partNumber: number, body: BodyInit, ): Promise<{ part_number: number; etag: string; byte_size: number }> { const response = await call("PUT", `/api/attachments/${id}/multipart/parts/${partNumber}`, env, { body }); expect(response.status).toBe(200); return (await response.json()) as { part_number: number; etag: string; byte_size: number }; } async function finalizeOk(env: unknown, id: string): Promise<{ attachment: { status: string; byte_size: number; etag: string | null; public_url: string | null } }> { const response = await call("POST", `/api/attachments/${id}/finalize`, env); expect(response.status).toBe(200); return response.json(); } describe("cfw-attachment worker (multipart & public files)", () => { it("uploads parts, finalizes, and the assembled object matches the concatenated bytes", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const created = await createAttachment(env, { filename: "big.txt", content_type: "text/plain" }); const id = created.attachment.id; const minPart = 5 * 1024 * 1024; const bigPart = "a".repeat(minPart); // 非末片必须 >= 5MiB(R2 约束) const tail = "-END"; const init = await initMultipart(env, id); expect(init.upload_id).toBeTruthy(); await uploadPartOk(env, id, 1, bigPart); await uploadPartOk(env, id, 2, tail); const finalized = await finalizeOk(env, id); expect(finalized.attachment.status).toBe("available"); expect(finalized.attachment.byte_size).toBe(minPart + tail.length); expect(finalized.attachment.etag).toBeTruthy(); // 默认 private → 不返回 public_url expect(finalized.attachment.public_url).toBeNull(); const download = await call("GET", `/api/attachments/${id}/download`, env); expect(download.headers.get("content-type")).toBe("text/plain"); const text = await download.text(); expect(text.length).toBe(minPart + tail.length); expect(text.endsWith(tail)).toBe(true); expect(text.slice(0, minPart)).toBe(bigPart); }); it("re-uploading the same part number keeps the latest bytes", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; await initMultipart(env, id); await uploadPartOk(env, id, 1, "first"); await uploadPartOk(env, id, 1, "second"); await finalizeOk(env, id); const download = await call("GET", `/api/attachments/${id}/download`, env); expect(await download.text()).toBe("second"); }); it("rejects finalize when a non-final part is below the minimum part size", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; await initMultipart(env, id); await uploadPartOk(env, id, 1, "tiny"); // 非末片且 < 5MiB await uploadPartOk(env, id, 2, "x"); const finalize = await call("POST", `/api/attachments/${id}/finalize`, env); expect(finalize.status).toBe(422); const problem = (await finalize.json()) as { code: string; detail: { part_number: number } }; expect(problem.code).toBe("multipart-part-too-small"); expect(problem.detail.part_number).toBe(1); }); it("aborts a multipart upload and lets a single-shot upload take over", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; await initMultipart(env, id); await uploadPartOk(env, id, 1, "part-data"); const abort = await call("DELETE", `/api/attachments/${id}/multipart`, env); expect(abort.status).toBe(204); // abort 后再传片 → 409(无活动会话) const partAfter = await call("PUT", `/api/attachments/${id}/multipart/parts/1`, env, { body: "x" }); expect(partAfter.status).toBe(409); // 改走单次上传 → finalize 成功 await call("PUT", `/api/attachments/${id}/content`, env, { body: "single-shot" }); const finalized = await finalizeOk(env, id); expect(finalized.attachment.status).toBe("available"); const download = await call("GET", `/api/attachments/${id}/download`, env); expect(await download.text()).toBe("single-shot"); }); it("GET /files/:id serves public/shared files with the right content-type and 404s otherwise", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const id = (await createAttachment(env, { filename: "img.png", content_type: "image/png", visibility: "public" })).attachment.id; await call("PUT", `/api/attachments/${id}/content`, env, { body: "PNGDATA" }); await finalizeOk(env, id); const publicFile = await call("GET", `/files/${id}`, env); expect(publicFile.status).toBe(200); expect(publicFile.headers.get("content-type")).toBe("image/png"); expect(publicFile.headers.get("content-disposition")).toContain("inline"); expect(await publicFile.text()).toBe("PNGDATA"); // 转 private → 404 await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "private" }) }); expect((await call("GET", `/files/${id}`, env)).status).toBe(404); // 转回 shared → 200 await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "shared" }) }); expect((await call("GET", `/files/${id}`, env)).status).toBe(200); // 未 finalize → 404 const pending = (await createAttachment(env, { filename: "u.txt", content_type: "text/plain", visibility: "public" })).attachment.id; expect((await call("GET", `/files/${pending}`, env)).status).toBe(404); }); it("exposes public_url for available public/shared attachments and omits it for private", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const expected = (id: string) => `https://attachment.local/files/${id}`; const id = (await createAttachment(env, { filename: "doc.pdf", content_type: "application/pdf", visibility: "public" })).attachment.id; await call("PUT", `/api/attachments/${id}/content`, env, { body: "PDF" }); const finalized = await finalizeOk(env, id); expect(finalized.attachment.public_url).toBe(expected(id)); const privateView = await jsonBody(await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "private" }) })); expect(privateView.attachment.public_url).toBeNull(); const sharedView = await jsonBody(await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ visibility: "shared" }) })); expect(sharedView.attachment.public_url).toBe(expected(id)); const search = await jsonBody(await call("GET", "/api/attachments/search", env)); expect(search.items[0].public_url).toBe(expected(id)); }); it("init refuses a second active multipart session", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const id = (await createAttachment(env, { filename: "p.txt", content_type: "text/plain" })).attachment.id; await initMultipart(env, id); const second = await call("POST", `/api/attachments/${id}/multipart`, env); expect(second.status).toBe(409); expect(((await second.json()) as { code: string }).code).toBe("multipart-active"); }); }); // 1×1 PNG(合法),用于验证 finalize 时服务端自动解析图片尺寸 const PNG_1x1_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; function decodeBase64ToBytes(b64: string): Uint8Array { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return bytes; } describe("cfw-attachment worker (metadata)", () => { it("stores client-supplied video metadata and returns metadata_kind", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const created = await createAttachment(env, { filename: "clip.mp4", content_type: "video/mp4", metadata: { kind: "video", width: 1920, height: 1080, duration_seconds: 12.3, codec: "h264" }, }); const detail = await jsonBody(await call("GET", `/api/attachments/${created.attachment.id}`, env)); expect(detail.attachment.metadata).toEqual({ kind: "video", width: 1920, height: 1080, duration_seconds: 12.3, codec: "h264", }); expect(detail.attachment.metadata_kind).toBe("video"); }); it("auto-extracts image dimensions on finalize", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const png = decodeBase64ToBytes(PNG_1x1_BASE64); const created = await createAttachment(env, { filename: "dot.png", content_type: "image/png" }); await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: png as BodyInit }); const finalized = await jsonBody(await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env)); expect(finalized.attachment.status).toBe("available"); expect(finalized.attachment.metadata).toEqual({ kind: "image", width: 1, height: 1, format: "png" }); expect(finalized.attachment.metadata_kind).toBe("image"); }); it("does not overwrite client-supplied image metadata on finalize", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const png = decodeBase64ToBytes(PNG_1x1_BASE64); const created = await createAttachment(env, { filename: "dot.png", content_type: "image/png", metadata: { kind: "image", width: 9, height: 9 }, }); await call("PUT", `/api/attachments/${created.attachment.id}/content`, env, { body: png as BodyInit }); const finalized = await jsonBody(await call("POST", `/api/attachments/${created.attachment.id}/finalize`, env)); expect(finalized.attachment.metadata).toEqual({ kind: "image", width: 9, height: 9 }); }); it("updates and clears metadata via PATCH", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const id = (await createAttachment(env, { filename: "doc.pdf", content_type: "application/pdf" })).attachment.id; const updated = await jsonBody( await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ metadata: { kind: "document", pages: 5 } }), }), ); expect(updated.attachment.metadata).toEqual({ kind: "document", pages: 5 }); expect(updated.attachment.metadata_kind).toBe("document"); const cleared = await jsonBody( await call("PATCH", `/api/attachments/${id}`, env, { body: JSON.stringify({ metadata: null }) }), ); expect(cleared.attachment.metadata).toBeNull(); expect(cleared.attachment.metadata_kind).toBeNull(); }); it("rejects invalid metadata with 422", async () => { const stores = sharedStores(); const env = makeEnv(stores, "user_a"); const response = await call("POST", "/api/attachments", env, { body: JSON.stringify({ filename: "x.png", content_type: "image/png", metadata: { kind: "image", width: -1, height: 1 }, }), }); expect(response.status).toBe(422); expect(((await response.json()) as { code: string }).code).toBe("validation-failed"); }); });