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:
498
packages/core/tests/integration/client/client-lifecycle.test.ts
Normal file
498
packages/core/tests/integration/client/client-lifecycle.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
394
packages/core/tests/integration/client/client.test.ts
Normal file
394
packages/core/tests/integration/client/client.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
349
packages/core/tests/integration/client/comments.test.ts
Normal file
349
packages/core/tests/integration/client/comments.test.ts
Normal 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("<script>");
|
||||
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);
|
||||
});
|
||||
});
|
||||
189
packages/core/tests/integration/client/field-widgets.test.ts
Normal file
189
packages/core/tests/integration/client/field-widgets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user