Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import {
contentCreateBody,
contentUpdateBody,
httpUrl,
mediaUploadUrlBody,
DEFAULT_MAX_UPLOAD_SIZE,
} from "../../../src/api/schemas/index.js";
describe("contentCreateBody schema", () => {
it("accepts status 'draft'", () => {
const result = contentCreateBody.parse({ data: { title: "Hi" }, status: "draft" });
expect(result.status).toBe("draft");
});
it("accepts omitted status", () => {
const result = contentCreateBody.parse({ data: { title: "Hi" } });
expect(result.status).toBeUndefined();
});
it("rejects status 'published'", () => {
expect(() => contentCreateBody.parse({ data: { title: "Hi" }, status: "published" })).toThrow();
});
it("rejects status 'scheduled'", () => {
expect(() => contentCreateBody.parse({ data: { title: "Hi" }, status: "scheduled" })).toThrow();
});
it("preserves publishedAt and createdAt when valid ISO 8601 datetimes are provided", () => {
const result = contentCreateBody.parse({
data: { title: "Hi" },
publishedAt: "2019-03-15T10:30:00.000Z",
createdAt: "2019-03-15T10:30:00.000Z",
});
expect(result.publishedAt).toBe("2019-03-15T10:30:00.000Z");
expect(result.createdAt).toBe("2019-03-15T10:30:00.000Z");
});
it("accepts offset-suffixed ISO datetimes", () => {
const result = contentCreateBody.parse({
data: { title: "Hi" },
publishedAt: "2019-03-15T10:30:00+00:00",
});
expect(result.publishedAt).toBe("2019-03-15T10:30:00+00:00");
});
it("rejects malformed datetime strings", () => {
expect(() =>
contentCreateBody.parse({ data: { title: "Hi" }, publishedAt: "yesterday" }),
).toThrow();
expect(() =>
contentCreateBody.parse({ data: { title: "Hi" }, createdAt: "2019-03-15" }),
).toThrow();
});
it("accepts null to explicitly clear the field", () => {
const result = contentCreateBody.parse({ data: { title: "Hi" }, publishedAt: null });
expect(result.publishedAt).toBeNull();
});
});
describe("contentUpdateBody schema", () => {
it("should pass through skipRevision when present", () => {
const input = {
data: { title: "Hello" },
skipRevision: true,
};
const result = contentUpdateBody.parse(input);
expect(result.skipRevision).toBe(true);
});
it("should accept updates without skipRevision", () => {
const input = {
data: { title: "Hello" },
};
const result = contentUpdateBody.parse(input);
expect(result.skipRevision).toBeUndefined();
});
it("accepts status 'draft'", () => {
const result = contentUpdateBody.parse({ data: { title: "Hi" }, status: "draft" });
expect(result.status).toBe("draft");
});
it("accepts omitted status", () => {
const result = contentUpdateBody.parse({ data: { title: "Hi" } });
expect(result.status).toBeUndefined();
});
it("rejects status 'published'", () => {
expect(() => contentUpdateBody.parse({ data: { title: "Hi" }, status: "published" })).toThrow();
});
it("rejects status 'scheduled'", () => {
expect(() => contentUpdateBody.parse({ data: { title: "Hi" }, status: "scheduled" })).toThrow();
});
it("preserves publishedAt when a valid ISO 8601 datetime is provided", () => {
const result = contentUpdateBody.parse({
data: { title: "Hi" },
publishedAt: "2019-03-15T10:30:00.000Z",
});
expect(result.publishedAt).toBe("2019-03-15T10:30:00.000Z");
});
it("rejects malformed publishedAt strings", () => {
expect(() =>
contentUpdateBody.parse({ data: { title: "Hi" }, publishedAt: "yesterday" }),
).toThrow();
});
it("strips createdAt — treat created_at as immutable on update", () => {
const result = contentUpdateBody.parse({
data: { title: "Hi" },
createdAt: "2019-03-15T10:30:00.000Z",
} as Parameters<typeof contentUpdateBody.parse>[0]);
expect("createdAt" in result).toBe(false);
});
});
describe("httpUrl validator", () => {
it("accepts http URLs", () => {
expect(httpUrl.parse("http://example.com")).toBe("http://example.com");
});
it("accepts https URLs", () => {
expect(httpUrl.parse("https://example.com/path?q=1")).toBe("https://example.com/path?q=1");
});
it("rejects javascript: URIs", () => {
expect(() => httpUrl.parse("javascript:alert(1)")).toThrow();
});
it("rejects data: URIs", () => {
expect(() => httpUrl.parse("data:text/html,<script>alert(1)</script>")).toThrow();
});
it("rejects ftp: URIs", () => {
expect(() => httpUrl.parse("ftp://example.com")).toThrow();
});
it("rejects empty string", () => {
expect(() => httpUrl.parse("")).toThrow();
});
it("rejects non-URL strings", () => {
expect(() => httpUrl.parse("not a url")).toThrow();
});
it("is case-insensitive for scheme", () => {
expect(httpUrl.parse("HTTPS://EXAMPLE.COM")).toBe("HTTPS://EXAMPLE.COM");
});
});
describe("mediaUploadUrlBody schema factory", () => {
it("DEFAULT_MAX_UPLOAD_SIZE is 50 MB", () => {
expect(DEFAULT_MAX_UPLOAD_SIZE).toBe(50 * 1024 * 1024);
});
it("rejects size above the configured limit", () => {
const schema = mediaUploadUrlBody(1_000);
expect(() =>
schema.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 1_001 }),
).toThrow();
});
it("accepts size equal to the configured limit", () => {
const schema = mediaUploadUrlBody(1_000);
const result = schema.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 1_000 });
expect(result.size).toBe(1_000);
});
it("accepts size below the configured limit", () => {
const schema = mediaUploadUrlBody(1_000);
const result = schema.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 500 });
expect(result.size).toBe(500);
});
it("each call returns an independent schema with its own limit", () => {
const strict = mediaUploadUrlBody(100);
const loose = mediaUploadUrlBody(1_000_000);
expect(() =>
strict.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 500 }),
).toThrow();
expect(() =>
loose.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 500 }),
).not.toThrow();
});
it("throws when maxSize is NaN", () => {
expect(() => mediaUploadUrlBody(NaN)).toThrow(/maxUploadSize/);
});
it("throws when maxSize is 0", () => {
expect(() => mediaUploadUrlBody(0)).toThrow(/maxUploadSize/);
});
it("throws when maxSize is negative", () => {
expect(() => mediaUploadUrlBody(-1024)).toThrow(/maxUploadSize/);
});
it("error message uses whole MB, not fractional", () => {
const schema = mediaUploadUrlBody(75_000_000);
let errorMessage = "";
try {
schema.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 75_000_001 });
} catch (e) {
errorMessage = String(e);
}
expect(errorMessage).not.toBe("");
expect(errorMessage).not.toMatch(/\d+\.\d+MB/);
});
it("error message does not overstate the limit in MB", () => {
// 75_000_000 bytes / 1024 / 1024 ≈ 71.5 MB; floor gives 71, round gives 72
const schema = mediaUploadUrlBody(75_000_000);
let errorMessage = "";
try {
schema.parse({ filename: "a.jpg", contentType: "image/jpeg", size: 75_000_001 });
} catch (e) {
errorMessage = String(e);
}
expect(errorMessage).toContain("71MB");
});
});