- 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>
711 lines
30 KiB
TypeScript
711 lines
30 KiB
TypeScript
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");
|
||
});
|
||
});
|