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,498 @@
/**
* Integration tests for EmDashClient.
*
* Tests full CRUD lifecycles against a mock HTTP backend that simulates
* the real API behavior including _rev tokens, schema caching, and
* content state transitions.
*/
import { describe, it, expect } from "vitest";
import { EmDashClient, EmDashApiError } from "../../../src/client/index.js";
import type { Interceptor } from "../../../src/client/transport.js";
// ---------------------------------------------------------------------------
// Simulated backend
// ---------------------------------------------------------------------------
const COLLECTION_MATCH_REGEX = /^\/schema\/collections\/([^/]+)$/;
const CONTENT_LIST_REGEX = /^\/content\/([^/]+)$/;
const CONTENT_ITEM_REGEX = /^\/content\/([^/]+)\/([^/]+)$/;
const CONTENT_ACTION_REGEX = /^\/content\/([^/]+)\/([^/]+)\/(publish|unpublish|schedule|restore)$/;
interface StoredItem {
id: string;
type: string;
slug: string | null;
status: string;
data: Record<string, unknown>;
authorId: string | null;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
scheduledAt: string | null;
liveRevisionId: string | null;
draftRevisionId: string | null;
version: number;
}
function encodeRev(item: StoredItem): string {
return btoa(`${item.version}:${item.updatedAt}`);
}
/** Wraps body in `{ data: body }` to match the standard API response envelope. */
function jsonRes(body: unknown, status = 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" },
});
}
/**
* A stateful mock backend that simulates EmDash's REST API.
* Supports schema, content CRUD, _rev tokens, and conflict detection.
*/
function createStatefulBackend() {
const collections = new Map<
string,
{
slug: string;
label: string;
labelSingular: string;
fields: Array<{ slug: string; type: string; label: string; required?: boolean }>;
}
>();
const content = new Map<string, StoredItem>();
let idCounter = 0;
// Seed a collection
collections.set("posts", {
slug: "posts",
label: "Posts",
labelSingular: "Post",
fields: [
{ slug: "title", type: "string", label: "Title", required: true },
{ slug: "body", type: "portableText", label: "Body" },
{ slug: "excerpt", type: "text", label: "Excerpt" },
],
});
const interceptor: Interceptor = async (req) => {
const url = new URL(req.url);
const path = url.pathname.replace("/_emdash/api", "");
// --- Schema routes ---
if (req.method === "GET" && path === "/schema/collections") {
return jsonRes({
items: Array.from(collections.values(), ({ slug, label, labelSingular }) => ({
slug,
label,
labelSingular,
supports: [],
})),
});
}
const colMatch = path.match(COLLECTION_MATCH_REGEX);
if (req.method === "GET" && colMatch) {
const col = collections.get(colMatch[1]);
if (!col) return jsonRes({ error: { code: "NOT_FOUND", message: "Not found" } }, 404);
return jsonRes({ item: { ...col, supports: [] } });
}
// --- Manifest ---
if (req.method === "GET" && path === "/manifest") {
const cols: Record<string, unknown> = {};
for (const [slug, col] of collections) {
const fields: Record<string, unknown> = {};
for (const f of col.fields) {
fields[f.slug] = { kind: f.type, label: f.label, required: f.required };
}
cols[slug] = {
label: col.label,
labelSingular: col.labelSingular,
supports: [],
fields,
};
}
return jsonRes({ version: "0.1.0", hash: "abc", collections: cols, plugins: {} });
}
// --- Content list ---
const listMatch = path.match(CONTENT_LIST_REGEX);
if (req.method === "GET" && listMatch) {
const collectionSlug = listMatch[1];
const status = url.searchParams.get("status");
const items = [...content.values()]
.filter((i) => i.type === collectionSlug)
.filter((i) => !status || i.status === status);
return jsonRes({ items, nextCursor: undefined });
}
// --- Content create ---
if (req.method === "POST" && listMatch) {
const collectionSlug = listMatch[1];
const body = (await req.json()) as {
data: Record<string, unknown>;
slug?: string;
status?: string;
};
const id = `item_${++idCounter}`;
const now = new Date().toISOString();
const item: StoredItem = {
id,
type: collectionSlug,
slug: body.slug ?? null,
status: body.status ?? "draft",
data: body.data,
authorId: null,
createdAt: now,
updatedAt: now,
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
version: 1,
};
content.set(id, item);
return jsonRes({ item, _rev: encodeRev(item) });
}
// --- Content get/update/delete ---
const itemMatch = path.match(CONTENT_ITEM_REGEX);
if (itemMatch) {
const itemId = itemMatch[2];
const item = content.get(itemId);
if (req.method === "GET") {
if (!item) return jsonRes({ error: { code: "NOT_FOUND", message: "Not found" } }, 404);
return jsonRes({ item, _rev: encodeRev(item) });
}
if (req.method === "PUT") {
if (!item) return jsonRes({ error: { code: "NOT_FOUND", message: "Not found" } }, 404);
const body = (await req.json()) as {
data?: Record<string, unknown>;
slug?: string;
status?: string;
_rev?: string;
};
// Check _rev for conflict
if (body._rev) {
const expected = encodeRev(item);
if (body._rev !== expected) {
return jsonRes(
{
error: {
code: "CONFLICT",
message: "Entry has been modified since last read",
},
},
409,
);
}
}
// Apply updates
if (body.data) item.data = { ...item.data, ...body.data };
if (body.slug !== undefined) item.slug = body.slug;
if (body.status) item.status = body.status;
item.updatedAt = new Date().toISOString();
item.version++;
return jsonRes({ item, _rev: encodeRev(item) });
}
if (req.method === "DELETE") {
if (!item) return jsonRes({ error: { code: "NOT_FOUND", message: "Not found" } }, 404);
item.status = "trashed";
item.updatedAt = new Date().toISOString();
return jsonRes({});
}
}
// --- Content actions ---
const actionMatch = path.match(CONTENT_ACTION_REGEX);
if (req.method === "POST" && actionMatch) {
const itemId = actionMatch[2];
const action = actionMatch[3];
const item = content.get(itemId);
if (!item) return jsonRes({ error: { code: "NOT_FOUND", message: "Not found" } }, 404);
switch (action) {
case "publish":
item.status = "published";
item.publishedAt = new Date().toISOString();
break;
case "unpublish":
item.status = "draft";
item.publishedAt = null;
break;
case "schedule": {
const body = (await req.json()) as { scheduledAt: string };
item.scheduledAt = body.scheduledAt;
break;
}
case "restore":
item.status = "draft";
break;
}
item.updatedAt = new Date().toISOString();
return jsonRes({});
}
// --- Search ---
if (req.method === "GET" && path === "/search") {
const q = url.searchParams.get("q") ?? "";
const items = [...content.values()]
.filter((i) => JSON.stringify(i.data).toLowerCase().includes(q.toLowerCase()))
.map((i) => ({
id: i.id,
collection: i.type,
title: typeof i.data.title === "string" ? i.data.title : "",
score: 1,
}));
return jsonRes({ items });
}
return jsonRes(
{ error: { code: "NOT_FOUND", message: `No route: ${req.method} ${path}` } },
404,
);
};
return { interceptor, collections, content };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("EmDashClient lifecycle (integration)", () => {
function createClient() {
const { interceptor, content } = createStatefulBackend();
const client = new EmDashClient({
baseUrl: "http://localhost:4321",
token: "test",
interceptors: [interceptor],
});
return { client, content };
}
it("full content CRUD lifecycle", async () => {
const { client } = createClient();
// Create
const created = await client.create("posts", {
data: { title: "My Post", body: "Hello **world**" },
slug: "my-post",
status: "draft",
});
expect(created.id).toBeDefined();
expect(created.slug).toBe("my-post");
expect(created.status).toBe("draft");
// body was converted from markdown to PT
expect(Array.isArray(created.data.body)).toBe(true);
// List
const list = await client.list("posts");
expect(list.items).toHaveLength(1);
expect(list.items[0].id).toBe(created.id);
// Get — returns _rev for optimistic concurrency
const fetched = await client.get("posts", created.id);
expect(fetched.id).toBe(created.id);
expect(typeof fetched.data.body).toBe("string"); // PT -> markdown
expect(fetched.data.body).toContain("world");
expect(fetched._rev).toBeDefined();
// Update with explicit _rev
const updated = await client.update("posts", created.id, {
data: { title: "Updated Title" },
_rev: fetched._rev,
});
expect(updated.data.title).toBe("Updated Title");
// Publish
await client.publish("posts", created.id);
// List published
const published = await client.list("posts", { status: "published" });
expect(published.items).toHaveLength(1);
// Unpublish
await client.unpublish("posts", created.id);
// Delete (soft)
await client.delete("posts", created.id);
});
it("blind update succeeds without _rev", async () => {
const { client } = createClient();
const item = await client.create("posts", {
data: { title: "Test" },
});
// Update without reading — blind write (no _rev) should succeed
const updated = await client.update("posts", item.id, {
data: { title: "Blind Write OK" },
});
expect(updated.data.title).toBe("Blind Write OK");
});
it("get() returns _rev and update() accepts it for conflict detection", async () => {
const { client } = createClient();
const item = await client.create("posts", {
data: { title: "Test" },
});
// Read — should return _rev on the item
const fetched = await client.get("posts", item.id);
expect(fetched._rev).toBeDefined();
// Update with explicit _rev
const updated = await client.update("posts", item.id, {
data: { title: "Safe Update" },
_rev: fetched._rev,
});
expect(updated.data.title).toBe("Safe Update");
});
it("multiple sequential updates work with explicit _rev", async () => {
const { client } = createClient();
const item = await client.create("posts", {
data: { title: "V1" },
});
// First read
const v1 = await client.get("posts", item.id);
// First update with _rev
await client.update("posts", item.id, {
data: { title: "V2" },
_rev: v1._rev,
});
// Re-read for fresh _rev (previous rev is now stale)
const v2 = await client.get("posts", item.id);
// Second update with new _rev
const v3 = await client.update("posts", item.id, {
data: { title: "V3" },
_rev: v2._rev,
});
expect(v3.data.title).toBe("V3");
});
it("listAll() iterates through all items", async () => {
const { client } = createClient();
// Create multiple items
await client.create("posts", { data: { title: "A" } });
await client.create("posts", { data: { title: "B" } });
await client.create("posts", { data: { title: "C" } });
const all = [];
for await (const item of client.listAll("posts")) {
all.push(item);
}
expect(all).toHaveLength(3);
});
it("schedule() sets scheduling metadata", async () => {
const { client } = createClient();
const item = await client.create("posts", { data: { title: "Scheduled" } });
await client.schedule("posts", item.id, { at: "2026-06-01T09:00:00Z" });
// Verify via get
const fetched = await client.get("posts", item.id);
expect(fetched.scheduledAt).toBe("2026-06-01T09:00:00Z");
});
it("search() finds matching content", async () => {
const { client } = createClient();
await client.create("posts", { data: { title: "Deployment Guide" } });
await client.create("posts", { data: { title: "Getting Started" } });
const results = await client.search("deployment");
expect(results).toHaveLength(1);
expect(results[0].title).toBe("Deployment Guide");
});
it("schema operations work", async () => {
const { client } = createClient();
const cols = await client.collections();
expect(cols.length).toBeGreaterThan(0);
expect(cols[0].slug).toBe("posts");
const col = await client.collection("posts");
expect(col.fields).toHaveLength(3);
expect(col.fields[0].slug).toBe("title");
});
it("manifest() returns full schema", async () => {
const { client } = createClient();
const manifest = await client.manifest();
expect(manifest.version).toBe("0.1.0");
expect(manifest.collections.posts).toBeDefined();
expect(manifest.collections.posts.fields.title).toBeDefined();
});
it("API errors are typed correctly", async () => {
const { client } = createClient();
try {
await client.get("posts", "nonexistent");
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(EmDashApiError);
const apiErr = error as EmDashApiError;
expect(apiErr.status).toBe(404);
expect(apiErr.code).toBe("NOT_FOUND");
}
});
it("PT conversion round-trips through create and get", async () => {
const { client } = createClient();
// Create with markdown
const item = await client.create("posts", {
data: {
title: "Markdown Post",
body: "# Hello\n\nSome **bold** text\n\n- Item 1\n- Item 2",
},
});
// Data stored as PT
expect(Array.isArray(item.data.body)).toBe(true);
// Get returns markdown
const fetched = await client.get("posts", item.id);
expect(typeof fetched.data.body).toBe("string");
const body = fetched.data.body as string;
expect(body).toContain("# Hello");
expect(body).toContain("**bold**");
expect(body).toContain("- Item 1");
});
});

View File

@@ -0,0 +1,394 @@
/**
* E2E tests for EmDashClient against a real Astro dev server.
*
* Uses an isolated fixture (not the demo site). The test helper
* creates a temp directory, starts a fresh dev server, runs setup,
* and seeds collections with test data.
*
* Runs by default. Requires built artifacts (auto-builds if missing).
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { EmDashClient, EmDashApiError } from "../../../src/client/index.js";
import type { TestServerContext } from "../server.js";
import { assertNodeVersion, createTestServer } from "../server.js";
const PORT = 4399;
describe("EmDashClient Integration", () => {
let ctx: TestServerContext;
beforeAll(async () => {
assertNodeVersion();
ctx = await createTestServer({ port: PORT });
});
afterAll(async () => {
await ctx?.cleanup();
});
it("fetches the manifest", async () => {
const manifest = await ctx.client.manifest();
expect(manifest.version).toBeDefined();
expect(typeof manifest.collections).toBe("object");
});
it("lists collections", async () => {
const collections = await ctx.client.collections();
expect(Array.isArray(collections)).toBe(true);
// Seeded collections should be present
const slugs = collections.map((c: { slug: string }) => c.slug);
expect(slugs).toContain("posts");
expect(slugs).toContain("pages");
});
it("lists seeded content", async () => {
const posts = await ctx.client.list("posts");
expect(posts.items.length).toBeGreaterThanOrEqual(2);
// Check published posts are returned
const titles = posts.items.map((p: { data: Record<string, unknown> }) => p.data.title);
expect(titles).toContain("First Post");
expect(titles).toContain("Second Post");
});
it("creates, reads, updates, and deletes content", async () => {
// Create
const item = await ctx.client.create("posts", {
data: { title: "E2E Article", body: "Hello **e2e**", excerpt: "Testing" },
slug: "e2e-article",
});
expect(item.id).toBeDefined();
expect(item.slug).toBe("e2e-article");
// Read — returns _rev for optimistic concurrency
const fetched = await ctx.client.get("posts", item.id);
expect(fetched.data.title).toBe("E2E Article");
expect(typeof fetched.data.body).toBe("string"); // PT→Markdown
expect(fetched._rev).toBeDefined();
// Update — pass _rev explicitly
const updated = await ctx.client.update("posts", item.id, {
data: { title: "Updated E2E Article" },
_rev: fetched._rev,
});
expect(updated.data.title).toBe("Updated E2E Article");
// Publish / unpublish
await ctx.client.publish("posts", item.id);
await ctx.client.unpublish("posts", item.id);
// Delete
await ctx.client.delete("posts", item.id);
});
it("blind update succeeds without _rev", async () => {
const item = await ctx.client.create("posts", {
data: { title: "Blind Update Test" },
});
// Fresh client — no prior get(), no _rev — blind write should succeed
const freshClient = new EmDashClient({
baseUrl: ctx.baseUrl,
devBypass: true,
});
const updated = await freshClient.update("posts", item.id, {
data: { title: "Blind Write OK" },
});
expect(updated.data.title).toBe("Blind Write OK");
await ctx.client.delete("posts", item.id);
});
it("returns Portable Text arrays in raw mode", async () => {
const item = await ctx.client.create("posts", {
data: { title: "Raw Test", body: "Some **bold** text" },
});
// Normal get — body as markdown string
const normal = await ctx.client.get("posts", item.id);
expect(typeof normal.data.body).toBe("string");
// Raw get — body as PT array
const raw = await ctx.client.get("posts", item.id, { raw: true });
expect(Array.isArray(raw.data.body)).toBe(true);
await ctx.client.delete("posts", item.id);
});
it("authenticates with PAT token", async () => {
// Use the PAT token directly via fetch (not the devBypass client)
const res = await fetch(`${ctx.baseUrl}/_emdash/api/content/posts`, {
headers: { Authorization: `Bearer ${ctx.token}` },
});
expect(res.ok).toBe(true);
const json = (await res.json()) as { data: { items: unknown[] } };
expect(Array.isArray(json.data.items)).toBe(true);
});
// -----------------------------------------------------------------------
// Rendered output tests
// -----------------------------------------------------------------------
/** Fetch a page and return the HTML body text */
async function fetchHtml(path: string): Promise<string> {
const res = await fetch(`${ctx.baseUrl}${path}`);
return res.text();
}
it("renders seeded posts on the index page", async () => {
const html = await fetchHtml("/");
// Published posts should appear
expect(html).toContain("First Post");
expect(html).toContain("Second Post");
// Draft post should NOT appear on the public page
expect(html).not.toContain("Draft Post");
});
it("renders a single post by slug", async () => {
const html = await fetchHtml("/posts/first-post");
expect(html).toContain('<h1 id="title">First Post</h1>');
expect(html).toContain("The very first post"); // excerpt
});
it("returns 404 for a nonexistent slug", async () => {
const res = await fetch(`${ctx.baseUrl}/posts/does-not-exist`);
expect(res.status).toBe(404);
});
it("reflects API edits in rendered output", async () => {
// Create and publish a new post
const item = await ctx.client.create("posts", {
data: { title: "Render Test Post", excerpt: "Check the HTML" },
slug: "render-test",
});
await ctx.client.publish("posts", item.id);
// Index page should include the new post
const indexHtml = await fetchHtml("/");
expect(indexHtml).toContain("Render Test Post");
// Single page should render it
const postHtml = await fetchHtml("/posts/render-test");
expect(postHtml).toContain("Render Test Post");
expect(postHtml).toContain("Check the HTML");
// Update the title via API — pass _rev from get()
const current = await ctx.client.get("posts", item.id);
await ctx.client.update("posts", item.id, {
data: { title: "Edited Render Test" },
_rev: current._rev,
});
// Rendered page should reflect the edit
const updatedHtml = await fetchHtml("/posts/render-test");
expect(updatedHtml).toContain("Edited Render Test");
expect(updatedHtml).not.toContain("Render Test Post");
// Unpublish — should disappear from index
await ctx.client.unpublish("posts", item.id);
const afterUnpublish = await fetchHtml("/");
expect(afterUnpublish).not.toContain("Edited Render Test");
// Clean up
await ctx.client.delete("posts", item.id);
});
it("creates and deletes collections", async () => {
const col = await ctx.client.createCollection({
slug: "e2e_temp",
label: "Temp",
});
expect(col.slug).toBe("e2e_temp");
const titleField = await ctx.client.createField("e2e_temp", {
slug: "title",
type: "string",
label: "Title",
});
expect(titleField.slug).toBe("title");
await ctx.client.deleteCollection("e2e_temp");
// Collection should be gone
const collections = await ctx.client.collections();
const slugs = collections.map((c: { slug: string }) => c.slug);
expect(slugs).not.toContain("e2e_temp");
});
// -----------------------------------------------------------------------
// Media tests
// -----------------------------------------------------------------------
it("uploads, gets, lists, and deletes media", async () => {
// Create a small PNG file (1x1 pixel)
const pngBytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
// Upload
const uploaded = await ctx.client.mediaUpload(pngBytes, "test-pixel.png", {
alt: "A test pixel",
});
expect(uploaded.id).toBeDefined();
expect(uploaded.filename).toBe("test-pixel.png");
expect(uploaded.mimeType).toBe("image/png");
// Get by ID
const fetched = await ctx.client.mediaGet(uploaded.id);
expect(fetched.id).toBe(uploaded.id);
expect(fetched.filename).toBe("test-pixel.png");
// List — should include the uploaded item
const list = await ctx.client.mediaList();
expect(list.items.length).toBeGreaterThanOrEqual(1);
const ids = list.items.map((m: { id: string }) => m.id);
expect(ids).toContain(uploaded.id);
// Delete
await ctx.client.mediaDelete(uploaded.id);
// Should be gone
await expect(ctx.client.mediaGet(uploaded.id)).rejects.toThrow();
});
// -----------------------------------------------------------------------
// Conflict detection
// -----------------------------------------------------------------------
it("returns 409 on _rev conflict", async () => {
const item = await ctx.client.create("posts", {
data: { title: "Conflict Test" },
});
// Two clients both read the same version
const clientA = new EmDashClient({ baseUrl: ctx.baseUrl, token: ctx.token });
const clientB = new EmDashClient({ baseUrl: ctx.baseUrl, token: ctx.token });
const fetchedA = await clientA.get("posts", item.id);
const fetchedB = await clientB.get("posts", item.id);
// A updates first — succeeds (passes _rev explicitly)
await clientA.update("posts", item.id, {
data: { title: "A wins" },
_rev: fetchedA._rev,
});
// B's _rev is now stale — should get 409
try {
await clientB.update("posts", item.id, {
data: { title: "B loses" },
_rev: fetchedB._rev,
});
expect.fail("Should have thrown a conflict error");
} catch (error) {
expect(error).toBeInstanceOf(EmDashApiError);
const apiErr = error as EmDashApiError;
expect(apiErr.status).toBe(409);
expect(apiErr.code).toBe("CONFLICT");
}
// Clean up
await ctx.client.delete("posts", item.id);
});
// -----------------------------------------------------------------------
// Schedule and restore
// -----------------------------------------------------------------------
it("schedules and restores content", async () => {
const item = await ctx.client.create("posts", {
data: { title: "Schedule Test" },
});
// Schedule for a future date
await ctx.client.schedule("posts", item.id, { at: "2027-06-01T09:00:00Z" });
// Verify via get
const fetched = await ctx.client.get("posts", item.id);
expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00Z");
// Trash and restore
await ctx.client.delete("posts", item.id);
await ctx.client.restore("posts", item.id);
// Should be accessible again (restore preserves the previous status)
const restored = await ctx.client.get("posts", item.id);
expect(restored.status).toBe("scheduled");
// Final cleanup
await ctx.client.delete("posts", item.id);
});
// -----------------------------------------------------------------------
// listAll cursor pagination
// -----------------------------------------------------------------------
it("listAll iterates through paginated results", async () => {
// Create enough items to potentially page (use limit=2 to force pagination)
const ids: string[] = [];
for (let i = 0; i < 5; i++) {
const item = await ctx.client.create("posts", {
data: { title: `Paginate ${i}` },
});
ids.push(item.id);
}
// listAll with small limit should still get all items
const all: { id: string }[] = [];
for await (const item of ctx.client.listAll("posts", { limit: 2 })) {
all.push(item);
}
// Should have at least our 5 + the seeded posts
expect(all.length).toBeGreaterThanOrEqual(5);
// All our created IDs should be in the results
const resultIds = all.map((a) => a.id);
for (const id of ids) {
expect(resultIds).toContain(id);
}
// Clean up
for (const id of ids) {
await ctx.client.delete("posts", id);
}
});
// -----------------------------------------------------------------------
// Error paths
// -----------------------------------------------------------------------
it("throws EmDashApiError on 404", async () => {
try {
await ctx.client.get("posts", "nonexistent-id-12345");
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(EmDashApiError);
const apiErr = error as EmDashApiError;
expect(apiErr.status).toBe(404);
expect(apiErr.code).toBe("NOT_FOUND");
}
});
it("throws on unauthorized request (no token)", async () => {
const noAuthClient = new EmDashClient({
baseUrl: ctx.baseUrl,
// No token, no devBypass
});
try {
await noAuthClient.collections();
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(EmDashApiError);
expect((error as EmDashApiError).status).toBe(401);
}
});
});

