Files
cfw-attachment/tests/attachment-worker.test.ts
Claude 0af18ada66 feat: per-type attachment metadata (image dims, video/audio/doc fields)
- AttachmentMetadata discriminated union (image/video/audio/document/file) in
  zod/openapi; attachments table gains metadata + metadata_kind (0003)
- create/PATCH accept client-supplied metadata; finalize auto-extracts image
  width/height/format from R2 head bytes via image-size (no client override)
- serializeAttachment surfaces parsed metadata in all responses

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 20:03:51 -07:00

711 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> } = {},
): Promise<Response> {
const headers: Record<string, string> = { ...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<string, unknown>,
): 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<string, unknown> };
};
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<string> {
const created = await createAttachment(env, { filename, content_type: contentType });
return created.attachment.id;
}
async function jsonBody(response: Response): Promise<any> {
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");
});
});