611 lines
26 KiB
TypeScript
611 lines
26 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");
|
|
});
|
|
});
|