View File

@@ -0,0 +1,349 @@
/**
* E2E tests for comment frontend components and API.
*
* Tests the full flow: rendering comments on pages, submitting via the
* public API, approving via admin API, and verifying display.
*
* Note: the public comment API has a rate limit (5 per 10 min per IP).
* Tests are ordered to stay within the limit — avoid adding submissions
* without accounting for the budget.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import type { TestServerContext } from "../server.js";
import { assertNodeVersion, createTestServer } from "../server.js";
const PORT = 4396;
/** Helper: raw fetch with auth headers */
async function adminFetch(
ctx: TestServerContext,
path: string,
init?: RequestInit,
): Promise<Response> {
return fetch(`${ctx.baseUrl}${path}`, {
...init,
headers: {
Authorization: `Bearer ${ctx.token}`,
"X-EmDash-Request": "1",
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
},
});
}
/** Helper: fetch HTML page */
async function fetchHtml(ctx: TestServerContext, path: string): Promise<string> {
const res = await fetch(`${ctx.baseUrl}${path}`);
return res.text();
}
/** Helper: submit a comment via the public API */
async function submitComment(
ctx: TestServerContext,
collection: string,
contentId: string,
data: {
authorName: string;
authorEmail: string;
body: string;
parentId?: string;
website_url?: string;
},
): Promise<Response> {
return fetch(
`${ctx.baseUrl}/_emdash/api/comments/${encodeURIComponent(collection)}/${encodeURIComponent(contentId)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: ctx.baseUrl,
},
body: JSON.stringify(data),
},
);
}
const COMMENT_COUNT_RE = /\d+ Comments/;
describe("Comments Integration", () => {
let ctx: TestServerContext;
beforeAll(async () => {
assertNodeVersion();
ctx = await createTestServer({ port: PORT });
// Enable comments on the posts collection with "none" moderation
// so comments are auto-approved for most tests
const res = await adminFetch(ctx, "/_emdash/api/schema/collections/posts", {
method: "PUT",
body: JSON.stringify({
commentsEnabled: true,
commentsModeration: "none",
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Failed to enable comments on posts (${res.status}): ${body}`);
}
});
afterAll(async () => {
await ctx?.cleanup();
});
// -----------------------------------------------------------------------
// Server-rendered component (no submissions)
// -----------------------------------------------------------------------
it("renders 'No comments yet' for a post with no comments", async () => {
const html = await fetchHtml(ctx, "/posts/first-post");
expect(html).toContain("No comments yet");
expect(html).toContain("ec-comments");
expect(html).toContain("ec-comment-form");
});
it("renders the comment form with correct fields", async () => {
const html = await fetchHtml(ctx, "/posts/first-post");
expect(html).toContain('name="authorName"');
expect(html).toContain('name="authorEmail"');
expect(html).toContain('name="body"');
expect(html).toContain('name="website_url"');
expect(html).toContain("Post Comment");
});
// -----------------------------------------------------------------------
// Submission #1: basic submit + rendering + auto-link + XSS escape
// -----------------------------------------------------------------------
it("submits a comment and renders it with auto-linked URLs and escaped HTML", async () => {
const postId = ctx.contentIds["posts"]![0]!;
// Submit a comment with a URL and HTML in the body
const res = await submitComment(ctx, "posts", postId, {
authorName: "Test User",
authorEmail: "test@example.com",
body: 'Check https://example.com and <script>alert("xss")</script>',
});
expect(res.status).toBe(201);
const json = (await res.json()) as { data: { id: string; status: string; message: string } };
expect(json.data.id).toBeDefined();
expect(json.data.status).toBe("approved");
expect(json.data.message).toBe("Comment published");
// Verify rendered page
const html = await fetchHtml(ctx, "/posts/first-post");
expect(html).toContain("Test User");
expect(html).not.toContain("No comments yet");
// Auto-linked URL
expect(html).toContain('href="https://example.com"');
expect(html).toContain('rel="nofollow ugc noopener"');
// HTML escaped (not rendered as real script tag)
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain('<script>alert("xss")</script>');
});
// -----------------------------------------------------------------------
// Submission #2: honeypot (early exit, doesn't count toward rate limit)
// -----------------------------------------------------------------------
it("silently accepts honeypot submissions", async () => {
const postId = ctx.contentIds["posts"]![0]!;
const res = await submitComment(ctx, "posts", postId, {
authorName: "Bot",
authorEmail: "bot@spam.com",
body: "Buy cheap pills",
website_url: "http://spam.com",
});
// Honeypot: returns 200 OK but doesn't actually create the comment
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { status: string; message: string } };
expect(json.data.status).toBe("pending");
});
// -----------------------------------------------------------------------
// No submission: validation and disabled collection
// -----------------------------------------------------------------------
it("rejects comments when collection has comments disabled", async () => {
const pageId = ctx.contentIds["pages"]![0]!;
const res = await submitComment(ctx, "pages", pageId, {
authorName: "Test",
authorEmail: "test@example.com",
body: "Should fail",
});
expect(res.status).toBe(403);
const data = (await res.json()) as { error: { code: string } };
expect(data.error.code).toBe("COMMENTS_DISABLED");
});
it("returns validation error for missing required fields", async () => {
const postId = ctx.contentIds["posts"]![0]!;
const res = await fetch(`${ctx.baseUrl}/_emdash/api/comments/posts/${postId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: ctx.baseUrl,
},
body: JSON.stringify({ authorName: "Test" }),
});
expect(res.status).toBe(400);
});
// -----------------------------------------------------------------------
// No submission: public GET API
// -----------------------------------------------------------------------
it("lists approved comments via the public GET API", async () => {
const postId = ctx.contentIds["posts"]![0]!;
const res = await fetch(`${ctx.baseUrl}/_emdash/api/comments/posts/${postId}`);
expect(res.ok).toBe(true);
const json = (await res.json()) as { data: { items: { authorName: string; body: string }[] } };
expect(Array.isArray(json.data.items)).toBe(true);
expect(json.data.items.length).toBeGreaterThan(0);
});
// -----------------------------------------------------------------------
// Submissions #3-4: threading (on second-post)
// -----------------------------------------------------------------------
it("submits and renders threaded replies", async () => {
const postId = ctx.contentIds["posts"]![1]!;
const rootRes = await submitComment(ctx, "posts", postId, {
authorName: "Thread Root",
authorEmail: "root@example.com",
body: "Root comment for threading test",
});
expect(rootRes.status).toBe(201);
const rootJson = (await rootRes.json()) as { data: { id: string } };
const replyRes = await submitComment(ctx, "posts", postId, {
authorName: "Thread Reply",
authorEmail: "reply@example.com",
body: "Reply to root comment",
parentId: rootJson.data.id,
});
expect(replyRes.status).toBe(201);
const html = await fetchHtml(ctx, "/posts/second-post");
expect(html).toContain("Thread Root");
expect(html).toContain("Thread Reply");
expect(html).toContain("ec-comment-replies");
});
// -----------------------------------------------------------------------
// Submission #5: moderation (last one within rate limit)
// -----------------------------------------------------------------------
it("holds comments for moderation and allows admin approval", async () => {
const updateRes = await adminFetch(ctx, "/_emdash/api/schema/collections/posts", {
method: "PUT",
body: JSON.stringify({ commentsModeration: "all" }),
});
expect(updateRes.ok).toBe(true);
const postId = ctx.contentIds["posts"]![1]!;
const submitRes = await submitComment(ctx, "posts", postId, {
authorName: "Pending Author",
authorEmail: "pending@example.com",
body: "This needs approval",
});
expect(submitRes.status).toBe(201);
const submitJson = (await submitRes.json()) as { data: { id: string; status: string } };
expect(submitJson.data.status).toBe("pending");
// Pending comment should NOT appear on the rendered page
const htmlBefore = await fetchHtml(ctx, "/posts/second-post");
expect(htmlBefore).not.toContain("This needs approval");
// Approve via admin API
const approveRes = await adminFetch(
ctx,
`/_emdash/api/admin/comments/${submitJson.data.id}/status`,
{
method: "PUT",
body: JSON.stringify({ status: "approved" }),
},
);
expect(approveRes.ok).toBe(true);
// Now it should appear on the rendered page
const htmlAfter = await fetchHtml(ctx, "/posts/second-post");
expect(htmlAfter).toContain("This needs approval");
expect(htmlAfter).toContain("Pending Author");
// Restore "none" moderation
await adminFetch(ctx, "/_emdash/api/schema/collections/posts", {
method: "PUT",
body: JSON.stringify({ commentsModeration: "none" }),
});
});
// -----------------------------------------------------------------------
// No submission: comment count, admin inbox
// -----------------------------------------------------------------------
it("updates the comment count heading as comments are added", async () => {
const html = await fetchHtml(ctx, "/posts/second-post");
expect(html).toMatch(COMMENT_COUNT_RE);
});
it("lists comments in the admin inbox", async () => {
// Default inbox lists all statuses; filter to approved to find our comments
const res = await adminFetch(ctx, "/_emdash/api/admin/comments?status=approved");
expect(res.ok).toBe(true);
const json = (await res.json()) as { data: { items: { id: string; status: string }[] } };
expect(Array.isArray(json.data.items)).toBe(true);
expect(json.data.items.length).toBeGreaterThan(0);
});
it("filters admin inbox by status", async () => {
const res = await adminFetch(ctx, "/_emdash/api/admin/comments?status=approved");
expect(res.ok).toBe(true);
const json = (await res.json()) as { data: { items: { status: string }[] } };
for (const item of json.data.items) {
expect(item.status).toBe("approved");
}
});
// -----------------------------------------------------------------------
// No submission: edge cases (GET-only or expected failures)
// -----------------------------------------------------------------------
it("returns 404 for comments on nonexistent collection", async () => {
const res = await fetch(`${ctx.baseUrl}/_emdash/api/comments/nonexistent/some-id`);
expect(res.status).toBe(404);
});
it("returns 404 for comments on nonexistent content", async () => {
const res = await submitComment(ctx, "posts", "nonexistent-id", {
authorName: "Test",
authorEmail: "test@example.com",
body: "Should fail",
});
// 404 (content not found) or 429 (rate limited) are both acceptable
expect([404, 429]).toContain(res.status);
});
it("returns 400 for reply to nonexistent parent", async () => {
const postId = ctx.contentIds["posts"]![0]!;
const res = await submitComment(ctx, "posts", postId, {
authorName: "Test",
authorEmail: "test@example.com",
body: "Orphan reply",
parentId: "nonexistent-parent-id",
});
// 400 (parent not found) or 429 (rate limited) are both acceptable
expect([400, 429]).toContain(res.status);
});
});

View File

@@ -0,0 +1,189 @@
/**
* Integration tests for plugin field widgets.
*
* Tests the full pipeline:
* - Manifest includes widget property on fields
* - Manifest includes plugin fieldWidgets declarations
* - Content CRUD works with widget-annotated fields
* - Widget data roundtrips correctly through the API
*
* The integration fixture is configured with the color plugin and a
* "theme_color" field with widget "color:picker" on the posts collection.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { TestServerContext } from "../server.js";
import { assertNodeVersion, createTestServer } from "../server.js";
const PORT = 4397;
describe("Field Widgets Integration", () => {
let ctx: TestServerContext;
beforeAll(async () => {
assertNodeVersion();
ctx = await createTestServer({ port: PORT });
});
afterAll(async () => {
await ctx?.cleanup();
});
describe("manifest", () => {
it("includes widget property on the theme_color field", async () => {
const res = await fetch(`${ctx.baseUrl}/_emdash/api/manifest`, {
headers: {
Cookie: ctx.sessionCookie,
"X-EmDash-Request": "1",
},
});
expect(res.ok).toBe(true);
const body = (await res.json()) as { data: Record<string, unknown> };
const manifest = body.data;
const collections = manifest.collections as Record<string, Record<string, unknown>>;
expect(collections.posts).toBeTruthy();
const fields = collections.posts.fields as Record<string, { kind: string; widget?: string }>;
expect(fields.theme_color).toBeTruthy();
expect(fields.theme_color.kind).toBe("string");
expect(fields.theme_color.widget).toBe("color:picker");
});
it("does not include widget on fields without one", async () => {
const res = await fetch(`${ctx.baseUrl}/_emdash/api/manifest`, {
headers: {
Cookie: ctx.sessionCookie,
"X-EmDash-Request": "1",
},
});
const body = (await res.json()) as { data: Record<string, unknown> };
const manifest = body.data;
const collections = manifest.collections as Record<string, Record<string, unknown>>;
const fields = collections.posts.fields as Record<string, { kind: string; widget?: string }>;
expect(fields.title).toBeTruthy();
expect(fields.title.widget).toBeUndefined();
});
it("includes color plugin with fieldWidgets in plugin manifest", async () => {
const res = await fetch(`${ctx.baseUrl}/_emdash/api/manifest`, {
headers: {
Cookie: ctx.sessionCookie,
"X-EmDash-Request": "1",
},
});
const body = (await res.json()) as { data: Record<string, unknown> };
const manifest = body.data;
const plugins = manifest.plugins as Record<string, Record<string, unknown>>;
expect(plugins.color).toBeTruthy();
expect(plugins.color.enabled).toBe(true);
const fieldWidgets = plugins.color.fieldWidgets as Array<{
name: string;
label: string;
fieldTypes: string[];
}>;
expect(fieldWidgets).toBeTruthy();
expect(fieldWidgets.length).toBe(1);
expect(fieldWidgets[0]!.name).toBe("picker");
expect(fieldWidgets[0]!.label).toBe("Color Picker");
expect(fieldWidgets[0]!.fieldTypes).toEqual(["string"]);
});
});
describe("content CRUD with widget fields", () => {
it("creates content with a color widget field value", async () => {
const item = await ctx.client.create("posts", {
data: {
title: "Colorful Post",
theme_color: "#ff6600",
},
slug: "colorful-post",
});
expect(item.id).toBeDefined();
expect(item.slug).toBe("colorful-post");
});
it("reads back the color value correctly", async () => {
const item = await ctx.client.create("posts", {
data: {
title: "Read Color Test",
theme_color: "#00ff88",
},
slug: "read-color-test",
});
const fetched = await ctx.client.get("posts", item.id);
expect(fetched.data.title).toBe("Read Color Test");
expect(fetched.data.theme_color).toBe("#00ff88");
});
it("updates the color value", async () => {
const item = await ctx.client.create("posts", {
data: {
title: "Update Color Test",
theme_color: "#111111",
},
slug: "update-color-test",
});
const fetched = await ctx.client.get("posts", item.id);
const updated = await ctx.client.update("posts", item.id, {
data: { theme_color: "#222222" },
_rev: fetched._rev,
});
expect(updated.data.theme_color).toBe("#222222");
});
it("allows null/empty color value", async () => {
const item = await ctx.client.create("posts", {
data: {
title: "No Color Post",
},
slug: "no-color-post",
});
const fetched = await ctx.client.get("posts", item.id);
// Color field is optional, so it should be null/undefined
expect(fetched.data.theme_color == null || fetched.data.theme_color === "").toBe(true);
});
it("stores color value alongside other content fields", async () => {
const item = await ctx.client.create("posts", {
data: {
title: "Full Post",
excerpt: "A post with color",
theme_color: "#abcdef",
},
slug: "full-post-with-color",
});
const fetched = await ctx.client.get("posts", item.id);
expect(fetched.data.title).toBe("Full Post");
expect(fetched.data.excerpt).toBe("A post with color");
expect(fetched.data.theme_color).toBe("#abcdef");
});
});
describe("content list with widget fields", () => {
it("includes widget field values in list results", async () => {
await ctx.client.create("posts", {
data: {
title: "Listed Color Post",
theme_color: "#ff0000",
},
slug: "listed-color-post",
});
const list = await ctx.client.list("posts");
const post = list.items.find(
(p: { data: Record<string, unknown> }) => p.data.title === "Listed Color Post",
);
expect(post).toBeTruthy();
expect((post as { data: Record<string, unknown> }).data.theme_color).toBe("#ff0000");
});
});
});

View File

@@ -0,0 +1,85 @@
/**
* Integration test for MCP OAuth discovery against a real Astro dev server.
*
* Uses the MCP SDK's own discovery functions with real fetch() so we test
* the actual Astro route registration, not just the handler logic. This
* catches mismatches between the paths we register in routes.ts and the
* paths the SDK constructs per RFC 8414 / RFC 9728.
*/
import {
discoverOAuthProtectedResourceMetadata,
discoverAuthorizationServerMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { TestServerContext } from "../server.js";
import { assertNodeVersion, createTestServer } from "../server.js";
const PORT = 4401;
describe("MCP OAuth Discovery (real server)", () => {
let ctx: TestServerContext;
beforeAll(async () => {
assertNodeVersion();
ctx = await createTestServer({ port: PORT });
});
afterAll(async () => {
await ctx?.cleanup();
});
it("discovers protected resource metadata from the MCP server URL", async () => {
const metadata = await discoverOAuthProtectedResourceMetadata(`${ctx.baseUrl}/_emdash/api/mcp`);
expect(metadata.resource).toBe(`${ctx.baseUrl}/_emdash/api/mcp`);
expect(metadata.authorization_servers).toContain(`${ctx.baseUrl}/_emdash`);
expect(metadata.scopes_supported).toContain("content:read");
expect(metadata.bearer_methods_supported).toContain("header");
});
it("discovers authorization server metadata via the RFC 8414 path", async () => {
// Step 1: get the authorization server URL from protected resource metadata
const resourceMeta = await discoverOAuthProtectedResourceMetadata(
`${ctx.baseUrl}/_emdash/api/mcp`,
);
const authServerUrl = resourceMeta.authorization_servers![0]!;
// Step 2: the SDK constructs /.well-known/oauth-authorization-server/_emdash
// per RFC 8414 (path component appended after well-known prefix).
// This must resolve to a real route, not 404.
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
expect(metadata).toBeDefined();
expect(metadata!.issuer).toBe(`${ctx.baseUrl}/_emdash`);
expect(metadata!.authorization_endpoint).toBe(`${ctx.baseUrl}/_emdash/oauth/authorize`);
expect(metadata!.token_endpoint).toBe(`${ctx.baseUrl}/_emdash/api/oauth/token`);
expect(metadata!.code_challenge_methods_supported).toContain("S256");
expect(metadata!.response_types_supported).toContain("code");
expect(metadata!.grant_types_supported).toContain("authorization_code");
});
it("MCP endpoint returns 401 with resource_metadata in WWW-Authenticate", async () => {
// Unauthenticated POST to MCP should return 401 with the discovery hint
const res = await fetch(`${ctx.baseUrl}/_emdash/api/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0" },
},
id: 1,
}),
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get("WWW-Authenticate");
expect(wwwAuth).toContain("resource_metadata=");
expect(wwwAuth).toContain("/.well-known/oauth-protected-resource");
});
});