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,710 @@
import { describe, it, expect } from "vitest";
import { EmDashClient, EmDashApiError } from "../../../src/client/index.js";
import type { Interceptor } from "../../../src/client/transport.js";
// Regex patterns for route matching
const CONTENT_POSTS_ABC_REGEX = /\/content\/posts\/abc/;
// ---------------------------------------------------------------------------
// Mock backend
// ---------------------------------------------------------------------------
interface MockRoute {
method: string;
path: RegExp | string;
handler: (req: Request) => Response | Promise<Response>;
}
/**
* Creates a mock HTTP backend as an interceptor.
* Routes are matched in order. Unmatched requests return 404.
*/
function createMockBackend(routes: MockRoute[]): Interceptor {
return async (req) => {
const url = new URL(req.url);
const path = url.pathname + url.search;
for (const route of routes) {
if (req.method !== route.method) continue;
if (typeof route.path === "string") {
if (!path.includes(route.path)) continue;
} else {
if (!route.path.test(path)) continue;
}
return route.handler(req);
}
return new Response(
JSON.stringify({ error: { code: "NOT_FOUND", message: "No matching route" } }),
{ status: 404, headers: { "Content-Type": "application/json" } },
);
};
}
/** Wraps body in `{ data: body }` to match the standard API response envelope. */
function jsonResponse(body: unknown, status: number = 200): Response {
// Error responses (4xx/5xx) are NOT wrapped in { data }
const payload = status >= 400 ? body : { data: body };
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("EmDashClient", () => {
describe("_rev token flow", () => {
it("blind update (no _rev) succeeds", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
label: "Posts",
fields: [{ slug: "title", type: "string", label: "Title" }],
},
}),
},
{
method: "PUT",
path: CONTENT_POSTS_ABC_REGEX,
handler: async (req) => {
const body = (await req.json()) as Record<string, unknown>;
// No _rev should be sent
expect(body._rev).toBeUndefined();
return jsonResponse({
item: { id: "abc", data: { title: "Blind" } },
_rev: "newrev",
});
},
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const updated = await client.update("posts", "abc", {
data: { title: "Blind" },
});
expect(updated.data.title).toBe("Blind");
});
it("get() returns _rev on the item", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
label: "Posts",
fields: [{ slug: "title", type: "string", label: "Title" }],
},
}),
},
{
method: "GET",
path: CONTENT_POSTS_ABC_REGEX,
handler: () =>
jsonResponse({
item: {
id: "abc",
type: "posts",
slug: "hello",
status: "draft",
data: { title: "Hello" },
authorId: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
},
_rev: "dGVzdHJldg",
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const post = await client.get("posts", "abc");
expect(post.id).toBe("abc");
expect(post._rev).toBe("dGVzdHJldg");
});
it("update() sends _rev when provided", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
label: "Posts",
fields: [{ slug: "title", type: "string", label: "Title" }],
},
}),
},
{
method: "PUT",
path: CONTENT_POSTS_ABC_REGEX,
handler: async (req) => {
const body = await req.json();
expect((body as Record<string, unknown>)._rev).toBe("dGVzdHJldg");
return jsonResponse({
item: { id: "abc", data: { title: "Updated" } },
_rev: "bmV3cmV2",
});
},
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const updated = await client.update("posts", "abc", {
data: { title: "Updated" },
_rev: "dGVzdHJldg",
});
expect(updated.data.title).toBe("Updated");
expect(updated._rev).toBe("bmV3cmV2");
});
});
describe("create()", () => {
it("does not require a prior get()", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
label: "Posts",
fields: [{ slug: "title", type: "string", label: "Title" }],
},
}),
},
{
method: "POST",
path: "/content/posts",
handler: () =>
jsonResponse({
item: {
id: "new1",
type: "posts",
slug: "hello",
status: "draft",
data: { title: "Hello" },
authorId: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
},
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const item = await client.create("posts", {
data: { title: "Hello" },
slug: "hello",
});
expect(item.id).toBe("new1");
});
});
describe("API error handling", () => {
it("throws EmDashApiError on 4xx responses", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections",
handler: () => jsonResponse({ error: { code: "FORBIDDEN", message: "No access" } }, 403),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
try {
await client.collections();
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(EmDashApiError);
const apiErr = error as EmDashApiError;
expect(apiErr.status).toBe(403);
expect(apiErr.code).toBe("FORBIDDEN");
expect(apiErr.message).toBe("No access");
}
});
it("throws EmDashApiError on 500 responses", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/manifest",
handler: () =>
jsonResponse(
{
error: {
code: "INTERNAL_ERROR",
message: "Something broke",
},
},
500,
),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
try {
await client.manifest();
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(EmDashApiError);
expect((error as EmDashApiError).status).toBe(500);
}
});
});
describe("list()", () => {
it("returns items and nextCursor", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/content/posts",
handler: () =>
jsonResponse({
items: [
{
id: "1",
type: "posts",
slug: "a",
status: "published",
data: {},
},
{
id: "2",
type: "posts",
slug: "b",
status: "published",
data: {},
},
],
nextCursor: "cursor123",
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const result = await client.list("posts", { status: "published" });
expect(result.items).toHaveLength(2);
expect(result.nextCursor).toBe("cursor123");
});
});
describe("listAll()", () => {
it("follows cursors until exhaustion", async () => {
let page = 0;
const backend = createMockBackend([
{
method: "GET",
path: "/content/posts",
handler: () => {
page++;
if (page === 1) {
return jsonResponse({
items: [{ id: "1", data: {} }],
nextCursor: "page2",
});
}
return jsonResponse({
items: [{ id: "2", data: {} }],
});
},
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const all = [];
for await (const item of client.listAll("posts")) {
all.push(item);
}
expect(all).toHaveLength(2);
expect(all[0]?.id).toBe("1");
expect(all[1]?.id).toBe("2");
});
});
describe("delete/publish/unpublish/schedule/restore", () => {
it("calls the correct endpoints", async () => {
const calledPaths: string[] = [];
const backend: Interceptor = async (req) => {
calledPaths.push(`${req.method} ${new URL(req.url).pathname}`);
return jsonResponse({});
};
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
await client.delete("posts", "abc");
await client.publish("posts", "abc");
await client.unpublish("posts", "abc");
await client.schedule("posts", "abc", { at: "2026-03-01T00:00:00Z" });
await client.restore("posts", "abc");
expect(calledPaths).toEqual([
"DELETE /_emdash/api/content/posts/abc",
"POST /_emdash/api/content/posts/abc/publish",
"POST /_emdash/api/content/posts/abc/unpublish",
"POST /_emdash/api/content/posts/abc/schedule",
"POST /_emdash/api/content/posts/abc/restore",
]);
});
});
describe("schema methods", () => {
it("collections() returns list", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections",
handler: () =>
jsonResponse({
items: [
{ slug: "posts", label: "Posts", supports: [] },
{ slug: "pages", label: "Pages", supports: [] },
],
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const cols = await client.collections();
expect(cols).toHaveLength(2);
expect(cols[0]?.slug).toBe("posts");
});
it("createCollection() sends correct payload", async () => {
let capturedBody: unknown;
const backend = createMockBackend([
{
method: "POST",
path: "/schema/collections",
handler: async (req) => {
capturedBody = await req.json();
return jsonResponse({
item: {
slug: "events",
label: "Events",
labelSingular: "Event",
},
});
},
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
await client.createCollection({
slug: "events",
label: "Events",
labelSingular: "Event",
});
expect(capturedBody).toEqual({
slug: "events",
label: "Events",
labelSingular: "Event",
});
});
});
describe("PT <-> Markdown auto-conversion", () => {
it("converts PT fields to markdown on get()", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
label: "Posts",
fields: [
{ slug: "title", type: "string", label: "Title" },
{ slug: "body", type: "portableText", label: "Body" },
],
},
}),
},
{
method: "GET",
path: CONTENT_POSTS_ABC_REGEX,
handler: () =>
jsonResponse({
item: {
id: "abc",
type: "posts",
data: {
title: "Hello",
body: [
{
_type: "block",
style: "normal",
markDefs: [],
children: [
{
_type: "span",
text: "World",
marks: [],
},
],
},
],
},
},
_rev: "rev1",
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const item = await client.get("posts", "abc");
expect(item.data.title).toBe("Hello");
expect(typeof item.data.body).toBe("string");
expect(item.data.body).toContain("World");
});
it("returns raw PT when raw: true", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
fields: [{ slug: "body", type: "portableText", label: "Body" }],
},
}),
},
{
method: "GET",
path: CONTENT_POSTS_ABC_REGEX,
handler: () =>
jsonResponse({
item: {
id: "abc",
data: {
body: [
{
_type: "block",
children: [{ _type: "span", text: "Raw" }],
},
],
},
},
_rev: "rev1",
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const item = await client.get("posts", "abc", { raw: true });
expect(Array.isArray(item.data.body)).toBe(true);
});
it("converts markdown to PT on create()", async () => {
let capturedData: Record<string, unknown> | undefined;
const backend = createMockBackend([
{
method: "GET",
path: "/schema/collections/posts",
handler: () =>
jsonResponse({
item: {
slug: "posts",
fields: [
{ slug: "title", type: "string", label: "Title" },
{ slug: "body", type: "portableText", label: "Body" },
],
},
}),
},
{
method: "POST",
path: "/content/posts",
handler: async (req) => {
const body = (await req.json()) as Record<string, unknown>;
capturedData = body.data as Record<string, unknown>;
return jsonResponse({
item: {
id: "new1",
data: capturedData,
},
});
},
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
await client.create("posts", {
data: {
title: "Hello",
body: "Some **bold** text",
},
});
expect(capturedData).toBeDefined();
expect(capturedData!.title).toBe("Hello");
expect(Array.isArray(capturedData!.body)).toBe(true);
});
});
// -----------------------------------------------------------------------
// Taxonomy & menu envelope bugs
// -----------------------------------------------------------------------
describe("taxonomies()", () => {
it("returns taxonomy array from { taxonomies } envelope", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/taxonomies",
handler: () =>
jsonResponse({
taxonomies: [
{
id: "t1",
name: "categories",
label: "Categories",
hierarchical: true,
collections: ["posts"],
},
],
}),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const result = await client.taxonomies();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0]!.name).toBe("categories");
});
});
describe("menus()", () => {
it("returns menu array from bare-array envelope", async () => {
const backend = createMockBackend([
{
method: "GET",
path: "/menus",
handler: () =>
jsonResponse([
{
id: "m1",
name: "primary",
label: "Primary",
itemCount: 3,
},
]),
},
]);
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [backend],
});
const result = await client.menus();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0]!.name).toBe("primary");
});
});
});

