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:
710
packages/core/tests/unit/client/client.test.ts
Normal file
710
packages/core/tests/unit/client/client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
546
packages/core/tests/unit/client/portable-text.test.ts
Normal file
546
packages/core/tests/unit/client/portable-text.test.ts
Normal 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("\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("\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
|
||||
});
|
||||
});
|
||||
320
packages/core/tests/unit/client/transport.test.ts
Normal file
320
packages/core/tests/unit/client/transport.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user