View File

@@ -0,0 +1,546 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { PortableTextBlock, FieldSchema } from "../../../src/client/portable-text.js";
import {
portableTextToMarkdown,
markdownToPortableText,
resetKeyCounter,
convertDataForRead,
convertDataForWrite,
} from "../../../src/client/portable-text.js";
beforeEach(() => {
resetKeyCounter();
});
// ---------------------------------------------------------------------------
// PT -> Markdown
// ---------------------------------------------------------------------------
describe("portableTextToMarkdown", () => {
it("converts a simple paragraph", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
_key: "a",
style: "normal",
markDefs: [],
children: [{ _type: "span", _key: "s1", text: "Hello world", marks: [] }],
},
];
expect(portableTextToMarkdown(blocks)).toBe("Hello world\n");
});
it("converts headings h1-h6", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "h1",
markDefs: [],
children: [{ _type: "span", text: "Title", marks: [] }],
},
{
_type: "block",
style: "h3",
markDefs: [],
children: [{ _type: "span", text: "Subtitle", marks: [] }],
},
];
expect(portableTextToMarkdown(blocks)).toBe("# Title\n\n### Subtitle\n");
});
it("converts bold, italic, code, and strikethrough marks", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
markDefs: [],
children: [
{ _type: "span", text: "bold", marks: ["strong"] },
{ _type: "span", text: " and ", marks: [] },
{ _type: "span", text: "italic", marks: ["em"] },
{ _type: "span", text: " and ", marks: [] },
{ _type: "span", text: "code", marks: ["code"] },
{ _type: "span", text: " and ", marks: [] },
{ _type: "span", text: "struck", marks: ["strike-through"] },
],
},
];
expect(portableTextToMarkdown(blocks)).toBe(
"**bold** and _italic_ and `code` and ~~struck~~\n",
);
});
it("converts links via markDefs", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
markDefs: [{ _key: "link1", _type: "link", href: "https://example.com" }],
children: [
{ _type: "span", text: "Click ", marks: [] },
{ _type: "span", text: "here", marks: ["link1"] },
],
},
];
expect(portableTextToMarkdown(blocks)).toBe("Click [here](https://example.com)\n");
});
it("converts blockquotes", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "blockquote",
markDefs: [],
children: [{ _type: "span", text: "A quote", marks: [] }],
},
];
expect(portableTextToMarkdown(blocks)).toBe("> A quote\n");
});
it("converts unordered lists", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
listItem: "bullet",
level: 1,
markDefs: [],
children: [{ _type: "span", text: "First", marks: [] }],
},
{
_type: "block",
style: "normal",
listItem: "bullet",
level: 1,
markDefs: [],
children: [{ _type: "span", text: "Second", marks: [] }],
},
{
_type: "block",
style: "normal",
listItem: "bullet",
level: 2,
markDefs: [],
children: [{ _type: "span", text: "Nested", marks: [] }],
},
];
expect(portableTextToMarkdown(blocks)).toBe("- First\n- Second\n - Nested\n");
});
it("converts ordered lists", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
listItem: "number",
level: 1,
markDefs: [],
children: [{ _type: "span", text: "First", marks: [] }],
},
{
_type: "block",
style: "normal",
listItem: "number",
level: 1,
markDefs: [],
children: [{ _type: "span", text: "Second", marks: [] }],
},
];
expect(portableTextToMarkdown(blocks)).toBe("1. First\n1. Second\n");
});
it("converts code blocks", () => {
const blocks: PortableTextBlock[] = [
{ _type: "code", _key: "c1", language: "typescript", code: "const x = 1;\nconsole.log(x);" },
];
expect(portableTextToMarkdown(blocks)).toBe(
"```typescript\nconst x = 1;\nconsole.log(x);\n```\n",
);
});
it("converts images", () => {
const blocks: PortableTextBlock[] = [
{ _type: "image", _key: "i1", alt: "A cat", asset: { url: "/img/cat.jpg" } },
];
expect(portableTextToMarkdown(blocks)).toBe("![A cat](/img/cat.jpg)\n");
});
it("serializes unknown blocks as opaque fences", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
markDefs: [],
children: [{ _type: "span", text: "Before", marks: [] }],
},
{
_type: "pluginWidget",
_key: "pw1",
config: { layout: "grid", items: 3 },
},
{
_type: "block",
style: "normal",
markDefs: [],
children: [{ _type: "span", text: "After", marks: [] }],
},
];
const md = portableTextToMarkdown(blocks);
expect(md).toContain("Before");
expect(md).toContain("After");
expect(md).toContain("<!--ec:block ");
expect(md).toContain('"_type":"pluginWidget"');
expect(md).toContain('"layout":"grid"');
});
it("handles mixed content with paragraphs, headings, and lists", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
style: "h1",
markDefs: [],
children: [{ _type: "span", text: "Title", marks: [] }],
},
{
_type: "block",
style: "normal",
markDefs: [],
children: [{ _type: "span", text: "A paragraph.", marks: [] }],
},
{
_type: "block",
style: "normal",
listItem: "bullet",
level: 1,
markDefs: [],
children: [{ _type: "span", text: "Item", marks: [] }],
},
];
const md = portableTextToMarkdown(blocks);
expect(md).toContain("# Title");
expect(md).toContain("A paragraph.");
expect(md).toContain("- Item");
});
});
// ---------------------------------------------------------------------------
// Markdown -> PT
// ---------------------------------------------------------------------------
describe("markdownToPortableText", () => {
it("converts a simple paragraph", () => {
const blocks = markdownToPortableText("Hello world\n");
expect(blocks).toHaveLength(1);
expect(blocks[0]._type).toBe("block");
expect(blocks[0].style).toBe("normal");
expect(blocks[0].children).toHaveLength(1);
expect((blocks[0].children[0] as { text: string }).text).toBe("Hello world");
});
it("converts headings", () => {
const blocks = markdownToPortableText("# Title\n\n### Subtitle\n");
expect(blocks).toHaveLength(2);
expect(blocks[0].style).toBe("h1");
expect(blocks[1].style).toBe("h3");
});
it("converts bold and italic", () => {
const blocks = markdownToPortableText("Some **bold** and _italic_ text\n");
expect(blocks).toHaveLength(1);
const children = blocks[0].children;
expect(children.length).toBeGreaterThan(1);
const boldSpan = children.find((c) => (c.marks ?? []).includes("strong"));
expect(boldSpan).toBeDefined();
expect(boldSpan!.text).toBe("bold");
const italicSpan = children.find((c) => (c.marks ?? []).includes("em"));
expect(italicSpan).toBeDefined();
expect(italicSpan!.text).toBe("italic");
});
it("converts inline code", () => {
const blocks = markdownToPortableText("Use `foo()` here\n");
const children = blocks[0].children;
const codeSpan = children.find((c) => (c.marks ?? []).includes("code"));
expect(codeSpan).toBeDefined();
expect(codeSpan!.text).toBe("foo()");
});
it("converts links with markDefs", () => {
const blocks = markdownToPortableText("Click [here](https://example.com)\n");
expect(blocks).toHaveLength(1);
expect(blocks[0].markDefs).toHaveLength(1);
expect(blocks[0].markDefs[0]._type).toBe("link");
expect(blocks[0].markDefs[0].href).toBe("https://example.com");
const linkSpan = blocks[0].children.find((c) =>
(c.marks ?? []).includes(blocks[0].markDefs[0]._key),
);
expect(linkSpan).toBeDefined();
expect(linkSpan!.text).toBe("here");
});
it("converts blockquotes", () => {
const blocks = markdownToPortableText("> A quote\n");
expect(blocks).toHaveLength(1);
expect(blocks[0].style).toBe("blockquote");
});
it("converts unordered lists", () => {
const blocks = markdownToPortableText("- First\n- Second\n - Nested\n");
expect(blocks).toHaveLength(3);
expect(blocks[0].listItem).toBe("bullet");
expect(blocks[0].level).toBe(1);
expect(blocks[2].listItem).toBe("bullet");
expect(blocks[2].level).toBe(2);
});
it("converts ordered lists", () => {
const blocks = markdownToPortableText("1. First\n2. Second\n");
expect(blocks).toHaveLength(2);
expect(blocks[0].listItem).toBe("number");
expect(blocks[1].listItem).toBe("number");
});
it("converts code fences", () => {
const blocks = markdownToPortableText("```typescript\nconst x = 1;\n```\n");
expect(blocks).toHaveLength(1);
expect(blocks[0]._type).toBe("code");
expect(blocks[0].language).toBe("typescript");
expect(blocks[0].code).toBe("const x = 1;");
});
it("converts images", () => {
const blocks = markdownToPortableText("![A cat](/img/cat.jpg)\n");
expect(blocks).toHaveLength(1);
expect(blocks[0]._type).toBe("image");
expect(blocks[0].alt).toBe("A cat");
expect((blocks[0].asset as { url: string }).url).toBe("/img/cat.jpg");
});
it("deserializes opaque fences back to original blocks", () => {
const original = {
_type: "pluginWidget",
_key: "pw1",
config: { layout: "grid", items: 3 },
};
const md = `<!--ec:block ${JSON.stringify(original)} -->`;
const blocks = markdownToPortableText(md);
expect(blocks).toHaveLength(1);
expect(blocks[0]._type).toBe("pluginWidget");
expect(blocks[0]._key).toBe("pw1");
expect((blocks[0] as Record<string, unknown>).config).toEqual({
layout: "grid",
items: 3,
});
});
it("skips blank lines", () => {
const blocks = markdownToPortableText("Hello\n\n\n\nWorld\n");
expect(blocks).toHaveLength(2);
});
it("converts strikethrough", () => {
const blocks = markdownToPortableText("Some ~~deleted~~ text\n");
const children = blocks[0].children;
const strikeSpan = children.find((c) => (c.marks ?? []).includes("strike-through"));
expect(strikeSpan).toBeDefined();
expect(strikeSpan!.text).toBe("deleted");
});
});
// ---------------------------------------------------------------------------
// Round-trip
// ---------------------------------------------------------------------------
describe("PT <-> Markdown round-trip", () => {
it("preserves simple text through round-trip", () => {
const original: PortableTextBlock[] = [
{
_type: "block",
_key: "a",
style: "normal",
markDefs: [],
children: [{ _type: "span", _key: "s", text: "Hello world", marks: [] }],
},
];
const md = portableTextToMarkdown(original);
const roundTripped = markdownToPortableText(md);
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].style).toBe("normal");
expect((roundTripped[0].children[0] as { text: string }).text).toBe("Hello world");
});
it("preserves headings through round-trip", () => {
const original: PortableTextBlock[] = [
{
_type: "block",
style: "h2",
markDefs: [],
children: [{ _type: "span", text: "My Heading", marks: [] }],
},
];
const md = portableTextToMarkdown(original);
const roundTripped = markdownToPortableText(md);
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0].style).toBe("h2");
expect((roundTripped[0].children[0] as { text: string }).text).toBe("My Heading");
});
it("preserves opaque fences through round-trip", () => {
const custom = {
_type: "callout",
_key: "c1",
style: "warning",
text: "Be careful!",
};
const original: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
markDefs: [],
children: [{ _type: "span", text: "Before", marks: [] }],
},
custom,
{
_type: "block",
style: "normal",
markDefs: [],
children: [{ _type: "span", text: "After", marks: [] }],
},
];
const md = portableTextToMarkdown(original);
const roundTripped = markdownToPortableText(md);
expect(roundTripped).toHaveLength(3);
expect(roundTripped[1]._type).toBe("callout");
expect(roundTripped[1]._key).toBe("c1");
expect((roundTripped[1] as Record<string, unknown>).style).toBe("warning");
expect((roundTripped[1] as Record<string, unknown>).text).toBe("Be careful!");
});
it("preserves code blocks through round-trip", () => {
const original: PortableTextBlock[] = [
{
_type: "code",
_key: "c1",
language: "javascript",
code: "const x = 42;",
},
];
const md = portableTextToMarkdown(original);
const roundTripped = markdownToPortableText(md);
expect(roundTripped).toHaveLength(1);
expect(roundTripped[0]._type).toBe("code");
expect(roundTripped[0].language).toBe("javascript");
expect(roundTripped[0].code).toBe("const x = 42;");
});
it("preserves bold text through round-trip", () => {
const original: PortableTextBlock[] = [
{
_type: "block",
style: "normal",
markDefs: [],
children: [
{ _type: "span", text: "Some ", marks: [] },
{ _type: "span", text: "bold", marks: ["strong"] },
{ _type: "span", text: " text", marks: [] },
],
},
];
const md = portableTextToMarkdown(original);
expect(md).toContain("**bold**");
const roundTripped = markdownToPortableText(md);
const boldSpan = roundTripped[0].children.find((c) => (c.marks ?? []).includes("strong"));
expect(boldSpan).toBeDefined();
expect(boldSpan!.text).toBe("bold");
});
});
// ---------------------------------------------------------------------------
// Schema-aware conversion
// ---------------------------------------------------------------------------
describe("convertDataForRead", () => {
const fields: FieldSchema[] = [
{ slug: "title", type: "string" },
{ slug: "body", type: "portableText" },
{ slug: "sidebar", type: "portableText" },
];
it("converts PT arrays to markdown for portableText fields", () => {
const data = {
title: "Hello",
body: [
{
_type: "block",
style: "normal",
markDefs: [],
children: [{ _type: "span", text: "Content", marks: [] }],
},
],
};
const result = convertDataForRead(data, fields);
expect(result.title).toBe("Hello");
expect(typeof result.body).toBe("string");
expect(result.body).toContain("Content");
});
it("skips conversion when raw is true", () => {
const data = {
body: [{ _type: "block", children: [{ _type: "span", text: "X" }] }],
};
const result = convertDataForRead(data, fields, true);
expect(Array.isArray(result.body)).toBe(true);
});
it("does not touch non-portableText fields", () => {
const data = { title: "Test", body: "already a string" };
const result = convertDataForRead(data, fields);
expect(result.title).toBe("Test");
expect(result.body).toBe("already a string"); // not an array, skip
});
});
describe("convertDataForWrite", () => {
const fields: FieldSchema[] = [
{ slug: "title", type: "string" },
{ slug: "body", type: "portableText" },
];
it("converts markdown strings to PT for portableText fields", () => {
const data = { title: "Hello", body: "Some **bold** text" };
const result = convertDataForWrite(data, fields);
expect(result.title).toBe("Hello");
expect(Array.isArray(result.body)).toBe(true);
const blocks = result.body as PortableTextBlock[];
expect(blocks[0]._type).toBe("block");
const boldSpan = blocks[0].children.find((c) => (c.marks ?? []).includes("strong"));
expect(boldSpan!.text).toBe("bold");
});
it("passes through raw PT arrays unchanged", () => {
const ptArray = [{ _type: "block", children: [{ _type: "span", text: "Raw" }] }];
const data = { body: ptArray };
const result = convertDataForWrite(data, fields);
expect(result.body).toBe(ptArray); // same reference
});
});

View File

@@ -0,0 +1,320 @@
import { describe, it, expect } from "vitest";
import type { Interceptor } from "../../../src/client/transport.js";
import {
createTransport,
csrfInterceptor,
refreshInterceptor,
tokenInterceptor,
} from "../../../src/client/transport.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Create an interceptor that adds a header to the request */
function createHeaderInterceptor(name: string, value: string): Interceptor {
return async (req, next) => {
const headers = new Headers(req.headers);
headers.set(name, value);
return next(new Request(req, { headers }));
};
}
/** Create a mock fetch that returns a fixed response */
function mockFetch(body: unknown = {}, status: number = 200): Interceptor {
return async () =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
// ---------------------------------------------------------------------------
// createTransport
// ---------------------------------------------------------------------------
describe("createTransport", () => {
it("calls global fetch when no interceptors are provided", async () => {
const transport = createTransport({
interceptors: [mockFetch({ ok: true })],
});
const res = await transport.fetch(new Request("https://example.com"));
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ ok: true });
});
it("runs interceptors in order", async () => {
const order: string[] = [];
const first: Interceptor = async (req, next) => {
order.push("first-before");
const res = await next(req);
order.push("first-after");
return res;
};
const second: Interceptor = async (req, next) => {
order.push("second-before");
const res = await next(req);
order.push("second-after");
return res;
};
const transport = createTransport({
interceptors: [first, second, mockFetch()],
});
await transport.fetch(new Request("https://example.com"));
expect(order).toEqual(["first-before", "second-before", "second-after", "first-after"]);
});
it("allows interceptors to modify requests", async () => {
let capturedHeader: string | null = null;
const addHeader = createHeaderInterceptor("X-Custom", "test-value");
const capture: Interceptor = async (req) => {
capturedHeader = req.headers.get("X-Custom");
return new Response("ok");
};
const transport = createTransport({
interceptors: [addHeader, capture],
});
await transport.fetch(new Request("https://example.com"));
expect(capturedHeader).toBe("test-value");
});
it("allows interceptors to retry on failure", async () => {
let attempts = 0;
const retryOnce: Interceptor = async (req, next) => {
const res = await next(req);
if (res.status === 401 && attempts === 0) {
attempts++;
return next(req);
}
return res;
};
let callCount = 0;
const backend: Interceptor = async () => {
callCount++;
if (callCount === 1) {
return new Response("unauthorized", { status: 401 });
}
return new Response("ok", { status: 200 });
};
const transport = createTransport({
interceptors: [retryOnce, backend],
});
const res = await transport.fetch(new Request("https://example.com"));
expect(res.status).toBe(200);
expect(callCount).toBe(2);
});
});
// ---------------------------------------------------------------------------
// csrfInterceptor
// ---------------------------------------------------------------------------
describe("csrfInterceptor", () => {
it("adds X-EmDash-Request header to POST requests", async () => {
let capturedHeader: string | null = null;
const capture: Interceptor = async (req) => {
capturedHeader = req.headers.get("X-EmDash-Request");
return new Response("ok");
};
const transport = createTransport({
interceptors: [csrfInterceptor(), capture],
});
await transport.fetch(new Request("https://example.com", { method: "POST" }));
expect(capturedHeader).toBe("1");
});
it("adds X-EmDash-Request header to PUT requests", async () => {
let capturedHeader: string | null = null;
const capture: Interceptor = async (req) => {
capturedHeader = req.headers.get("X-EmDash-Request");
return new Response("ok");
};
const transport = createTransport({
interceptors: [csrfInterceptor(), capture],
});
await transport.fetch(new Request("https://example.com", { method: "PUT" }));
expect(capturedHeader).toBe("1");
});
it("adds X-EmDash-Request header to DELETE requests", async () => {
let capturedHeader: string | null = null;
const capture: Interceptor = async (req) => {
capturedHeader = req.headers.get("X-EmDash-Request");
return new Response("ok");
};
const transport = createTransport({
interceptors: [csrfInterceptor(), capture],
});
await transport.fetch(new Request("https://example.com", { method: "DELETE" }));
expect(capturedHeader).toBe("1");
});
it("does NOT add header to GET requests", async () => {
let capturedHeader: string | null = null;
const capture: Interceptor = async (req) => {
capturedHeader = req.headers.get("X-EmDash-Request");
return new Response("ok");
};
const transport = createTransport({
interceptors: [csrfInterceptor(), capture],
});
await transport.fetch(new Request("https://example.com", { method: "GET" }));
expect(capturedHeader).toBeNull();
});
});
// ---------------------------------------------------------------------------
// tokenInterceptor
// ---------------------------------------------------------------------------
describe("tokenInterceptor", () => {
it("adds Authorization Bearer header to all requests", async () => {
let capturedAuth: string | null = null;
const capture: Interceptor = async (req) => {
capturedAuth = req.headers.get("Authorization");
return new Response("ok");
};
const transport = createTransport({
interceptors: [tokenInterceptor("ec_pat_abc123"), capture],
});
await transport.fetch(new Request("https://example.com"));
expect(capturedAuth).toBe("Bearer ec_pat_abc123");
});
it("adds Authorization to both GET and POST", async () => {
const captured: string[] = [];
const capture: Interceptor = async (req) => {
captured.push(req.headers.get("Authorization") ?? "");
return new Response("ok");
};
const transport = createTransport({
interceptors: [tokenInterceptor("tok"), capture],
});
await transport.fetch(new Request("https://example.com", { method: "GET" }));
await transport.fetch(new Request("https://example.com", { method: "POST" }));
expect(captured).toEqual(["Bearer tok", "Bearer tok"]);
});
});
// ---------------------------------------------------------------------------
// Interceptor composition
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// refreshInterceptor
// ---------------------------------------------------------------------------
describe("refreshInterceptor", () => {
it("unwraps { data: { access_token } } envelope from token endpoint", async () => {
let retryAuth: string | null = null;
let refreshedToken: string | null = null;
let refreshedRefresh: string | null = null;
const interceptor = refreshInterceptor({
refreshToken: "rt_old",
tokenEndpoint: "https://example.com/_emdash/api/oauth/token/refresh",
onTokenRefreshed: (accessToken, refreshToken) => {
refreshedToken = accessToken;
refreshedRefresh = refreshToken;
},
});
// Mock: first call returns 401, refresh endpoint returns wrapped envelope,
// retry should use the new token
let callCount = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.includes("/oauth/token/refresh")) {
// Server wraps in { data: ... } via apiSuccess/unwrapResult
return new Response(
JSON.stringify({
data: {
access_token: "new_access",
refresh_token: "new_refresh",
expires_in: 3600,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return originalFetch(input);
};
try {
const backend: Interceptor = async (req) => {
callCount++;
if (callCount === 1) {
return new Response("unauthorized", { status: 401 });
}
retryAuth = req.headers.get("Authorization");
return new Response("ok", { status: 200 });
};
const transport = createTransport({
interceptors: [interceptor, backend],
});
const res = await transport.fetch(new Request("https://example.com/api/test"));
expect(res.status).toBe(200);
expect(callCount).toBe(2);
expect(retryAuth).toBe("Bearer new_access");
expect(refreshedToken).toBe("new_access");
expect(refreshedRefresh).toBe("new_refresh");
} finally {
globalThis.fetch = originalFetch;
}
});
});
// ---------------------------------------------------------------------------
// Interceptor composition
// ---------------------------------------------------------------------------
describe("interceptor composition", () => {
it("csrf + token interceptors compose correctly", async () => {
let capturedAuth: string | null = null;
let capturedCsrf: string | null = null;
const capture: Interceptor = async (req) => {
capturedAuth = req.headers.get("Authorization");
capturedCsrf = req.headers.get("X-EmDash-Request");
return new Response("ok");
};
const transport = createTransport({
interceptors: [csrfInterceptor(), tokenInterceptor("tok"), capture],
});
await transport.fetch(new Request("https://example.com", { method: "POST" }));
expect(capturedAuth).toBe("Bearer tok");
expect(capturedCsrf).toBe("1");
});
});