first commit
This commit is contained in:
159
packages/core/tests/database/connection.test.ts
Normal file
159
packages/core/tests/database/connection.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase, EmDashDatabaseError } from "../../src/database/connection.js";
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
|
||||
describe("createDatabase", () => {
|
||||
let db: Kysely<Database> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
db = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
describe("in-memory SQLite", () => {
|
||||
it("should create in-memory database with :memory: URL", () => {
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
it("should allow queries on in-memory database", async () => {
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
|
||||
// Create a simple table
|
||||
await db.schema
|
||||
.createTable("test")
|
||||
.addColumn("id", "text", (col) => col.primaryKey())
|
||||
.execute();
|
||||
|
||||
// Insert a row
|
||||
await db
|
||||
.insertInto("test" as any)
|
||||
.values({ id: "test-1" })
|
||||
.execute();
|
||||
|
||||
// Query it back
|
||||
const result = await db
|
||||
.selectFrom("test" as any)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("test-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file-based SQLite", () => {
|
||||
const testDbPath = "./test-db.sqlite";
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
unlinkSync(testDbPath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
it("should create file-based database with file: URL", () => {
|
||||
db = createDatabase({ url: `file:${testDbPath}` });
|
||||
expect(db).toBeDefined();
|
||||
});
|
||||
|
||||
it("should persist data to file", async () => {
|
||||
// Create database and insert data
|
||||
db = createDatabase({ url: `file:${testDbPath}` });
|
||||
|
||||
await db.schema
|
||||
.createTable("test")
|
||||
.addColumn("id", "text", (col) => col.primaryKey())
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("test" as any)
|
||||
.values({ id: "test-1" })
|
||||
.execute();
|
||||
await db.destroy();
|
||||
db = undefined;
|
||||
|
||||
// Reopen database and verify data persists
|
||||
db = createDatabase({ url: `file:${testDbPath}` });
|
||||
const result = await db
|
||||
.selectFrom("test" as any)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("test-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("libSQL / Turso", () => {
|
||||
it("should throw error for libsql URL without auth token", () => {
|
||||
expect(() => {
|
||||
createDatabase({ url: "libsql://example.turso.io" });
|
||||
}).toThrow(EmDashDatabaseError);
|
||||
|
||||
expect(() => {
|
||||
createDatabase({ url: "libsql://example.turso.io" });
|
||||
}).toThrow("Auth token required");
|
||||
});
|
||||
|
||||
it("should throw not implemented error for libsql URL with token", () => {
|
||||
expect(() => {
|
||||
createDatabase({
|
||||
url: "libsql://example.turso.io",
|
||||
authToken: "test-token",
|
||||
});
|
||||
}).toThrow("LibSQL not yet implemented");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should throw EmDashDatabaseError for invalid URL scheme", () => {
|
||||
expect(() => {
|
||||
createDatabase({ url: "invalid://test" });
|
||||
}).toThrow(EmDashDatabaseError);
|
||||
|
||||
expect(() => {
|
||||
createDatabase({ url: "invalid://test" });
|
||||
}).toThrow("Unsupported database URL scheme");
|
||||
});
|
||||
|
||||
it("should throw EmDashDatabaseError for malformed file path", () => {
|
||||
expect(() => {
|
||||
createDatabase({ url: "file:/nonexistent/path/to/db.sqlite" });
|
||||
}).toThrow(EmDashDatabaseError);
|
||||
});
|
||||
|
||||
it("should wrap underlying errors in EmDashDatabaseError", () => {
|
||||
try {
|
||||
createDatabase({ url: "file:/root/cannot-write-here.db" });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EmDashDatabaseError);
|
||||
expect(error).toHaveProperty("cause");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection lifecycle", () => {
|
||||
it("should allow closing connection with destroy()", async () => {
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
await expect(db.destroy()).resolves.not.toThrow();
|
||||
db = undefined;
|
||||
});
|
||||
|
||||
it("should return functional Kysely instance", () => {
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
|
||||
// Check for Kysely methods
|
||||
expect(db.selectFrom).toBeInstanceOf(Function);
|
||||
expect(db.insertInto).toBeInstanceOf(Function);
|
||||
expect(db.updateTable).toBeInstanceOf(Function);
|
||||
expect(db.deleteFrom).toBeInstanceOf(Function);
|
||||
expect(db.schema).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
289
packages/core/tests/database/migrations.test.ts
Normal file
289
packages/core/tests/database/migrations.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../src/database/connection.js";
|
||||
import { runMigrations, getMigrationStatus } from "../../src/database/migrations/runner.js";
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
|
||||
describe("Database Migrations", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Fresh in-memory database for each test
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("getMigrationStatus", () => {
|
||||
it("should return all migrations as pending for fresh database", async () => {
|
||||
const status = await getMigrationStatus(db);
|
||||
|
||||
expect(status.applied).toEqual([]);
|
||||
expect(status.pending).toContain("001_initial");
|
||||
});
|
||||
|
||||
it("should create migrations tracking table when running migrations", async () => {
|
||||
// Note: getMigrationStatus doesn't create the table, runMigrations does
|
||||
await runMigrations(db);
|
||||
|
||||
// Verify table was created
|
||||
const tables = await db.introspection.getTables();
|
||||
const migrationTable = tables.find((t) => t.name === "_emdash_migrations");
|
||||
expect(migrationTable).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("should run all pending migrations on fresh database", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const status = await getMigrationStatus(db);
|
||||
expect(status.pending).toEqual([]);
|
||||
expect(status.applied).toContain("001_initial");
|
||||
});
|
||||
|
||||
it("should create all tables from initial migration", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const tables = await db.introspection.getTables();
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
|
||||
// Core system tables (no generic "content" table - collections create ec_* tables)
|
||||
expect(tableNames).toContain("revisions");
|
||||
expect(tableNames).toContain("taxonomies");
|
||||
expect(tableNames).toContain("content_taxonomies");
|
||||
expect(tableNames).toContain("media");
|
||||
expect(tableNames).toContain("users");
|
||||
expect(tableNames).toContain("options");
|
||||
expect(tableNames).toContain("audit_logs");
|
||||
expect(tableNames).toContain("_emdash_migrations");
|
||||
// Schema registry tables
|
||||
expect(tableNames).toContain("_emdash_collections");
|
||||
expect(tableNames).toContain("_emdash_fields");
|
||||
});
|
||||
|
||||
it("should be idempotent - running twice should not error", async () => {
|
||||
await runMigrations(db);
|
||||
await expect(runMigrations(db)).resolves.not.toThrow();
|
||||
|
||||
const status = await getMigrationStatus(db);
|
||||
expect(status.applied).toHaveLength(31); // 001_initial through 032_rate_limits (no 010)
|
||||
});
|
||||
|
||||
it("should record migration in tracking table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const records = await db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(records).toHaveLength(31);
|
||||
expect(records[0].name).toBe("001_initial");
|
||||
expect(records[0].timestamp).toBeDefined();
|
||||
expect(records[1].name).toBe("002_media_status");
|
||||
expect(records[1].timestamp).toBeDefined();
|
||||
expect(records[2].name).toBe("003_schema_registry");
|
||||
expect(records[2].timestamp).toBeDefined();
|
||||
expect(records[3].name).toBe("004_plugins");
|
||||
expect(records[3].timestamp).toBeDefined();
|
||||
expect(records[4].name).toBe("005_menus");
|
||||
expect(records[4].timestamp).toBeDefined();
|
||||
expect(records[5].name).toBe("006_taxonomy_defs");
|
||||
expect(records[5].timestamp).toBeDefined();
|
||||
expect(records[6].name).toBe("007_widgets");
|
||||
expect(records[6].timestamp).toBeDefined();
|
||||
expect(records[7].name).toBe("008_auth");
|
||||
expect(records[7].timestamp).toBeDefined();
|
||||
expect(records[8].name).toBe("009_user_disabled");
|
||||
expect(records[8].timestamp).toBeDefined();
|
||||
expect(records[9].name).toBe("011_sections");
|
||||
expect(records[9].timestamp).toBeDefined();
|
||||
expect(records[10].name).toBe("012_search");
|
||||
expect(records[10].timestamp).toBeDefined();
|
||||
expect(records[11].name).toBe("013_scheduled_publishing");
|
||||
expect(records[11].timestamp).toBeDefined();
|
||||
expect(records[12].name).toBe("014_draft_revisions");
|
||||
expect(records[12].timestamp).toBeDefined();
|
||||
expect(records[13].name).toBe("015_indexes");
|
||||
expect(records[13].timestamp).toBeDefined();
|
||||
expect(records[14].name).toBe("016_api_tokens");
|
||||
expect(records[14].timestamp).toBeDefined();
|
||||
expect(records[15].name).toBe("017_authorization_codes");
|
||||
expect(records[15].timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema registry tables", () => {
|
||||
beforeEach(async () => {
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
it("should have _emdash_collections table with correct columns", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const collectionsTable = tables.find((t) => t.name === "_emdash_collections");
|
||||
|
||||
expect(collectionsTable).toBeDefined();
|
||||
const columns = collectionsTable!.columns.map((c) => c.name);
|
||||
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("slug");
|
||||
expect(columns).toContain("label");
|
||||
expect(columns).toContain("label_singular");
|
||||
expect(columns).toContain("description");
|
||||
expect(columns).toContain("icon");
|
||||
expect(columns).toContain("supports");
|
||||
expect(columns).toContain("source");
|
||||
expect(columns).toContain("created_at");
|
||||
expect(columns).toContain("updated_at");
|
||||
});
|
||||
|
||||
it("should have _emdash_fields table with correct columns", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const fieldsTable = tables.find((t) => t.name === "_emdash_fields");
|
||||
|
||||
expect(fieldsTable).toBeDefined();
|
||||
const columns = fieldsTable!.columns.map((c) => c.name);
|
||||
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("collection_id");
|
||||
expect(columns).toContain("slug");
|
||||
expect(columns).toContain("label");
|
||||
expect(columns).toContain("type");
|
||||
expect(columns).toContain("column_type");
|
||||
expect(columns).toContain("required");
|
||||
expect(columns).toContain("unique");
|
||||
expect(columns).toContain("default_value");
|
||||
expect(columns).toContain("validation");
|
||||
expect(columns).toContain("widget");
|
||||
expect(columns).toContain("options");
|
||||
expect(columns).toContain("sort_order");
|
||||
expect(columns).toContain("created_at");
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on collection slug", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: "1",
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await expect(
|
||||
db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: "2",
|
||||
slug: "posts",
|
||||
label: "Posts Again",
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users table schema", () => {
|
||||
beforeEach(async () => {
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on email", async () => {
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "1",
|
||||
email: "test@example.com",
|
||||
role: 50, // ADMIN
|
||||
})
|
||||
.execute();
|
||||
|
||||
await expect(
|
||||
db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "2",
|
||||
email: "test@example.com",
|
||||
role: 40, // EDITOR
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should have auth-related tables", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
|
||||
expect(tableNames).toContain("credentials");
|
||||
expect(tableNames).toContain("auth_tokens");
|
||||
expect(tableNames).toContain("oauth_accounts");
|
||||
expect(tableNames).toContain("allowed_domains");
|
||||
});
|
||||
});
|
||||
|
||||
describe("revisions table", () => {
|
||||
beforeEach(async () => {
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
it("should have correct columns for per-collection architecture", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const revisionsTable = tables.find((t) => t.name === "revisions");
|
||||
|
||||
expect(revisionsTable).toBeDefined();
|
||||
const columns = revisionsTable!.columns.map((c) => c.name);
|
||||
|
||||
// Revisions now reference collection + entry_id instead of content_id
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("collection");
|
||||
expect(columns).toContain("entry_id");
|
||||
expect(columns).toContain("data");
|
||||
expect(columns).toContain("created_at");
|
||||
});
|
||||
|
||||
it("should store revision data", async () => {
|
||||
await db
|
||||
.insertInto("revisions")
|
||||
.values({
|
||||
id: "rev-1",
|
||||
collection: "posts",
|
||||
entry_id: "entry-1",
|
||||
data: JSON.stringify({ title: "Original Title" }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const revisions = await db
|
||||
.selectFrom("revisions")
|
||||
.where("collection", "=", "posts")
|
||||
.where("entry_id", "=", "entry-1")
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(JSON.parse(revisions[0].data)).toEqual({
|
||||
title: "Original Title",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("media table", () => {
|
||||
beforeEach(async () => {
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
it("should have correct columns", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const mediaTable = tables.find((t) => t.name === "media");
|
||||
|
||||
expect(mediaTable).toBeDefined();
|
||||
const columns = mediaTable!.columns.map((c) => c.name);
|
||||
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("filename");
|
||||
expect(columns).toContain("mime_type");
|
||||
expect(columns).toContain("size");
|
||||
expect(columns).toContain("storage_key");
|
||||
});
|
||||
});
|
||||
});
|
||||
755
packages/core/tests/database/repositories/content.test.ts
Normal file
755
packages/core/tests/database/repositories/content.test.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import { EmDashValidationError } from "../../../src/database/repositories/types.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
|
||||
describe("ContentRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: ContentRepository;
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh in-memory database for each test
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
await runMigrations(db);
|
||||
repo = new ContentRepository(db);
|
||||
registry = new SchemaRegistry(db);
|
||||
|
||||
// Create collections needed for tests (this creates ec_post and ec_page tables)
|
||||
await registry.createCollection({
|
||||
slug: "post",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
await registry.createCollection({
|
||||
slug: "page",
|
||||
label: "Pages",
|
||||
labelSingular: "Page",
|
||||
});
|
||||
|
||||
// Add fields to both collections
|
||||
await registry.createField("post", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
await registry.createField("post", {
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
});
|
||||
await registry.createField("page", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create content with minimal data", async () => {
|
||||
const content = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test Post" },
|
||||
});
|
||||
|
||||
expect(content.id).toBeDefined();
|
||||
expect(content.type).toBe("post");
|
||||
expect(content.data).toEqual({ title: "Test Post" });
|
||||
expect(content.status).toBe("draft");
|
||||
expect(content.createdAt).toBeDefined();
|
||||
expect(content.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create content with all fields", async () => {
|
||||
const content = await repo.create({
|
||||
type: "post",
|
||||
slug: "test-post",
|
||||
data: { title: "Test Post", content: "Body" },
|
||||
status: "published",
|
||||
authorId: "author-1",
|
||||
});
|
||||
|
||||
expect(content.id).toBeDefined();
|
||||
expect(content.type).toBe("post");
|
||||
expect(content.slug).toBe("test-post");
|
||||
expect(content.data).toEqual({ title: "Test Post", content: "Body" });
|
||||
expect(content.status).toBe("published");
|
||||
expect(content.authorId).toBe("author-1");
|
||||
});
|
||||
|
||||
it("should throw validation error when type is missing", async () => {
|
||||
await expect(
|
||||
repo.create({
|
||||
type: "",
|
||||
data: { title: "Test" },
|
||||
}),
|
||||
).rejects.toThrow(EmDashValidationError);
|
||||
});
|
||||
|
||||
it("should throw error for duplicate type+slug", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "duplicate-slug",
|
||||
data: { title: "First" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.create({
|
||||
type: "post",
|
||||
slug: "duplicate-slug",
|
||||
data: { title: "Second" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should allow same slug for different types", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "same-slug",
|
||||
data: { title: "Post" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.create({
|
||||
type: "page",
|
||||
slug: "same-slug",
|
||||
data: { title: "Page" },
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow null slug", async () => {
|
||||
const content = await repo.create({
|
||||
type: "post",
|
||||
slug: null,
|
||||
data: { title: "No slug" },
|
||||
});
|
||||
|
||||
expect(content.slug).toBeNull();
|
||||
});
|
||||
|
||||
it("should default status to draft", async () => {
|
||||
const content = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
expect(content.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("should generate unique ID", async () => {
|
||||
const content1 = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "First" },
|
||||
});
|
||||
|
||||
const content2 = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Second" },
|
||||
});
|
||||
|
||||
expect(content1.id).not.toBe(content2.id);
|
||||
});
|
||||
|
||||
it("should store complex nested data in JSON columns", async () => {
|
||||
// Portable Text content is stored as JSON
|
||||
const portableTextContent = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
children: [{ _type: "span", text: "Hello world" }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "h1",
|
||||
children: [{ _type: "span", text: "Heading", marks: ["bold"] }],
|
||||
},
|
||||
];
|
||||
|
||||
const content = await repo.create({
|
||||
type: "post",
|
||||
data: {
|
||||
title: "Complex Post",
|
||||
content: portableTextContent,
|
||||
},
|
||||
});
|
||||
|
||||
expect(content.data.title).toBe("Complex Post");
|
||||
expect(content.data.content).toEqual(portableTextContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("should find content by ID", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.data).toEqual(created.data);
|
||||
});
|
||||
|
||||
it("should return null for non-existent ID", async () => {
|
||||
const found = await repo.findById("post", "non-existent-id");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when type doesn't match", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const found = await repo.findById("page", created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should not find soft-deleted content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBySlug", () => {
|
||||
it("should find content by slug", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "test-slug",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const found = await repo.findBySlug("post", "test-slug");
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.slug).toBe("test-slug");
|
||||
});
|
||||
|
||||
it("should return null for non-existent slug", async () => {
|
||||
const found = await repo.findBySlug("post", "non-existent");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should return correct content when same slug exists for different types", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "shared-slug",
|
||||
data: { title: "Post" },
|
||||
});
|
||||
|
||||
await repo.create({
|
||||
type: "page",
|
||||
slug: "shared-slug",
|
||||
data: { title: "Page" },
|
||||
});
|
||||
|
||||
const post = await repo.findBySlug("post", "shared-slug");
|
||||
const page = await repo.findBySlug("page", "shared-slug");
|
||||
|
||||
expect(post!.type).toBe("post");
|
||||
expect(post!.data.title).toBe("Post");
|
||||
expect(page!.type).toBe("page");
|
||||
expect(page!.data.title).toBe("Page");
|
||||
});
|
||||
|
||||
it("should not find soft-deleted content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "test-slug",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
const found = await repo.findBySlug("post", "test-slug");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
beforeEach(async () => {
|
||||
// Create test data
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: `post-${i}`,
|
||||
data: { title: `Post ${i}` },
|
||||
status: i % 2 === 0 ? "published" : "draft",
|
||||
authorId: i < 3 ? "author-1" : "author-2",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should return all content by default", async () => {
|
||||
const result = await repo.findMany("post");
|
||||
|
||||
expect(result.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
const result = await repo.findMany("post", {
|
||||
where: { status: "published" },
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items.every((item) => item.status === "published")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by authorId", async () => {
|
||||
const result = await repo.findMany("post", {
|
||||
where: { authorId: "author-1" },
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(3);
|
||||
expect(result.items.every((item) => item.authorId === "author-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by both status and authorId", async () => {
|
||||
const result = await repo.findMany("post", {
|
||||
where: {
|
||||
status: "published",
|
||||
authorId: "author-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should apply limit", async () => {
|
||||
const result = await repo.findMany("post", { limit: 2 });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should support cursor pagination", async () => {
|
||||
const page1 = await repo.findMany("post", { limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeDefined();
|
||||
|
||||
const page2 = await repo.findMany("post", {
|
||||
limit: 2,
|
||||
cursor: page1.nextCursor,
|
||||
});
|
||||
expect(page2.items).toHaveLength(2);
|
||||
|
||||
// Items should be different
|
||||
const page1Ids = page1.items.map((i) => i.id);
|
||||
const page2Ids = page2.items.map((i) => i.id);
|
||||
expect(page1Ids).not.toEqual(page2Ids);
|
||||
});
|
||||
|
||||
it("should not include nextCursor when no more items", async () => {
|
||||
const result = await repo.findMany("post", { limit: 10 });
|
||||
|
||||
expect(result.items).toHaveLength(5);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should order by createdAt desc by default", async () => {
|
||||
const result = await repo.findMany("post");
|
||||
|
||||
// Items should be in descending order (newest first)
|
||||
for (let i = 1; i < result.items.length; i++) {
|
||||
expect(result.items[i - 1].createdAt >= result.items[i].createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should support custom ordering", async () => {
|
||||
const result = await repo.findMany("post", {
|
||||
orderBy: {
|
||||
field: "createdAt",
|
||||
direction: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
// Items should be in ascending order (oldest first)
|
||||
for (let i = 1; i < result.items.length; i++) {
|
||||
expect(result.items[i - 1].createdAt <= result.items[i].createdAt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should default limit to 50", async () => {
|
||||
// Create more than 50 items
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await repo.create({
|
||||
type: "page",
|
||||
data: { title: `Page ${i}` },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await repo.findMany("page");
|
||||
|
||||
expect(result.items.length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("should cap limit at 100", async () => {
|
||||
const result = await repo.findMany("post", { limit: 200 });
|
||||
|
||||
// Even with limit: 200, should not return more than 100
|
||||
expect(result.items.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("should not include soft-deleted content", async () => {
|
||||
const toDelete = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "To Delete" },
|
||||
});
|
||||
|
||||
await repo.delete("post", toDelete.id);
|
||||
|
||||
const result = await repo.findMany("post");
|
||||
|
||||
expect(result.items.every((item) => item.id !== toDelete.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return empty array when no items match", async () => {
|
||||
const result = await repo.findMany("page");
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update content data", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Original" },
|
||||
});
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated" },
|
||||
});
|
||||
|
||||
expect(updated.data.title).toBe("Updated");
|
||||
expect(updated.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("should update status", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(updated.status).toBe("published");
|
||||
});
|
||||
|
||||
it("should update slug", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "old-slug",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
slug: "new-slug",
|
||||
});
|
||||
|
||||
expect(updated.slug).toBe("new-slug");
|
||||
});
|
||||
|
||||
it("should update publishedAt", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const publishedAt = new Date().toISOString();
|
||||
const updated = await repo.update("post", created.id, {
|
||||
publishedAt,
|
||||
});
|
||||
|
||||
expect(updated.publishedAt).toBe(publishedAt);
|
||||
});
|
||||
|
||||
it("should support partial updates", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "test-slug",
|
||||
data: { title: "Test", content: "Original content" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Only status should change
|
||||
expect(updated.status).toBe("published");
|
||||
expect(updated.slug).toBe("test-slug");
|
||||
expect(updated.data).toEqual({
|
||||
title: "Test",
|
||||
content: "Original content",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error for non-existent content", async () => {
|
||||
await expect(repo.update("post", "non-existent", { status: "published" })).rejects.toThrow(
|
||||
"Content not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not update soft-deleted content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
await expect(repo.update("post", created.id, { status: "published" })).rejects.toThrow(
|
||||
"Content not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should update updatedAt timestamp", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
// Small delay to ensure timestamp difference
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated" },
|
||||
});
|
||||
|
||||
expect(updated.updatedAt > created.updatedAt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should soft delete content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const result = await repo.delete("post", created.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify content is not found
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should return true for successful deletion", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
const result = await repo.delete("post", created.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-existent content", async () => {
|
||||
const result = await repo.delete("post", "non-existent");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for already deleted content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
await repo.delete("post", created.id);
|
||||
const result = await repo.delete("post", created.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should set deleted_at timestamp", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
// Directly query database to check deleted_at
|
||||
// Use raw SQL since ec_post is a dynamic table
|
||||
const { sql } = await import("kysely");
|
||||
const result = await sql<{ deleted_at: string | null }>`
|
||||
SELECT deleted_at FROM ec_post WHERE id = ${created.id}
|
||||
`.execute(db);
|
||||
|
||||
expect(result.rows[0]?.deleted_at).toBeDefined();
|
||||
expect(result.rows[0]?.deleted_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("count", () => {
|
||||
beforeEach(async () => {
|
||||
// Create test data
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
data: { title: `Post ${i}` },
|
||||
status: i % 2 === 0 ? "published" : "draft",
|
||||
authorId: i < 5 ? "author-1" : "author-2",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should count all content of a type", async () => {
|
||||
const count = await repo.count("post");
|
||||
|
||||
expect(count).toBe(10);
|
||||
});
|
||||
|
||||
it("should count by status", async () => {
|
||||
const count = await repo.count("post", { status: "published" });
|
||||
|
||||
expect(count).toBe(5);
|
||||
});
|
||||
|
||||
it("should count by authorId", async () => {
|
||||
const count = await repo.count("post", { authorId: "author-1" });
|
||||
|
||||
expect(count).toBe(5);
|
||||
});
|
||||
|
||||
it("should count by both status and authorId", async () => {
|
||||
const count = await repo.count("post", {
|
||||
status: "published",
|
||||
authorId: "author-1",
|
||||
});
|
||||
|
||||
// Posts 0, 2, 4 are published by author-1
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("should return 0 when no items match", async () => {
|
||||
const count = await repo.count("page");
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("should not count soft-deleted content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: { title: "To Delete" },
|
||||
});
|
||||
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
const count = await repo.count("post");
|
||||
|
||||
expect(count).toBe(10); // Not 11
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
it("should handle full CRUD lifecycle", async () => {
|
||||
// Create
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "test-post",
|
||||
data: { title: "Test Post", content: "Original content" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
expect(created.id).toBeDefined();
|
||||
expect(created.status).toBe("draft");
|
||||
|
||||
// Read
|
||||
const found = await repo.findBySlug("post", "test-post");
|
||||
expect(found!.id).toBe(created.id);
|
||||
|
||||
// Update
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated Post", content: "New content" },
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(updated.data.title).toBe("Updated Post");
|
||||
expect(updated.status).toBe("published");
|
||||
|
||||
// Delete
|
||||
const deleted = await repo.delete("post", created.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Verify not found
|
||||
const notFound = await repo.findById("post", created.id);
|
||||
expect(notFound).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle concurrent operations", async () => {
|
||||
// Create multiple items concurrently
|
||||
const promises = Array.from({ length: 10 }, (_, i) =>
|
||||
repo.create({
|
||||
type: "post",
|
||||
data: { title: `Post ${i}` },
|
||||
}),
|
||||
);
|
||||
|
||||
const created = await Promise.all(promises);
|
||||
|
||||
expect(created).toHaveLength(10);
|
||||
expect(new Set(created.map((c) => c.id)).size).toBe(10); // All unique IDs
|
||||
});
|
||||
|
||||
it("should persist complex nested data structures", async () => {
|
||||
// Use the content field (portableText type) for complex nested data
|
||||
const complexContent = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "h1",
|
||||
children: [{ _type: "span", text: "Title" }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
children: [
|
||||
{ _type: "span", text: "Bold", marks: ["bold"] },
|
||||
{ _type: "span", text: " and " },
|
||||
{ _type: "span", text: "italic", marks: ["italic"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
data: {
|
||||
title: "Complex Post",
|
||||
content: complexContent,
|
||||
},
|
||||
});
|
||||
|
||||
const retrieved = await repo.findById("post", created.id);
|
||||
|
||||
expect(retrieved!.data.title).toBe("Complex Post");
|
||||
expect(retrieved!.data.content).toEqual(complexContent);
|
||||
});
|
||||
});
|
||||
});
|
||||
64
packages/core/tests/fields/image.test.ts
Normal file
64
packages/core/tests/fields/image.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { image } from "../../src/fields/image.js";
|
||||
|
||||
describe("image field", () => {
|
||||
it("should create field definition", () => {
|
||||
const field = image();
|
||||
|
||||
expect(field.type).toBe("image");
|
||||
expect(field.schema).toBeDefined();
|
||||
expect(field.ui?.widget).toBe("image");
|
||||
});
|
||||
|
||||
it("should accept valid image value", () => {
|
||||
const field = image();
|
||||
const valid = {
|
||||
id: "img-123",
|
||||
src: "https://example.com/image.jpg",
|
||||
alt: "Test image",
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
|
||||
expect(() => field.schema.parse(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should accept image without optional fields", () => {
|
||||
const field = image();
|
||||
const minimal = {
|
||||
id: "img-123",
|
||||
src: "https://example.com/image.jpg",
|
||||
};
|
||||
|
||||
expect(() => field.schema.parse(minimal)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should reject invalid image value", () => {
|
||||
const field = image();
|
||||
|
||||
expect(() => field.schema.parse({ id: "missing-src" })).toThrow();
|
||||
expect(() => field.schema.parse("not an object")).toThrow();
|
||||
});
|
||||
|
||||
it("should support required option", () => {
|
||||
const required = image({ required: true });
|
||||
const optional = image({ required: false });
|
||||
|
||||
// Required should reject undefined
|
||||
expect(() => required.schema.parse(undefined)).toThrow();
|
||||
|
||||
// Optional should accept undefined
|
||||
expect(() => optional.schema.parse(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should store options", () => {
|
||||
const field = image({
|
||||
maxSize: 5 * 1024 * 1024, // 5MB
|
||||
allowedTypes: ["image/jpeg", "image/png"],
|
||||
});
|
||||
|
||||
expect(field.options?.maxSize).toBe(5 * 1024 * 1024);
|
||||
expect(field.options?.allowedTypes).toEqual(["image/jpeg", "image/png"]);
|
||||
});
|
||||
});
|
||||
45
packages/core/tests/fields/portable-text.test.ts
Normal file
45
packages/core/tests/fields/portable-text.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { portableText } from "../../src/fields/portable-text.js";
|
||||
|
||||
describe("portableText field", () => {
|
||||
it("should create field definition", () => {
|
||||
const field = portableText();
|
||||
|
||||
expect(field.type).toBe("portableText");
|
||||
expect(field.schema).toBeDefined();
|
||||
expect(field.ui?.widget).toBe("portableText");
|
||||
});
|
||||
|
||||
it("should accept valid Portable Text", () => {
|
||||
const field = portableText();
|
||||
const valid = [
|
||||
{
|
||||
_type: "block",
|
||||
_key: "abc123",
|
||||
style: "normal",
|
||||
children: [{ _type: "span", text: "Hello World" }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => field.schema.parse(valid)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should reject invalid Portable Text", () => {
|
||||
const field = portableText();
|
||||
|
||||
expect(() => field.schema.parse("not an array")).toThrow();
|
||||
expect(() => field.schema.parse([{ missing: "_type" }])).toThrow();
|
||||
});
|
||||
|
||||
it("should support required option", () => {
|
||||
const required = portableText({ required: true });
|
||||
const optional = portableText({ required: false });
|
||||
|
||||
// Required should reject undefined
|
||||
expect(() => required.schema.parse(undefined)).toThrow();
|
||||
|
||||
// Optional should accept undefined
|
||||
expect(() => optional.schema.parse(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
40
packages/core/tests/fields/reference.test.ts
Normal file
40
packages/core/tests/fields/reference.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { reference } from "../../src/fields/reference.js";
|
||||
|
||||
describe("reference field", () => {
|
||||
it("should create field definition", () => {
|
||||
const field = reference("posts");
|
||||
|
||||
expect(field.type).toBe("reference");
|
||||
expect(field.schema).toBeDefined();
|
||||
expect(field.ui?.widget).toBe("reference");
|
||||
expect(field.options?.collection).toBe("posts");
|
||||
});
|
||||
|
||||
it("should accept valid reference ID", () => {
|
||||
const field = reference("posts");
|
||||
|
||||
expect(() => field.schema.parse("post-123")).not.toThrow();
|
||||
expect(() => field.schema.parse("abc-def-ghi")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should reject invalid reference", () => {
|
||||
const field = reference("posts");
|
||||
|
||||
expect(() => field.schema.parse(123)).toThrow();
|
||||
expect(() => field.schema.parse({})).toThrow();
|
||||
expect(() => field.schema.parse(null)).toThrow();
|
||||
});
|
||||
|
||||
it("should support required option", () => {
|
||||
const required = reference("posts", { required: true });
|
||||
const optional = reference("posts", { required: false });
|
||||
|
||||
// Required should reject undefined
|
||||
expect(() => required.schema.parse(undefined)).toThrow();
|
||||
|
||||
// Optional should accept undefined
|
||||
expect(() => optional.schema.parse(undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
309
packages/core/tests/integration/auth/api-tokens.test.ts
Normal file
309
packages/core/tests/integration/auth/api-tokens.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Integration tests for API token handlers.
|
||||
*
|
||||
* Tests token CRUD and resolution against a real in-memory SQLite database.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
handleApiTokenCreate,
|
||||
handleApiTokenList,
|
||||
handleApiTokenRevoke,
|
||||
resolveApiToken,
|
||||
resolveOAuthToken,
|
||||
} from "../../../src/api/handlers/api-tokens.js";
|
||||
import { generatePrefixedToken, TOKEN_PREFIXES } from "../../../src/auth/api-tokens.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Regex patterns for token validation
|
||||
const PAT_PREFIX_REGEX = /^ec_pat_/;
|
||||
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
|
||||
// Create a test user
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user_1",
|
||||
email: "admin@test.com",
|
||||
name: "Admin",
|
||||
role: 50, // ADMIN
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("handleApiTokenCreate", () => {
|
||||
it("creates a token and returns the raw value", async () => {
|
||||
const result = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Test Token",
|
||||
scopes: ["content:read", "content:write"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data!.token).toMatch(PAT_PREFIX_REGEX);
|
||||
expect(result.data!.info.name).toBe("Test Token");
|
||||
expect(result.data!.info.scopes).toEqual(["content:read", "content:write"]);
|
||||
expect(result.data!.info.userId).toBe("user_1");
|
||||
expect(result.data!.info.prefix).toMatch(PAT_PREFIX_REGEX);
|
||||
});
|
||||
|
||||
it("creates tokens with different hashes", async () => {
|
||||
const result1 = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Token 1",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
const result2 = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Token 2",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
|
||||
expect(result1.data!.token).not.toBe(result2.data!.token);
|
||||
});
|
||||
|
||||
it("stores expiry date when provided", async () => {
|
||||
const expiresAt = new Date(Date.now() + 86400000).toISOString();
|
||||
const result = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Expiring Token",
|
||||
scopes: ["content:read"],
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
expect(result.data!.info.expiresAt).toBe(expiresAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleApiTokenList", () => {
|
||||
it("lists tokens for a user", async () => {
|
||||
await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Token A",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Token B",
|
||||
scopes: ["admin"],
|
||||
});
|
||||
|
||||
const result = await handleApiTokenList(db, "user_1");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.items).toHaveLength(2);
|
||||
const names = result.data!.items.map((t) => t.name).toSorted();
|
||||
expect(names).toEqual(["Token A", "Token B"]);
|
||||
});
|
||||
|
||||
it("does not return tokens for other users", async () => {
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user_2",
|
||||
email: "other@test.com",
|
||||
name: "Other",
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await handleApiTokenCreate(db, "user_1", {
|
||||
name: "User 1 Token",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
await handleApiTokenCreate(db, "user_2", {
|
||||
name: "User 2 Token",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
|
||||
const result = await handleApiTokenList(db, "user_1");
|
||||
expect(result.data!.items).toHaveLength(1);
|
||||
expect(result.data!.items[0].name).toBe("User 1 Token");
|
||||
});
|
||||
|
||||
it("never returns the token hash", async () => {
|
||||
await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Test",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
|
||||
const result = await handleApiTokenList(db, "user_1");
|
||||
const item = result.data!.items[0];
|
||||
|
||||
// Ensure no hash or raw token is exposed
|
||||
expect(item).not.toHaveProperty("token_hash");
|
||||
expect(item).not.toHaveProperty("tokenHash");
|
||||
expect(item).not.toHaveProperty("token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleApiTokenRevoke", () => {
|
||||
it("revokes a token", async () => {
|
||||
const createResult = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "To Revoke",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
const tokenId = createResult.data!.info.id;
|
||||
|
||||
const result = await handleApiTokenRevoke(db, tokenId, "user_1");
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Should be gone from the list
|
||||
const list = await handleApiTokenList(db, "user_1");
|
||||
expect(list.data!.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns error for non-existent token", async () => {
|
||||
const result = await handleApiTokenRevoke(db, "nonexistent", "user_1");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error!.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("cannot revoke another user's token", async () => {
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user_2",
|
||||
email: "other@test.com",
|
||||
name: "Other",
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const createResult = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "User 1 Token",
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
const tokenId = createResult.data!.info.id;
|
||||
|
||||
// User 2 tries to revoke user 1's token
|
||||
const result = await handleApiTokenRevoke(db, tokenId, "user_2");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error!.code).toBe("NOT_FOUND");
|
||||
|
||||
// Token should still exist
|
||||
const list = await handleApiTokenList(db, "user_1");
|
||||
expect(list.data!.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApiToken", () => {
|
||||
it("resolves a valid token to user and scopes", async () => {
|
||||
const createResult = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Test",
|
||||
scopes: ["content:read", "media:write"],
|
||||
});
|
||||
const rawToken = createResult.data!.token;
|
||||
|
||||
const resolved = await resolveApiToken(db, rawToken);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.userId).toBe("user_1");
|
||||
expect(resolved!.scopes).toEqual(["content:read", "media:write"]);
|
||||
});
|
||||
|
||||
it("returns null for invalid token", async () => {
|
||||
const resolved = await resolveApiToken(db, "ec_pat_invalidtoken123");
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for expired token", async () => {
|
||||
const pastDate = new Date(Date.now() - 86400000).toISOString(); // Yesterday
|
||||
const createResult = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Expired",
|
||||
scopes: ["content:read"],
|
||||
expiresAt: pastDate,
|
||||
});
|
||||
const rawToken = createResult.data!.token;
|
||||
|
||||
const resolved = await resolveApiToken(db, rawToken);
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves non-expired token", async () => {
|
||||
const futureDate = new Date(Date.now() + 86400000).toISOString(); // Tomorrow
|
||||
const createResult = await handleApiTokenCreate(db, "user_1", {
|
||||
name: "Future",
|
||||
scopes: ["admin"],
|
||||
expiresAt: futureDate,
|
||||
});
|
||||
const rawToken = createResult.data!.token;
|
||||
|
||||
const resolved = await resolveApiToken(db, rawToken);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.scopes).toEqual(["admin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOAuthToken", () => {
|
||||
it("resolves a valid OAuth access token", async () => {
|
||||
// Insert directly since we don't have a Device Flow handler yet
|
||||
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
|
||||
const futureDate = new Date(Date.now() + 3600000).toISOString();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_oauth_tokens")
|
||||
.values({
|
||||
token_hash: hash,
|
||||
token_type: "access",
|
||||
user_id: "user_1",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
client_type: "cli",
|
||||
expires_at: futureDate,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const resolved = await resolveOAuthToken(db, raw);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.userId).toBe("user_1");
|
||||
expect(resolved!.scopes).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("returns null for expired OAuth token", async () => {
|
||||
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
|
||||
const pastDate = new Date(Date.now() - 3600000).toISOString();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_oauth_tokens")
|
||||
.values({
|
||||
token_hash: hash,
|
||||
token_type: "access",
|
||||
user_id: "user_1",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
client_type: "cli",
|
||||
expires_at: pastDate,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const resolved = await resolveOAuthToken(db, raw);
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("does not resolve refresh tokens", async () => {
|
||||
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_REFRESH);
|
||||
const futureDate = new Date(Date.now() + 3600000).toISOString();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_oauth_tokens")
|
||||
.values({
|
||||
token_hash: hash,
|
||||
token_type: "refresh",
|
||||
user_id: "user_1",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
client_type: "cli",
|
||||
expires_at: futureDate,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const resolved = await resolveOAuthToken(db, raw);
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
475
packages/core/tests/integration/auth/authorization-code.test.ts
Normal file
475
packages/core/tests/integration/auth/authorization-code.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Integration tests for OAuth 2.1 Authorization Code + PKCE handlers.
|
||||
*
|
||||
* Tests the full authorization code flow lifecycle against a real
|
||||
* in-memory SQLite database.
|
||||
*/
|
||||
|
||||
import { computeS256Challenge, Role } from "@emdashcms/auth";
|
||||
import { generateCodeVerifier } from "arctic";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildDeniedRedirect,
|
||||
cleanupExpiredAuthorizationCodes,
|
||||
handleAuthorizationApproval,
|
||||
handleAuthorizationCodeExchange,
|
||||
} from "../../../src/api/handlers/oauth-authorization.js";
|
||||
import { handleOAuthClientCreate } from "../../../src/api/handlers/oauth-clients.js";
|
||||
import { hashApiToken } from "../../../src/auth/api-tokens.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
const ACCESS_TOKEN_PREFIX_REGEX = /^ec_oat_/;
|
||||
const REFRESH_TOKEN_PREFIX_REGEX = /^ec_ort_/;
|
||||
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
|
||||
// Create a test user
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Register OAuth clients used by tests
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["http://127.0.0.1:8080/callback", "https://myapp.example.com/callback"],
|
||||
});
|
||||
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
redirectUris: ["http://127.0.0.1:8080/callback"],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("Authorization Approval", () => {
|
||||
it("should create an authorization code with valid params", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read content:write",
|
||||
state: "random-state-value",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
const redirectUrl = new URL(result.data.redirect_url);
|
||||
expect(redirectUrl.origin).toBe("http://127.0.0.1:8080");
|
||||
expect(redirectUrl.pathname).toBe("/callback");
|
||||
expect(redirectUrl.searchParams.get("code")).toBeTruthy();
|
||||
expect(redirectUrl.searchParams.get("state")).toBe("random-state-value");
|
||||
});
|
||||
|
||||
it("should reject unsupported response_type", async () => {
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "token",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: "test",
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("UNSUPPORTED_RESPONSE_TYPE");
|
||||
});
|
||||
|
||||
it("should reject plain HTTP redirect to non-localhost", async () => {
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://evil.com/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: "test",
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_REDIRECT_URI");
|
||||
});
|
||||
|
||||
it("should allow HTTPS redirects", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "https://myapp.example.com/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject plain code challenge method", async () => {
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: "test",
|
||||
code_challenge_method: "plain",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_REQUEST");
|
||||
});
|
||||
|
||||
it("should reject invalid scopes", async () => {
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "invalid:scope",
|
||||
code_challenge: "test",
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_SCOPE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization Code Exchange: Full Flow", () => {
|
||||
it("should exchange code for tokens with valid PKCE", async () => {
|
||||
// Step 1: Generate PKCE pair
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
// Step 2: Get authorization code
|
||||
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read content:write media:read",
|
||||
state: "state123",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
expect(approvalResult.success).toBe(true);
|
||||
if (!approvalResult.success) return;
|
||||
|
||||
const redirectUrl = new URL(approvalResult.data.redirect_url);
|
||||
const code = redirectUrl.searchParams.get("code")!;
|
||||
|
||||
// Step 3: Exchange code for tokens
|
||||
const exchangeResult = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
client_id: "test-client",
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
expect(exchangeResult.success).toBe(true);
|
||||
if (!exchangeResult.success) return;
|
||||
|
||||
expect(exchangeResult.data.access_token).toMatch(ACCESS_TOKEN_PREFIX_REGEX);
|
||||
expect(exchangeResult.data.refresh_token).toMatch(REFRESH_TOKEN_PREFIX_REGEX);
|
||||
expect(exchangeResult.data.token_type).toBe("Bearer");
|
||||
expect(exchangeResult.data.expires_in).toBe(3600);
|
||||
expect(exchangeResult.data.scope).toBe("content:read content:write media:read");
|
||||
|
||||
// Step 4: Verify tokens are stored
|
||||
const accessHash = hashApiToken(exchangeResult.data.access_token);
|
||||
const accessRow = await db
|
||||
.selectFrom("_emdash_oauth_tokens")
|
||||
.selectAll()
|
||||
.where("token_hash", "=", accessHash)
|
||||
.executeTakeFirst();
|
||||
expect(accessRow).toBeTruthy();
|
||||
expect(accessRow!.token_type).toBe("access");
|
||||
expect(accessRow!.user_id).toBe("user-1");
|
||||
expect(accessRow!.client_id).toBe("test-client");
|
||||
|
||||
// Step 5: Authorization code is consumed (single-use)
|
||||
const codeHash = hashApiToken(code);
|
||||
const codeRow = await db
|
||||
.selectFrom("_emdash_authorization_codes")
|
||||
.selectAll()
|
||||
.where("code_hash", "=", codeHash)
|
||||
.executeTakeFirst();
|
||||
expect(codeRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject wrong code verifier (PKCE failure)", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
expect(approvalResult.success).toBe(true);
|
||||
if (!approvalResult.success) return;
|
||||
|
||||
const redirectUrl = new URL(approvalResult.data.redirect_url);
|
||||
const code = redirectUrl.searchParams.get("code")!;
|
||||
|
||||
// Use a DIFFERENT code verifier
|
||||
const wrongVerifier = generateCodeVerifier();
|
||||
const exchangeResult = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
client_id: "test-client",
|
||||
code_verifier: wrongVerifier,
|
||||
});
|
||||
|
||||
expect(exchangeResult.success).toBe(false);
|
||||
if (exchangeResult.success) return;
|
||||
expect(exchangeResult.error.code).toBe("invalid_grant");
|
||||
expect(exchangeResult.error.message).toContain("PKCE");
|
||||
|
||||
// Code should be deleted after failed PKCE verification
|
||||
const codeHash = hashApiToken(code);
|
||||
const codeRow = await db
|
||||
.selectFrom("_emdash_authorization_codes")
|
||||
.selectAll()
|
||||
.where("code_hash", "=", codeHash)
|
||||
.executeTakeFirst();
|
||||
expect(codeRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject mismatched redirect_uri", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
expect(approvalResult.success).toBe(true);
|
||||
if (!approvalResult.success) return;
|
||||
|
||||
const redirectUrl = new URL(approvalResult.data.redirect_url);
|
||||
const code = redirectUrl.searchParams.get("code")!;
|
||||
|
||||
const exchangeResult = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:9999/different",
|
||||
client_id: "test-client",
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
expect(exchangeResult.success).toBe(false);
|
||||
if (exchangeResult.success) return;
|
||||
expect(exchangeResult.error.code).toBe("invalid_grant");
|
||||
expect(exchangeResult.error.message).toContain("redirect_uri");
|
||||
});
|
||||
|
||||
it("should reject mismatched client_id", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
expect(approvalResult.success).toBe(true);
|
||||
if (!approvalResult.success) return;
|
||||
|
||||
const redirectUrl = new URL(approvalResult.data.redirect_url);
|
||||
const code = redirectUrl.searchParams.get("code")!;
|
||||
|
||||
const exchangeResult = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
client_id: "different-client",
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
expect(exchangeResult.success).toBe(false);
|
||||
if (exchangeResult.success) return;
|
||||
expect(exchangeResult.error.code).toBe("invalid_grant");
|
||||
expect(exchangeResult.error.message).toContain("client_id");
|
||||
});
|
||||
|
||||
it("should reject expired authorization code", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
// Insert an expired code directly
|
||||
const code = generateCodeVerifier();
|
||||
const codeHash = hashApiToken(code);
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_authorization_codes")
|
||||
.values({
|
||||
code_hash: codeHash,
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
user_id: "user-1",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
resource: null,
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(), // Already expired
|
||||
})
|
||||
.execute();
|
||||
|
||||
const exchangeResult = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
client_id: "test-client",
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
expect(exchangeResult.success).toBe(false);
|
||||
if (exchangeResult.success) return;
|
||||
expect(exchangeResult.error.code).toBe("invalid_grant");
|
||||
expect(exchangeResult.error.message).toContain("expired");
|
||||
});
|
||||
|
||||
it("should reject code reuse (single-use enforcement)", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
expect(approvalResult.success).toBe(true);
|
||||
if (!approvalResult.success) return;
|
||||
|
||||
const redirectUrl = new URL(approvalResult.data.redirect_url);
|
||||
const code = redirectUrl.searchParams.get("code")!;
|
||||
|
||||
// First exchange succeeds
|
||||
const first = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
client_id: "test-client",
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
expect(first.success).toBe(true);
|
||||
|
||||
// Second exchange with same code fails
|
||||
const second = await handleAuthorizationCodeExchange(db, {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
client_id: "test-client",
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
expect(second.success).toBe(false);
|
||||
if (second.success) return;
|
||||
expect(second.error.code).toBe("invalid_grant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDeniedRedirect", () => {
|
||||
it("should include error and state params", () => {
|
||||
const url = buildDeniedRedirect("http://127.0.0.1:8080/callback", "state123");
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.searchParams.get("error")).toBe("access_denied");
|
||||
expect(parsed.searchParams.get("error_description")).toBeTruthy();
|
||||
expect(parsed.searchParams.get("state")).toBe("state123");
|
||||
});
|
||||
|
||||
it("should omit state when not provided", () => {
|
||||
const url = buildDeniedRedirect("http://127.0.0.1:8080/callback");
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.searchParams.get("error")).toBe("access_denied");
|
||||
expect(parsed.searchParams.has("state")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupExpiredAuthorizationCodes", () => {
|
||||
it("should delete expired codes", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
// Insert an expired code
|
||||
await db
|
||||
.insertInto("_emdash_authorization_codes")
|
||||
.values({
|
||||
code_hash: "expired-hash",
|
||||
client_id: "test",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
user_id: "user-1",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
resource: null,
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Insert a valid code
|
||||
await db
|
||||
.insertInto("_emdash_authorization_codes")
|
||||
.values({
|
||||
code_hash: "valid-hash",
|
||||
client_id: "test",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
user_id: "user-1",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
resource: null,
|
||||
expires_at: new Date(Date.now() + 600000).toISOString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const deleted = await cleanupExpiredAuthorizationCodes(db);
|
||||
expect(deleted).toBe(1);
|
||||
|
||||
// Valid code should remain
|
||||
const remaining = await db.selectFrom("_emdash_authorization_codes").selectAll().execute();
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.code_hash).toBe("valid-hash");
|
||||
});
|
||||
});
|
||||
584
packages/core/tests/integration/auth/device-flow.test.ts
Normal file
584
packages/core/tests/integration/auth/device-flow.test.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Integration tests for OAuth Device Flow handlers.
|
||||
*
|
||||
* Tests the full device flow lifecycle against a real in-memory SQLite database.
|
||||
*/
|
||||
|
||||
import { Role } from "@emdashcms/auth";
|
||||
import type { RoleLevel } from "@emdashcms/auth";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
handleDeviceAuthorize,
|
||||
handleDeviceCodeRequest,
|
||||
handleDeviceTokenExchange,
|
||||
handleTokenRefresh,
|
||||
handleTokenRevoke,
|
||||
} from "../../../src/api/handlers/device-flow.js";
|
||||
import { hashApiToken } from "../../../src/auth/api-tokens.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
const USER_CODE_FORMAT_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
||||
const ACCESS_TOKEN_PREFIX_REGEX = /^ec_oat_/;
|
||||
const REFRESH_TOKEN_PREFIX_REGEX = /^ec_ort_/;
|
||||
const HYPHEN_REGEX = /-/g;
|
||||
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
|
||||
// Create a test user
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("Device Code Request", () => {
|
||||
it("should create a device code with default scopes", async () => {
|
||||
const result = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ client_id: "emdash-cli" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
expect(result.data.device_code).toBeTruthy();
|
||||
expect(result.data.user_code).toMatch(USER_CODE_FORMAT_REGEX);
|
||||
expect(result.data.verification_uri).toBe("https://example.com/_emdash/device");
|
||||
expect(result.data.expires_in).toBe(900); // 15 minutes
|
||||
expect(result.data.interval).toBe(5);
|
||||
});
|
||||
|
||||
it("should create a device code with custom scopes", async () => {
|
||||
const result = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ scope: "content:read media:read" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
// Verify scopes were stored
|
||||
const row = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.selectAll()
|
||||
.where("device_code", "=", result.data.device_code)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect(JSON.parse(row!.scopes)).toEqual(["content:read", "media:read"]);
|
||||
});
|
||||
|
||||
it("should reject invalid scopes", async () => {
|
||||
const result = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ scope: "invalid:scope" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_SCOPE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Device Flow: Full Lifecycle", () => {
|
||||
it("should complete the full device flow: code → authorize → exchange", async () => {
|
||||
// Step 1: Request device code
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ client_id: "emdash-cli" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
const { device_code, user_code } = codeResult.data;
|
||||
|
||||
// Step 2: Poll before authorization → pending
|
||||
const pendingResult = await handleDeviceTokenExchange(db, {
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
expect(pendingResult.success).toBe(false);
|
||||
expect(pendingResult.deviceFlowError).toBe("authorization_pending");
|
||||
|
||||
// Step 3: User authorizes (admin role = 50)
|
||||
const authResult = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code,
|
||||
});
|
||||
expect(authResult.success).toBe(true);
|
||||
if (!authResult.success) return;
|
||||
expect(authResult.data.authorized).toBe(true);
|
||||
|
||||
// Step 4: Exchange for tokens
|
||||
const tokenResult = await handleDeviceTokenExchange(db, {
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
expect(tokenResult.success).toBe(true);
|
||||
if (!tokenResult.success) return;
|
||||
|
||||
expect(tokenResult.data.access_token).toMatch(ACCESS_TOKEN_PREFIX_REGEX);
|
||||
expect(tokenResult.data.refresh_token).toMatch(REFRESH_TOKEN_PREFIX_REGEX);
|
||||
expect(tokenResult.data.token_type).toBe("Bearer");
|
||||
expect(tokenResult.data.expires_in).toBe(3600);
|
||||
expect(tokenResult.data.scope).toBeTruthy();
|
||||
|
||||
// Step 5: Device code should be consumed
|
||||
const row = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.selectAll()
|
||||
.where("device_code", "=", device_code)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeUndefined();
|
||||
|
||||
// Step 6: Tokens should be stored
|
||||
const accessHash = hashApiToken(tokenResult.data.access_token);
|
||||
const accessRow = await db
|
||||
.selectFrom("_emdash_oauth_tokens")
|
||||
.selectAll()
|
||||
.where("token_hash", "=", accessHash)
|
||||
.executeTakeFirst();
|
||||
expect(accessRow).toBeTruthy();
|
||||
expect(accessRow!.token_type).toBe("access");
|
||||
expect(accessRow!.user_id).toBe("user-1");
|
||||
|
||||
const refreshHash = hashApiToken(tokenResult.data.refresh_token);
|
||||
const refreshRow = await db
|
||||
.selectFrom("_emdash_oauth_tokens")
|
||||
.selectAll()
|
||||
.where("token_hash", "=", refreshHash)
|
||||
.executeTakeFirst();
|
||||
expect(refreshRow).toBeTruthy();
|
||||
expect(refreshRow!.token_type).toBe("refresh");
|
||||
});
|
||||
|
||||
it("should handle denied authorization", async () => {
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{},
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
// User denies
|
||||
const authResult = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: codeResult.data.user_code,
|
||||
action: "deny",
|
||||
});
|
||||
expect(authResult.success).toBe(true);
|
||||
if (!authResult.success) return;
|
||||
expect(authResult.data.authorized).toBe(false);
|
||||
|
||||
// Exchange should return access_denied
|
||||
const tokenResult = await handleDeviceTokenExchange(db, {
|
||||
device_code: codeResult.data.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
expect(tokenResult.success).toBe(false);
|
||||
expect(tokenResult.deviceFlowError).toBe("access_denied");
|
||||
});
|
||||
|
||||
it("should normalize user codes (strip hyphens, case-insensitive)", async () => {
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{},
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
// Submit lowercase without hyphen
|
||||
const code = codeResult.data.user_code.replace(HYPHEN_REGEX, "").toLowerCase();
|
||||
const authResult = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: code,
|
||||
});
|
||||
expect(authResult.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Device Token Exchange: Error Cases", () => {
|
||||
it("should reject invalid grant_type", async () => {
|
||||
const result = await handleDeviceTokenExchange(db, {
|
||||
device_code: "whatever",
|
||||
grant_type: "invalid",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("UNSUPPORTED_GRANT_TYPE");
|
||||
});
|
||||
|
||||
it("should reject unknown device codes", async () => {
|
||||
const result = await handleDeviceTokenExchange(db, {
|
||||
device_code: "nonexistent",
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_GRANT");
|
||||
});
|
||||
|
||||
it("should report expired device codes", async () => {
|
||||
// Create a device code that's already expired
|
||||
await db
|
||||
.insertInto("_emdash_device_codes")
|
||||
.values({
|
||||
device_code: "expired-code",
|
||||
user_code: "AAAA-BBBB",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
status: "pending",
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(),
|
||||
interval: 5,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const result = await handleDeviceTokenExchange(db, {
|
||||
device_code: "expired-code",
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.deviceFlowError).toBe("expired_token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Refresh", () => {
|
||||
it("should exchange a refresh token for a new access token", async () => {
|
||||
// Complete a device flow first to get tokens
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{},
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: codeResult.data.user_code,
|
||||
});
|
||||
|
||||
const tokenResult = await handleDeviceTokenExchange(db, {
|
||||
device_code: codeResult.data.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
expect(tokenResult.success).toBe(true);
|
||||
if (!tokenResult.success) return;
|
||||
|
||||
// Refresh
|
||||
const refreshResult = await handleTokenRefresh(db, {
|
||||
refresh_token: tokenResult.data.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
expect(refreshResult.success).toBe(true);
|
||||
if (!refreshResult.success) return;
|
||||
|
||||
expect(refreshResult.data.access_token).toMatch(ACCESS_TOKEN_PREFIX_REGEX);
|
||||
expect(refreshResult.data.access_token).not.toBe(tokenResult.data.access_token);
|
||||
expect(refreshResult.data.refresh_token).toBe(tokenResult.data.refresh_token);
|
||||
expect(refreshResult.data.expires_in).toBe(3600);
|
||||
});
|
||||
|
||||
it("should reject invalid refresh tokens", async () => {
|
||||
const result = await handleTokenRefresh(db, {
|
||||
refresh_token: "ec_ort_invalid",
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_GRANT");
|
||||
});
|
||||
|
||||
it("should reject wrong grant_type", async () => {
|
||||
const result = await handleTokenRefresh(db, {
|
||||
refresh_token: "ec_ort_whatever",
|
||||
grant_type: "authorization_code",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("UNSUPPORTED_GRANT_TYPE");
|
||||
});
|
||||
|
||||
it("should reject wrong token prefix", async () => {
|
||||
const result = await handleTokenRefresh(db, {
|
||||
refresh_token: "ec_pat_notarefresh",
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_GRANT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Revoke", () => {
|
||||
it("should revoke an access token", async () => {
|
||||
// Get tokens via device flow
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{},
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: codeResult.data.user_code,
|
||||
});
|
||||
|
||||
const tokenResult = await handleDeviceTokenExchange(db, {
|
||||
device_code: codeResult.data.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
if (!tokenResult.success) return;
|
||||
|
||||
// Revoke the access token
|
||||
const revokeResult = await handleTokenRevoke(db, {
|
||||
token: tokenResult.data.access_token,
|
||||
});
|
||||
expect(revokeResult.success).toBe(true);
|
||||
|
||||
// Access token should be gone
|
||||
const accessHash = hashApiToken(tokenResult.data.access_token);
|
||||
const row = await db
|
||||
.selectFrom("_emdash_oauth_tokens")
|
||||
.selectAll()
|
||||
.where("token_hash", "=", accessHash)
|
||||
.executeTakeFirst();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should revoke a refresh token and its access tokens", async () => {
|
||||
// Get tokens via device flow
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{},
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: codeResult.data.user_code,
|
||||
});
|
||||
|
||||
const tokenResult = await handleDeviceTokenExchange(db, {
|
||||
device_code: codeResult.data.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
if (!tokenResult.success) return;
|
||||
|
||||
// Revoke the refresh token
|
||||
const revokeResult = await handleTokenRevoke(db, {
|
||||
token: tokenResult.data.refresh_token,
|
||||
});
|
||||
expect(revokeResult.success).toBe(true);
|
||||
|
||||
// Both tokens should be gone
|
||||
const tokenCount = await db
|
||||
.selectFrom("_emdash_oauth_tokens")
|
||||
.select(db.fn.count("token_hash").as("count"))
|
||||
.executeTakeFirst();
|
||||
expect(Number(tokenCount?.count ?? 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("should return success for unknown tokens (RFC 7009)", async () => {
|
||||
const result = await handleTokenRevoke(db, {
|
||||
token: "ec_oat_nonexistent",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Device Authorize: Error Cases", () => {
|
||||
it("should reject invalid user codes", async () => {
|
||||
const result = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: "INVALID-CODE",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_CODE");
|
||||
});
|
||||
|
||||
it("should reject expired device codes", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_device_codes")
|
||||
.values({
|
||||
device_code: "expired-dc",
|
||||
user_code: "CCCC-DDDD",
|
||||
scopes: JSON.stringify(["content:read"]),
|
||||
status: "pending",
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(),
|
||||
interval: 5,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const result = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
|
||||
user_code: "CCCC-DDDD",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("EXPIRED_CODE");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope escalation prevention (SEC: CWE-269)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Scope Clamping: Role-based scope restriction", () => {
|
||||
/** Helper: run a full device flow with given requested scopes and user role */
|
||||
async function completeDeviceFlow(
|
||||
requestedScopes: string,
|
||||
userRole: RoleLevel,
|
||||
): Promise<{ scopes: string; success: boolean }> {
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ scope: requestedScopes },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
if (!codeResult.success) return { scopes: "", success: false };
|
||||
|
||||
const authResult = await handleDeviceAuthorize(db, "user-1", userRole, {
|
||||
user_code: codeResult.data.user_code,
|
||||
});
|
||||
if (!authResult.success) return { scopes: "", success: false };
|
||||
|
||||
const tokenResult = await handleDeviceTokenExchange(db, {
|
||||
device_code: codeResult.data.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
});
|
||||
if (!tokenResult.success) return { scopes: "", success: false };
|
||||
|
||||
return { scopes: tokenResult.data.scope, success: true };
|
||||
}
|
||||
|
||||
it("should strip admin scope from non-admin user tokens", async () => {
|
||||
// CONTRIBUTOR requests admin scope — this is the core attack scenario
|
||||
const result = await completeDeviceFlow("content:read content:write admin", Role.CONTRIBUTOR);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const scopes = result.scopes.split(" ");
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("content:write");
|
||||
expect(scopes).not.toContain("admin");
|
||||
});
|
||||
|
||||
it("should strip schema:write from non-admin user tokens", async () => {
|
||||
// EDITOR requests schema:write — only ADMIN gets schema:write
|
||||
const result = await completeDeviceFlow("content:read schema:read schema:write", Role.EDITOR);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const scopes = result.scopes.split(" ");
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("schema:read");
|
||||
expect(scopes).not.toContain("schema:write");
|
||||
});
|
||||
|
||||
it("should strip schema:read from contributor tokens", async () => {
|
||||
// CONTRIBUTOR requests schema:read — only EDITOR+ gets schema:read
|
||||
const result = await completeDeviceFlow("content:read schema:read", Role.CONTRIBUTOR);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const scopes = result.scopes.split(" ");
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).not.toContain("schema:read");
|
||||
});
|
||||
|
||||
it("should allow admin user to get all scopes", async () => {
|
||||
const result = await completeDeviceFlow(
|
||||
"content:read content:write media:read media:write schema:read schema:write admin",
|
||||
Role.ADMIN,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const scopes = result.scopes.split(" ");
|
||||
expect(scopes).toContain("admin");
|
||||
expect(scopes).toContain("schema:write");
|
||||
expect(scopes).toContain("content:write");
|
||||
});
|
||||
|
||||
it("should return INSUFFICIENT_ROLE when no scopes survive clamping", async () => {
|
||||
// SUBSCRIBER requests only admin scope — nothing survives
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ scope: "admin schema:write" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
const authResult = await handleDeviceAuthorize(db, "user-1", Role.SUBSCRIBER, {
|
||||
user_code: codeResult.data.user_code,
|
||||
});
|
||||
expect(authResult.success).toBe(false);
|
||||
if (authResult.success) return;
|
||||
expect(authResult.error.code).toBe("INSUFFICIENT_ROLE");
|
||||
});
|
||||
|
||||
it("should clamp scopes in stored device code at authorize time", async () => {
|
||||
// Verify that the stored scopes are clamped, not just the response
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ scope: "content:read content:write schema:write admin" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
// Before authorize: scopes include admin and schema:write
|
||||
const beforeRow = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.selectAll()
|
||||
.where("device_code", "=", codeResult.data.device_code)
|
||||
.executeTakeFirst();
|
||||
expect(JSON.parse(beforeRow!.scopes)).toContain("admin");
|
||||
expect(JSON.parse(beforeRow!.scopes)).toContain("schema:write");
|
||||
|
||||
// Authorize as CONTRIBUTOR — admin and schema:write must be stripped
|
||||
await handleDeviceAuthorize(db, "user-1", Role.CONTRIBUTOR, {
|
||||
user_code: codeResult.data.user_code,
|
||||
});
|
||||
|
||||
// After authorize: scopes should be clamped in DB
|
||||
const afterRow = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.selectAll()
|
||||
.where("device_code", "=", codeResult.data.device_code)
|
||||
.executeTakeFirst();
|
||||
const storedScopes = JSON.parse(afterRow!.scopes) as string[];
|
||||
expect(storedScopes).toContain("content:read");
|
||||
expect(storedScopes).toContain("content:write");
|
||||
expect(storedScopes).not.toContain("admin");
|
||||
expect(storedScopes).not.toContain("schema:write");
|
||||
});
|
||||
|
||||
it("should allow editor to get content + media + schema:read scopes", async () => {
|
||||
const result = await completeDeviceFlow(
|
||||
"content:read content:write media:read media:write schema:read",
|
||||
Role.EDITOR,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const scopes = result.scopes.split(" ");
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("content:write");
|
||||
expect(scopes).toContain("media:read");
|
||||
expect(scopes).toContain("media:write");
|
||||
expect(scopes).toContain("schema:read");
|
||||
});
|
||||
});
|
||||
342
packages/core/tests/integration/auth/oauth-clients.test.ts
Normal file
342
packages/core/tests/integration/auth/oauth-clients.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Integration tests for OAuth client management and redirect URI allowlist.
|
||||
*
|
||||
* Tests that the authorization endpoint rejects unregistered clients and
|
||||
* redirect URIs not in the client's registered set.
|
||||
*/
|
||||
|
||||
import { computeS256Challenge, Role } from "@emdashcms/auth";
|
||||
import { generateCodeVerifier } from "arctic";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { handleAuthorizationApproval } from "../../../src/api/handlers/oauth-authorization.js";
|
||||
import {
|
||||
handleOAuthClientCreate,
|
||||
handleOAuthClientDelete,
|
||||
handleOAuthClientGet,
|
||||
handleOAuthClientList,
|
||||
handleOAuthClientUpdate,
|
||||
lookupOAuthClient,
|
||||
validateClientRedirectUri,
|
||||
} from "../../../src/api/handlers/oauth-clients.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
|
||||
// Create a test user
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateClientRedirectUri (unit-level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateClientRedirectUri", () => {
|
||||
it("should return null for a registered redirect URI", () => {
|
||||
const result = validateClientRedirectUri("https://myapp.example.com/callback", [
|
||||
"https://myapp.example.com/callback",
|
||||
"http://127.0.0.1:8080/callback",
|
||||
]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error for an unregistered redirect URI", () => {
|
||||
const result = validateClientRedirectUri("https://evil.com/callback", [
|
||||
"https://myapp.example.com/callback",
|
||||
]);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should require exact match (no prefix matching)", () => {
|
||||
const result = validateClientRedirectUri("https://myapp.example.com/callback/extra", [
|
||||
"https://myapp.example.com/callback",
|
||||
]);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should require exact match (no query string tolerance)", () => {
|
||||
const result = validateClientRedirectUri("https://myapp.example.com/callback?foo=bar", [
|
||||
"https://myapp.example.com/callback",
|
||||
]);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth Client CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("OAuth Client CRUD", () => {
|
||||
it("should create a client", async () => {
|
||||
const result = await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.data.id).toBe("test-client");
|
||||
expect(result.data.name).toBe("Test Client");
|
||||
expect(result.data.redirectUris).toEqual(["https://myapp.example.com/callback"]);
|
||||
});
|
||||
|
||||
it("should reject duplicate client IDs", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback"],
|
||||
});
|
||||
|
||||
const result = await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Duplicate Client",
|
||||
redirectUris: ["https://other.example.com/callback"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("CONFLICT");
|
||||
});
|
||||
|
||||
it("should reject clients with empty redirect URIs", async () => {
|
||||
const result = await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should list clients", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "client-1",
|
||||
name: "Client 1",
|
||||
redirectUris: ["https://one.example.com/callback"],
|
||||
});
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "client-2",
|
||||
name: "Client 2",
|
||||
redirectUris: ["https://two.example.com/callback"],
|
||||
});
|
||||
|
||||
const result = await handleOAuthClientList(db);
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.data.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should get a client by ID", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback"],
|
||||
scopes: ["content:read"],
|
||||
});
|
||||
|
||||
const result = await handleOAuthClientGet(db, "test-client");
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.data.id).toBe("test-client");
|
||||
expect(result.data.scopes).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("should return NOT_FOUND for unknown client", async () => {
|
||||
const result = await handleOAuthClientGet(db, "unknown");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("should update a client", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback"],
|
||||
});
|
||||
|
||||
const result = await handleOAuthClientUpdate(db, "test-client", {
|
||||
name: "Updated Client",
|
||||
redirectUris: ["https://myapp.example.com/callback", "https://myapp.example.com/callback2"],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.data.name).toBe("Updated Client");
|
||||
expect(result.data.redirectUris).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should reject update with empty redirect URIs", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback"],
|
||||
});
|
||||
|
||||
const result = await handleOAuthClientUpdate(db, "test-client", {
|
||||
redirectUris: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("should delete a client", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback"],
|
||||
});
|
||||
|
||||
const result = await handleOAuthClientDelete(db, "test-client");
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const getResult = await handleOAuthClientGet(db, "test-client");
|
||||
expect(getResult.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should return NOT_FOUND when deleting unknown client", async () => {
|
||||
const result = await handleOAuthClientDelete(db, "unknown");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lookupOAuthClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("lookupOAuthClient", () => {
|
||||
it("should return redirect URIs for a registered client", async () => {
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["https://myapp.example.com/callback", "http://127.0.0.1:8080/callback"],
|
||||
});
|
||||
|
||||
const client = await lookupOAuthClient(db, "test-client");
|
||||
expect(client).toBeTruthy();
|
||||
expect(client!.redirectUris).toEqual([
|
||||
"https://myapp.example.com/callback",
|
||||
"http://127.0.0.1:8080/callback",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return null for an unregistered client", async () => {
|
||||
const client = await lookupOAuthClient(db, "unknown-client");
|
||||
expect(client).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization with client redirect URI validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Authorization with redirect URI allowlist", () => {
|
||||
beforeEach(async () => {
|
||||
// Register a client with specific redirect URIs
|
||||
await handleOAuthClientCreate(db, {
|
||||
id: "test-client",
|
||||
name: "Test Client",
|
||||
redirectUris: ["http://127.0.0.1:8080/callback", "https://myapp.example.com/callback"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should approve authorization with a registered redirect URI", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read content:write",
|
||||
state: "random-state-value",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
|
||||
const redirectUrl = new URL(result.data.redirect_url);
|
||||
expect(redirectUrl.origin).toBe("http://127.0.0.1:8080");
|
||||
expect(redirectUrl.searchParams.get("code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should reject authorization with unregistered redirect URI", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "https://evil.example.com/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_REDIRECT_URI");
|
||||
expect(result.error.message).toContain("not registered");
|
||||
});
|
||||
|
||||
it("should reject authorization with unknown client_id", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "unknown-client",
|
||||
redirect_uri: "http://127.0.0.1:8080/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.code).toBe("INVALID_CLIENT");
|
||||
});
|
||||
|
||||
it("should accept HTTPS redirect URI in allowlist", async () => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = computeS256Challenge(codeVerifier);
|
||||
|
||||
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
|
||||
response_type: "code",
|
||||
client_id: "test-client",
|
||||
redirect_uri: "https://myapp.example.com/callback",
|
||||
scope: "content:read",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
338
packages/core/tests/integration/auth/rate-limit.test.ts
Normal file
338
packages/core/tests/integration/auth/rate-limit.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Integration tests for database-backed rate limiting.
|
||||
*
|
||||
* Tests the rate limiter utility and slow_down enforcement
|
||||
* against a real in-memory SQLite database.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
handleDeviceCodeRequest,
|
||||
handleDeviceTokenExchange,
|
||||
} from "../../../src/api/handlers/device-flow.js";
|
||||
import {
|
||||
checkRateLimit,
|
||||
cleanupExpiredRateLimits,
|
||||
getClientIp,
|
||||
} from "../../../src/auth/rate-limit.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate Limiter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
it("should allow requests within the limit", async () => {
|
||||
const result1 = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
expect(result1.allowed).toBe(true);
|
||||
expect(result1.count).toBe(1);
|
||||
|
||||
const result2 = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
expect(result2.allowed).toBe(true);
|
||||
expect(result2.count).toBe(2);
|
||||
|
||||
const result3 = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
expect(result3.allowed).toBe(true);
|
||||
expect(result3.count).toBe(3);
|
||||
});
|
||||
|
||||
it("should reject requests exceeding the limit", async () => {
|
||||
// Use up the limit
|
||||
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
|
||||
// 4th request should be rejected
|
||||
const result = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.count).toBe(4);
|
||||
expect(result.limit).toBe(3);
|
||||
});
|
||||
|
||||
it("should track limits per IP independently", async () => {
|
||||
// IP A uses its limit
|
||||
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 2, 60);
|
||||
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 2, 60);
|
||||
const resultA = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 2, 60);
|
||||
expect(resultA.allowed).toBe(false);
|
||||
|
||||
// IP B should still be allowed
|
||||
const resultB = await checkRateLimit(db, "5.6.7.8", "test/endpoint", 2, 60);
|
||||
expect(resultB.allowed).toBe(true);
|
||||
expect(resultB.count).toBe(1);
|
||||
});
|
||||
|
||||
it("should track limits per endpoint independently", async () => {
|
||||
// Use up limit on endpoint A
|
||||
await checkRateLimit(db, "1.2.3.4", "endpoint-a", 1, 60);
|
||||
const resultA = await checkRateLimit(db, "1.2.3.4", "endpoint-a", 1, 60);
|
||||
expect(resultA.allowed).toBe(false);
|
||||
|
||||
// Endpoint B should still be allowed
|
||||
const resultB = await checkRateLimit(db, "1.2.3.4", "endpoint-b", 1, 60);
|
||||
expect(resultB.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("should skip rate limiting when IP is null", async () => {
|
||||
// Even after many calls, null IP is always allowed
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = await checkRateLimit(db, null, "test/endpoint", 1, 60);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.count).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reset after window expires", async () => {
|
||||
// Use a 1-second window
|
||||
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 1, 1);
|
||||
const blocked = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 1, 1);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
|
||||
// Wait for the window to expire (advance past the 1-second boundary)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const allowed = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 1, 1);
|
||||
expect(allowed.allowed).toBe(true);
|
||||
expect(allowed.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IP Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getClientIp", () => {
|
||||
/** Create a request with a fake `cf` object to simulate Cloudflare. */
|
||||
function cfRequest(url: string, init?: RequestInit): Request {
|
||||
const req = new Request(url, init);
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test helper
|
||||
(req as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
|
||||
return req;
|
||||
}
|
||||
|
||||
it("should extract IP from CF-Connecting-IP on Cloudflare", () => {
|
||||
const request = cfRequest("http://localhost/test", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.1" },
|
||||
});
|
||||
expect(getClientIp(request)).toBe("198.51.100.1");
|
||||
});
|
||||
|
||||
it("should extract IP from X-Forwarded-For on Cloudflare", () => {
|
||||
const request = cfRequest("http://localhost/test", {
|
||||
headers: { "x-forwarded-for": "203.0.113.50, 70.41.3.18, 150.172.238.178" },
|
||||
});
|
||||
expect(getClientIp(request)).toBe("203.0.113.50");
|
||||
});
|
||||
|
||||
it("should return null when not on Cloudflare (no cf object)", () => {
|
||||
const request = new Request("http://localhost/test");
|
||||
expect(getClientIp(request)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when not on Cloudflare even with XFF header", () => {
|
||||
const request = new Request("http://localhost/test", {
|
||||
headers: { "x-forwarded-for": "203.0.113.50" },
|
||||
});
|
||||
expect(getClientIp(request)).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject non-IP values in X-Forwarded-For", () => {
|
||||
const request = cfRequest("http://localhost/test", {
|
||||
headers: { "x-forwarded-for": "<script>alert(1)</script>" },
|
||||
});
|
||||
expect(getClientIp(request)).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle IPv6 addresses on Cloudflare", () => {
|
||||
const request = cfRequest("http://localhost/test", {
|
||||
headers: { "x-forwarded-for": "2001:db8::1" },
|
||||
});
|
||||
expect(getClientIp(request)).toBe("2001:db8::1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cleanupExpiredRateLimits", () => {
|
||||
it("should delete expired entries", async () => {
|
||||
// Insert a rate limit entry with a window in the past
|
||||
const oldWindow = new Date(Date.now() - 7200 * 1000).toISOString();
|
||||
const currentWindow = new Date(Math.floor(Date.now() / (60 * 1000)) * 60 * 1000).toISOString();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_rate_limits")
|
||||
.values([
|
||||
{ key: "old:entry", window: oldWindow, count: 5 },
|
||||
{ key: "current:entry", window: currentWindow, count: 2 },
|
||||
])
|
||||
.execute();
|
||||
|
||||
const deleted = await cleanupExpiredRateLimits(db, 3600);
|
||||
expect(deleted).toBe(1);
|
||||
|
||||
// Current entry should still exist
|
||||
const rows = await db.selectFrom("_emdash_rate_limits").selectAll().execute();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.key).toBe("current:entry");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RFC 8628 slow_down
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Device Token Exchange: slow_down enforcement", () => {
|
||||
const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
it("should return slow_down when polling faster than interval", async () => {
|
||||
// Create a device code
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ client_id: "emdash-cli" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
const { device_code } = codeResult.data;
|
||||
|
||||
// First poll — sets last_polled_at, returns authorization_pending
|
||||
const poll1 = await handleDeviceTokenExchange(db, {
|
||||
device_code,
|
||||
grant_type: GRANT_TYPE,
|
||||
});
|
||||
expect(poll1.success).toBe(false);
|
||||
expect(poll1.deviceFlowError).toBe("authorization_pending");
|
||||
|
||||
// Second poll immediately — should get slow_down with new interval
|
||||
const poll2 = await handleDeviceTokenExchange(db, {
|
||||
device_code,
|
||||
grant_type: GRANT_TYPE,
|
||||
});
|
||||
expect(poll2.success).toBe(false);
|
||||
expect(poll2.deviceFlowError).toBe("slow_down");
|
||||
// Default interval (5) + SLOW_DOWN_INCREMENT (5) = 10
|
||||
expect(poll2.deviceFlowInterval).toBe(10);
|
||||
});
|
||||
|
||||
it("should increase interval by 5s on each slow_down", async () => {
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ client_id: "emdash-cli" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
const { device_code } = codeResult.data;
|
||||
|
||||
// First poll — sets baseline
|
||||
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
|
||||
|
||||
// Rapid polls — each should trigger slow_down and increase interval
|
||||
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
|
||||
|
||||
// Check the interval was increased
|
||||
const row = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.select("interval")
|
||||
.where("device_code", "=", device_code)
|
||||
.executeTakeFirst();
|
||||
|
||||
// Default interval is 5, after one slow_down it should be 10
|
||||
expect(row?.interval).toBe(10);
|
||||
|
||||
// Another rapid poll — interval should increase again to 15
|
||||
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
|
||||
|
||||
const row2 = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.select("interval")
|
||||
.where("device_code", "=", device_code)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row2?.interval).toBe(15);
|
||||
});
|
||||
|
||||
it("should cap slow_down interval at 60 seconds", async () => {
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ client_id: "emdash-cli" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
const { device_code } = codeResult.data;
|
||||
|
||||
// First poll — sets baseline
|
||||
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
|
||||
|
||||
// Set interval to just below the cap so the next slow_down would exceed it
|
||||
await db
|
||||
.updateTable("_emdash_device_codes")
|
||||
.set({ interval: 58 })
|
||||
.where("device_code", "=", device_code)
|
||||
.execute();
|
||||
|
||||
// Rapid poll — triggers slow_down, interval should cap at 60 not 63
|
||||
const poll = await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
|
||||
expect(poll.deviceFlowInterval).toBe(60);
|
||||
|
||||
const row = await db
|
||||
.selectFrom("_emdash_device_codes")
|
||||
.select("interval")
|
||||
.where("device_code", "=", device_code)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row?.interval).toBe(60);
|
||||
});
|
||||
|
||||
it("should not return slow_down when polling at or above the interval", async () => {
|
||||
const codeResult = await handleDeviceCodeRequest(
|
||||
db,
|
||||
{ client_id: "emdash-cli" },
|
||||
"https://example.com/_emdash/device",
|
||||
);
|
||||
expect(codeResult.success).toBe(true);
|
||||
if (!codeResult.success) return;
|
||||
|
||||
const { device_code } = codeResult.data;
|
||||
|
||||
// First poll — sets last_polled_at
|
||||
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
|
||||
|
||||
// Manually set last_polled_at to far enough in the past
|
||||
await db
|
||||
.updateTable("_emdash_device_codes")
|
||||
.set({
|
||||
last_polled_at: new Date(Date.now() - 10_000).toISOString(),
|
||||
})
|
||||
.where("device_code", "=", device_code)
|
||||
.execute();
|
||||
|
||||
// This poll should NOT get slow_down (10s > 5s interval)
|
||||
const poll = await handleDeviceTokenExchange(db, {
|
||||
device_code,
|
||||
grant_type: GRANT_TYPE,
|
||||
});
|
||||
expect(poll.success).toBe(false);
|
||||
// Should be authorization_pending, not slow_down
|
||||
expect(poll.deviceFlowError).toBe("authorization_pending");
|
||||
});
|
||||
});
|
||||
316
packages/core/tests/integration/cli/cli.test.ts
Normal file
316
packages/core/tests/integration/cli/cli.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* E2E tests for CLI commands against a real Astro dev server.
|
||||
*
|
||||
* Shells out to the actual `emdash` binary with --url and --token
|
||||
* flags, verifying real command output and exit codes.
|
||||
*
|
||||
* Runs by default. Requires built artifacts (auto-builds if missing).
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import type { TestServerContext } from "../server.js";
|
||||
import { assertNodeVersion, createTestServer } from "../server.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const PORT = 4398; // Different port from client integration tests
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
// Path to the built CLI binary
|
||||
const CLI_BIN = resolve(import.meta.dirname, "../../../dist/cli/index.mjs");
|
||||
|
||||
describe("CLI Integration", () => {
|
||||
let ctx: TestServerContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertNodeVersion();
|
||||
ctx = await createTestServer({ port: PORT });
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await ctx?.cleanup();
|
||||
});
|
||||
|
||||
/** Run an emdash CLI command and return stdout */
|
||||
async function cli(...args: string[]): Promise<string> {
|
||||
const { stdout } = await exec(
|
||||
"node",
|
||||
[CLI_BIN, ...args, "--url", ctx.baseUrl, "--token", ctx.token, "--json"],
|
||||
{
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/** Run CLI and parse JSON output */
|
||||
async function cliJson<T = unknown>(...args: string[]): Promise<T> {
|
||||
const stdout = await cli(...args);
|
||||
return JSON.parse(stdout) as T;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Schema commands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("schema", () => {
|
||||
it("lists collections", async () => {
|
||||
const result = await cliJson<{ slug: string }[]>("schema", "list");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const slugs = result.map((c) => c.slug);
|
||||
expect(slugs).toContain("posts");
|
||||
expect(slugs).toContain("pages");
|
||||
});
|
||||
|
||||
it("gets a single collection", async () => {
|
||||
const result = await cliJson<{ slug: string; label: string }>("schema", "get", "posts");
|
||||
expect(result.slug).toBe("posts");
|
||||
expect(result.label).toBe("Posts");
|
||||
});
|
||||
|
||||
it("creates and deletes a collection", async () => {
|
||||
const created = await cliJson<{ slug: string }>(
|
||||
"schema",
|
||||
"create",
|
||||
"cli_temp",
|
||||
"--label",
|
||||
"CLI Temp",
|
||||
);
|
||||
expect(created.slug).toBe("cli_temp");
|
||||
|
||||
// Verify it exists
|
||||
const list = await cliJson<{ slug: string }[]>("schema", "list");
|
||||
expect(list.map((c) => c.slug)).toContain("cli_temp");
|
||||
|
||||
// Delete
|
||||
await cli("schema", "delete", "cli_temp", "--force");
|
||||
|
||||
// Verify it's gone
|
||||
const listAfter = await cliJson<{ slug: string }[]>("schema", "list");
|
||||
expect(listAfter.map((c) => c.slug)).not.toContain("cli_temp");
|
||||
});
|
||||
|
||||
it("adds and removes fields", async () => {
|
||||
// Create a temp collection
|
||||
await cli("schema", "create", "cli_fields", "--label", "Fields Test");
|
||||
|
||||
// Add a field
|
||||
const field = await cliJson<{ slug: string; type: string }>(
|
||||
"schema",
|
||||
"add-field",
|
||||
"cli_fields",
|
||||
"name",
|
||||
"--type",
|
||||
"string",
|
||||
"--label",
|
||||
"Name",
|
||||
);
|
||||
expect(field.slug).toBe("name");
|
||||
expect(field.type).toBe("string");
|
||||
|
||||
// Remove the field
|
||||
await cli("schema", "remove-field", "cli_fields", "name");
|
||||
|
||||
// Clean up
|
||||
await cli("schema", "delete", "cli_fields", "--force");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Content commands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content", () => {
|
||||
it("lists content", async () => {
|
||||
const result = await cliJson<{ items: { data: Record<string, unknown> }[] }>(
|
||||
"content",
|
||||
"list",
|
||||
"posts",
|
||||
);
|
||||
expect(result.items.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("gets content by id", async () => {
|
||||
const postId = ctx.contentIds["posts"]![0]!;
|
||||
const result = await cliJson<{ data: { title: string } }>("content", "get", "posts", postId);
|
||||
expect(result.data.title).toBe("First Post");
|
||||
});
|
||||
|
||||
it("creates, updates, and deletes content", async () => {
|
||||
// Create
|
||||
const created = await cliJson<{ id: string; slug: string }>(
|
||||
"content",
|
||||
"create",
|
||||
"posts",
|
||||
"--data",
|
||||
JSON.stringify({ title: "CLI Post", excerpt: "From CLI" }),
|
||||
"--slug",
|
||||
"cli-post",
|
||||
);
|
||||
expect(created.id).toBeDefined();
|
||||
expect(created.slug).toBe("cli-post");
|
||||
|
||||
// Update (get first to obtain _rev, then update with it)
|
||||
const fetched = await cliJson<{ _rev: string }>("content", "get", "posts", created.id);
|
||||
const updated = await cliJson<{ data: { title: string } }>(
|
||||
"content",
|
||||
"update",
|
||||
"posts",
|
||||
created.id,
|
||||
"--rev",
|
||||
fetched._rev,
|
||||
"--data",
|
||||
JSON.stringify({ title: "Updated CLI Post" }),
|
||||
);
|
||||
expect(updated.data.title).toBe("Updated CLI Post");
|
||||
|
||||
// Delete
|
||||
await cli("content", "delete", "posts", created.id);
|
||||
});
|
||||
|
||||
it("publishes and unpublishes content", async () => {
|
||||
const item = await cliJson<{ id: string }>(
|
||||
"content",
|
||||
"create",
|
||||
"posts",
|
||||
"--data",
|
||||
JSON.stringify({ title: "Pub Test" }),
|
||||
);
|
||||
|
||||
await cli("content", "publish", "posts", item.id);
|
||||
await cli("content", "unpublish", "posts", item.id);
|
||||
|
||||
// Clean up
|
||||
await cli("content", "delete", "posts", item.id);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Content lifecycle: schedule and restore
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content lifecycle", () => {
|
||||
it("schedules content for publishing", async () => {
|
||||
const item = await cliJson<{ id: string }>(
|
||||
"content",
|
||||
"create",
|
||||
"posts",
|
||||
"--data",
|
||||
JSON.stringify({ title: "CLI Schedule Test" }),
|
||||
);
|
||||
|
||||
// Schedule does not produce JSON output, just a success message
|
||||
await cli("content", "schedule", "posts", item.id, "--at", "2027-06-01T09:00:00Z");
|
||||
|
||||
// Verify via get
|
||||
const fetched = await cliJson<{ scheduledAt: string }>("content", "get", "posts", item.id);
|
||||
expect(fetched.scheduledAt).toBe("2027-06-01T09:00:00Z");
|
||||
|
||||
// Clean up
|
||||
await cli("content", "delete", "posts", item.id);
|
||||
});
|
||||
|
||||
it("restores a trashed item", async () => {
|
||||
const item = await cliJson<{ id: string }>(
|
||||
"content",
|
||||
"create",
|
||||
"posts",
|
||||
"--data",
|
||||
JSON.stringify({ title: "CLI Restore Test" }),
|
||||
);
|
||||
|
||||
// Delete (soft trash)
|
||||
await cli("content", "delete", "posts", item.id);
|
||||
|
||||
// Restore
|
||||
await cli("content", "restore", "posts", item.id);
|
||||
|
||||
// Should be accessible again (auto-published before deletion, so restored as published)
|
||||
const fetched = await cliJson<{ status: string }>("content", "get", "posts", item.id);
|
||||
expect(fetched.status).toBe("published");
|
||||
|
||||
// Final cleanup
|
||||
await cli("content", "delete", "posts", item.id);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Media commands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("media", () => {
|
||||
it("uploads, lists, gets, and deletes media", async () => {
|
||||
// Create a temp file to upload
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
// 1x1 PNG pixel
|
||||
const pngBytes = Buffer.from([
|
||||
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,
|
||||
]);
|
||||
const tmpFile = join(tmpdir(), "emdash-cli-test.png");
|
||||
writeFileSync(tmpFile, pngBytes);
|
||||
|
||||
// Upload
|
||||
const uploaded = await cliJson<{ id: string; filename: string }>(
|
||||
"media",
|
||||
"upload",
|
||||
tmpFile,
|
||||
"--alt",
|
||||
"CLI test image",
|
||||
);
|
||||
expect(uploaded.id).toBeDefined();
|
||||
expect(uploaded.filename).toBe("emdash-cli-test.png");
|
||||
|
||||
// List
|
||||
const list = await cliJson<{ items: { id: string }[] }>("media", "list");
|
||||
const ids = list.items.map((m) => m.id);
|
||||
expect(ids).toContain(uploaded.id);
|
||||
|
||||
// Get
|
||||
const fetched = await cliJson<{ id: string; filename: string }>("media", "get", uploaded.id);
|
||||
expect(fetched.id).toBe(uploaded.id);
|
||||
|
||||
// Delete
|
||||
await cli("media", "delete", uploaded.id);
|
||||
|
||||
// Clean up temp file
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(tmpFile);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Search command
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("search", () => {
|
||||
it("searches content", async () => {
|
||||
// Search should work even if no results (the command shouldn't error)
|
||||
const result = await cliJson<unknown[]>("search", "First Post");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Auth commands
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("auth", () => {
|
||||
it("whoami returns user info with token auth", async () => {
|
||||
const result = await cliJson<{ email: string; role: string }>("whoami");
|
||||
expect(result.email).toBe("dev@emdash.local");
|
||||
expect(result.role).toBe("admin");
|
||||
});
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
395
packages/core/tests/integration/client/client.test.ts
Normal file
395
packages/core/tests/integration/client/client.test.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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;
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
describe("EmDashClient Integration", () => {
|
||||
let ctx: TestServerContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertNodeVersion();
|
||||
ctx = await createTestServer({ port: PORT });
|
||||
}, TIMEOUT);
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
350
packages/core/tests/integration/client/comments.test.ts
Normal file
350
packages/core/tests/integration/client/comments.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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 = 4398;
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
/** 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}`);
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
190
packages/core/tests/integration/client/field-widgets.test.ts
Normal file
190
packages/core/tests/integration/client/field-widgets.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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;
|
||||
const TIMEOUT = 90_000;
|
||||
|
||||
describe("Field Widgets Integration", () => {
|
||||
let ctx: TestServerContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertNodeVersion();
|
||||
ctx = await createTestServer({ port: PORT });
|
||||
}, TIMEOUT);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
518
packages/core/tests/integration/comments/hooks.test.ts
Normal file
518
packages/core/tests/integration/comments/hooks.test.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { defaultCommentModerate } from "../../../src/comments/moderator.js";
|
||||
import {
|
||||
createComment,
|
||||
moderateComment,
|
||||
type CommentHookRunner,
|
||||
} from "../../../src/comments/service.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { definePlugin } from "../../../src/plugins/define-plugin.js";
|
||||
import { createHookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js";
|
||||
import type {
|
||||
CollectionCommentSettings,
|
||||
CommentBeforeCreateEvent,
|
||||
CommentModerateEvent,
|
||||
ModerationDecision,
|
||||
PluginContext,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function defaultSettings(
|
||||
overrides: Partial<CollectionCommentSettings> = {},
|
||||
): CollectionCommentSettings {
|
||||
return {
|
||||
commentsEnabled: true,
|
||||
commentsModeration: "first_time",
|
||||
commentsClosedAfterDays: 90,
|
||||
commentsAutoApproveUsers: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultInput = {
|
||||
collection: "post",
|
||||
contentId: "content-1",
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
body: "Great post!",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1: Service with mocked CommentHookRunner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Comment Service with CommentHookRunner", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
function makeHookRunner(overrides: Partial<CommentHookRunner> = {}): CommentHookRunner {
|
||||
return {
|
||||
runBeforeCreate: vi.fn(async (event: CommentBeforeCreateEvent) => event),
|
||||
runModerate: vi.fn(async () => ({
|
||||
status: "approved" as const,
|
||||
reason: "Test",
|
||||
})),
|
||||
fireAfterCreate: vi.fn(),
|
||||
fireAfterModerate: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("creates comment with status from runModerate", async () => {
|
||||
const hooks = makeHookRunner({
|
||||
runModerate: vi.fn(async () => ({ status: "pending" as const, reason: "Held" })),
|
||||
});
|
||||
|
||||
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.comment.status).toBe("pending");
|
||||
expect(result!.decision.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("transforms comment data via beforeCreate", async () => {
|
||||
const hooks = makeHookRunner({
|
||||
runBeforeCreate: vi.fn(async (event: CommentBeforeCreateEvent) => ({
|
||||
...event,
|
||||
comment: { ...event.comment, body: "Modified body" },
|
||||
})),
|
||||
});
|
||||
|
||||
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.comment.body).toBe("Modified body");
|
||||
});
|
||||
|
||||
it("returns null when beforeCreate returns false (rejected)", async () => {
|
||||
const hooks = makeHookRunner({
|
||||
runBeforeCreate: vi.fn(async () => false as const),
|
||||
});
|
||||
|
||||
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("saves as spam when runModerate returns spam", async () => {
|
||||
const hooks = makeHookRunner({
|
||||
runModerate: vi.fn(async () => ({ status: "spam" as const, reason: "Spam detected" })),
|
||||
});
|
||||
|
||||
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.comment.status).toBe("spam");
|
||||
});
|
||||
|
||||
it("fires fireAfterCreate with correct shape", async () => {
|
||||
const hooks = makeHookRunner();
|
||||
|
||||
await createComment(db, defaultInput, defaultSettings(), hooks, {
|
||||
id: "content-1",
|
||||
collection: "post",
|
||||
slug: "my-post",
|
||||
title: "My Post",
|
||||
});
|
||||
|
||||
expect(hooks.fireAfterCreate).toHaveBeenCalledOnce();
|
||||
const event = (hooks.fireAfterCreate as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(event.comment.collection).toBe("post");
|
||||
expect(event.comment.contentId).toBe("content-1");
|
||||
expect(event.content.slug).toBe("my-post");
|
||||
});
|
||||
|
||||
it("moderateComment updates status and fires fireAfterModerate", async () => {
|
||||
const hooks = makeHookRunner();
|
||||
const created = await createComment(db, defaultInput, defaultSettings(), hooks);
|
||||
|
||||
const updated = await moderateComment(
|
||||
db,
|
||||
created!.comment.id,
|
||||
"spam",
|
||||
{ id: "admin-1", name: "Admin" },
|
||||
hooks,
|
||||
);
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.status).toBe("spam");
|
||||
expect(hooks.fireAfterModerate).toHaveBeenCalledOnce();
|
||||
|
||||
const event = (hooks.fireAfterModerate as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(event.previousStatus).toBe("approved");
|
||||
expect(event.newStatus).toBe("spam");
|
||||
expect(event.moderator.id).toBe("admin-1");
|
||||
});
|
||||
|
||||
it("moderateComment returns null for non-existent id", async () => {
|
||||
const hooks = makeHookRunner();
|
||||
|
||||
const result = await moderateComment(
|
||||
db,
|
||||
"nonexistent",
|
||||
"approved",
|
||||
{ id: "admin-1", name: "Admin" },
|
||||
hooks,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(hooks.fireAfterModerate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2: Built-in moderator unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Built-in Default Comment Moderator", () => {
|
||||
const ctx = {} as PluginContext;
|
||||
|
||||
function makeModerateEvent(overrides: Partial<CommentModerateEvent> = {}): CommentModerateEvent {
|
||||
return {
|
||||
comment: {
|
||||
collection: "post",
|
||||
contentId: "c1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: null,
|
||||
body: "Hello",
|
||||
ipHash: null,
|
||||
userAgent: null,
|
||||
},
|
||||
metadata: {},
|
||||
collectionSettings: defaultSettings(),
|
||||
priorApprovedCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("auto-approves authenticated CMS users when configured", async () => {
|
||||
const decision = await defaultCommentModerate(
|
||||
makeModerateEvent({
|
||||
comment: {
|
||||
...makeModerateEvent().comment,
|
||||
authorUserId: "user-1",
|
||||
},
|
||||
collectionSettings: defaultSettings({ commentsAutoApproveUsers: true }),
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("approved");
|
||||
expect(decision.reason).toContain("Authenticated");
|
||||
});
|
||||
|
||||
it("does not auto-approve when commentsAutoApproveUsers is false", async () => {
|
||||
const decision = await defaultCommentModerate(
|
||||
makeModerateEvent({
|
||||
comment: {
|
||||
...makeModerateEvent().comment,
|
||||
authorUserId: "user-1",
|
||||
},
|
||||
collectionSettings: defaultSettings({
|
||||
commentsAutoApproveUsers: false,
|
||||
commentsModeration: "all",
|
||||
}),
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("approves when moderation is 'none'", async () => {
|
||||
const decision = await defaultCommentModerate(
|
||||
makeModerateEvent({
|
||||
collectionSettings: defaultSettings({ commentsModeration: "none" }),
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("approved");
|
||||
expect(decision.reason).toContain("disabled");
|
||||
});
|
||||
|
||||
it("approves returning commenter with first_time moderation", async () => {
|
||||
const decision = await defaultCommentModerate(
|
||||
makeModerateEvent({
|
||||
collectionSettings: defaultSettings({ commentsModeration: "first_time" }),
|
||||
priorApprovedCount: 3,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("approved");
|
||||
expect(decision.reason).toContain("Returning");
|
||||
});
|
||||
|
||||
it("holds new commenter with first_time moderation", async () => {
|
||||
const decision = await defaultCommentModerate(
|
||||
makeModerateEvent({
|
||||
collectionSettings: defaultSettings({ commentsModeration: "first_time" }),
|
||||
priorApprovedCount: 0,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("holds all comments when moderation is 'all'", async () => {
|
||||
const decision = await defaultCommentModerate(
|
||||
makeModerateEvent({
|
||||
collectionSettings: defaultSettings({ commentsModeration: "all" }),
|
||||
priorApprovedCount: 10,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(decision.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3: Real HookPipeline integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Comment Hooks with HookPipeline", () => {
|
||||
let pipelineDb: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
pipelineDb = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(pipelineDb);
|
||||
});
|
||||
|
||||
it("invokes comment:beforeCreate handler registered via definePlugin", async () => {
|
||||
const spy = vi.fn(async (event: CommentBeforeCreateEvent) => ({
|
||||
...event,
|
||||
metadata: { ...event.metadata, enriched: true },
|
||||
}));
|
||||
|
||||
const plugin = definePlugin({
|
||||
id: "test-enricher",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:users"],
|
||||
hooks: {
|
||||
"comment:beforeCreate": spy,
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
|
||||
|
||||
const event: CommentBeforeCreateEvent = {
|
||||
comment: {
|
||||
collection: "post",
|
||||
contentId: "c1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: null,
|
||||
body: "Hello",
|
||||
ipHash: null,
|
||||
userAgent: null,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = await pipeline.runCommentBeforeCreate(event);
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(result).not.toBe(false);
|
||||
expect((result as CommentBeforeCreateEvent).metadata.enriched).toBe(true);
|
||||
});
|
||||
|
||||
it("invokes exclusive comment:moderate plugin and returns decision", async () => {
|
||||
const moderateHandler = vi.fn(async () => ({
|
||||
status: "spam" as const,
|
||||
reason: "Custom moderator",
|
||||
}));
|
||||
|
||||
const plugin = definePlugin({
|
||||
id: "test-moderator",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:users"],
|
||||
hooks: {
|
||||
"comment:moderate": {
|
||||
exclusive: true,
|
||||
handler: moderateHandler,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
|
||||
|
||||
// Auto-select the sole provider
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: () => true,
|
||||
getOption: async () => null,
|
||||
setOption: async () => {},
|
||||
deleteOption: async () => {},
|
||||
});
|
||||
|
||||
const moderateEvent: CommentModerateEvent = {
|
||||
comment: {
|
||||
collection: "post",
|
||||
contentId: "c1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: null,
|
||||
body: "Buy cheap pills",
|
||||
ipHash: null,
|
||||
userAgent: null,
|
||||
},
|
||||
metadata: {},
|
||||
collectionSettings: defaultSettings(),
|
||||
priorApprovedCount: 0,
|
||||
};
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("comment:moderate", moderateEvent);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect((result!.result as ModerationDecision).status).toBe("spam");
|
||||
expect(moderateHandler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("built-in moderator is auto-selected when sole provider", async () => {
|
||||
const { DEFAULT_COMMENT_MODERATOR_PLUGIN_ID } =
|
||||
await import("../../../src/comments/moderator.js");
|
||||
|
||||
const plugin = definePlugin({
|
||||
id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
|
||||
version: "0.0.0",
|
||||
capabilities: ["read:users"],
|
||||
hooks: {
|
||||
"comment:moderate": {
|
||||
exclusive: true,
|
||||
handler: defaultCommentModerate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: () => true,
|
||||
getOption: async () => null,
|
||||
setOption: async () => {},
|
||||
deleteOption: async () => {},
|
||||
});
|
||||
|
||||
const selection = pipeline.getExclusiveSelection("comment:moderate");
|
||||
expect(selection).toBe(DEFAULT_COMMENT_MODERATOR_PLUGIN_ID);
|
||||
|
||||
// Verify it actually works
|
||||
const moderateEvent: CommentModerateEvent = {
|
||||
comment: {
|
||||
collection: "post",
|
||||
contentId: "c1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: null,
|
||||
body: "Hello",
|
||||
ipHash: null,
|
||||
userAgent: null,
|
||||
},
|
||||
metadata: {},
|
||||
collectionSettings: defaultSettings({ commentsModeration: "none" }),
|
||||
priorApprovedCount: 0,
|
||||
};
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("comment:moderate", moderateEvent);
|
||||
expect(result).not.toBeNull();
|
||||
expect((result!.result as ModerationDecision).status).toBe("approved");
|
||||
});
|
||||
|
||||
it("fires comment:afterCreate handlers", async () => {
|
||||
const spy = vi.fn(async () => {});
|
||||
|
||||
const plugin = definePlugin({
|
||||
id: "test-after-create",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:users"],
|
||||
hooks: {
|
||||
"comment:afterCreate": spy,
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
|
||||
|
||||
await pipeline.runCommentAfterCreate({
|
||||
comment: {
|
||||
id: "c1",
|
||||
collection: "post",
|
||||
contentId: "content-1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: null,
|
||||
body: "Hello",
|
||||
status: "approved",
|
||||
moderationMetadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
metadata: {},
|
||||
content: { id: "content-1", collection: "post", slug: "my-post" },
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fires comment:afterModerate handlers", async () => {
|
||||
const spy = vi.fn(async () => {});
|
||||
|
||||
const plugin = definePlugin({
|
||||
id: "test-after-moderate",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:users"],
|
||||
hooks: {
|
||||
"comment:afterModerate": spy,
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
|
||||
|
||||
await pipeline.runCommentAfterModerate({
|
||||
comment: {
|
||||
id: "c1",
|
||||
collection: "post",
|
||||
contentId: "content-1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: null,
|
||||
body: "Hello",
|
||||
status: "approved",
|
||||
moderationMetadata: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
previousStatus: "pending",
|
||||
newStatus: "approved",
|
||||
moderator: { id: "admin-1", name: "Admin" },
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
318
packages/core/tests/integration/comments/notifications.test.ts
Normal file
318
packages/core/tests/integration/comments/notifications.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommentNotificationEmail,
|
||||
lookupContentAuthor,
|
||||
sendCommentNotification,
|
||||
} from "../../../src/comments/notifications.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import type { EmailPipeline } from "../../../src/plugins/email.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("Comment Notifications", () => {
|
||||
describe("buildCommentNotificationEmail", () => {
|
||||
it("builds email with content title", () => {
|
||||
const email = buildCommentNotificationEmail("author@example.com", {
|
||||
commentAuthorName: "Jane",
|
||||
commentBody: "Great post!",
|
||||
contentTitle: "My Blog Post",
|
||||
collection: "post",
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(email.to).toBe("author@example.com");
|
||||
expect(email.subject).toBe('New comment on "My Blog Post"');
|
||||
expect(email.text).toContain("Jane");
|
||||
expect(email.text).toContain("Great post!");
|
||||
expect(email.text).toContain("/_emdash/admin/comments");
|
||||
expect(email.html).toContain("Jane");
|
||||
expect(email.html).toContain("Great post!");
|
||||
});
|
||||
|
||||
it("falls back to collection name when no title", () => {
|
||||
const email = buildCommentNotificationEmail("author@example.com", {
|
||||
commentAuthorName: "Jane",
|
||||
commentBody: "Nice!",
|
||||
contentTitle: "",
|
||||
collection: "post",
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(email.subject).toBe('New comment on "post item"');
|
||||
});
|
||||
|
||||
it("truncates long comment bodies", () => {
|
||||
const longBody = "x".repeat(600);
|
||||
const email = buildCommentNotificationEmail("author@example.com", {
|
||||
commentAuthorName: "Jane",
|
||||
commentBody: longBody,
|
||||
contentTitle: "Post",
|
||||
collection: "post",
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(email.text).toContain("...");
|
||||
expect(email.text).not.toContain("x".repeat(600));
|
||||
});
|
||||
|
||||
it("escapes HTML in author name and body", () => {
|
||||
const email = buildCommentNotificationEmail("author@example.com", {
|
||||
commentAuthorName: '<script>alert("xss")</script>',
|
||||
commentBody: "<img src=x onerror=alert(1)>",
|
||||
contentTitle: "Post",
|
||||
collection: "post",
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(email.html).not.toContain("<script>");
|
||||
expect(email.html).not.toContain("<img src=x");
|
||||
expect(email.html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("strips CRLF from subject to prevent header injection", () => {
|
||||
const email = buildCommentNotificationEmail("author@example.com", {
|
||||
commentAuthorName: "Jane",
|
||||
commentBody: "Nice!",
|
||||
contentTitle: "Post\r\nBcc: attacker@evil.com",
|
||||
collection: "post",
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(email.subject).not.toContain("\r");
|
||||
expect(email.subject).not.toContain("\n");
|
||||
expect(email.subject).toContain("Post");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendCommentNotification", () => {
|
||||
let mockEmail: EmailPipeline;
|
||||
let sendSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
sendSpy = vi.fn().mockResolvedValue(undefined);
|
||||
mockEmail = {
|
||||
send: sendSpy,
|
||||
isAvailable: () => true,
|
||||
} as unknown as EmailPipeline;
|
||||
});
|
||||
|
||||
it("sends notification for approved comments", async () => {
|
||||
const sent = await sendCommentNotification({
|
||||
email: mockEmail,
|
||||
comment: {
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
body: "Great post!",
|
||||
status: "approved",
|
||||
collection: "post",
|
||||
},
|
||||
contentTitle: "My Post",
|
||||
contentAuthor: { email: "author@example.com", name: "Author" },
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(sendSpy).toHaveBeenCalledOnce();
|
||||
const [message, source] = sendSpy.mock.calls[0]!;
|
||||
expect(message.to).toBe("author@example.com");
|
||||
expect(message.subject).toContain("My Post");
|
||||
expect(source).toBe("emdash-comments");
|
||||
});
|
||||
|
||||
it("skips pending comments", async () => {
|
||||
const sent = await sendCommentNotification({
|
||||
email: mockEmail,
|
||||
comment: {
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
body: "Great post!",
|
||||
status: "pending",
|
||||
collection: "post",
|
||||
},
|
||||
contentAuthor: { email: "author@example.com", name: "Author" },
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when no content author", async () => {
|
||||
const sent = await sendCommentNotification({
|
||||
email: mockEmail,
|
||||
comment: {
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
body: "Great post!",
|
||||
status: "approved",
|
||||
collection: "post",
|
||||
},
|
||||
contentAuthor: undefined,
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when email provider not available", async () => {
|
||||
mockEmail = {
|
||||
send: sendSpy,
|
||||
isAvailable: () => false,
|
||||
} as unknown as EmailPipeline;
|
||||
|
||||
const sent = await sendCommentNotification({
|
||||
email: mockEmail,
|
||||
comment: {
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
body: "Great post!",
|
||||
status: "approved",
|
||||
collection: "post",
|
||||
},
|
||||
contentAuthor: { email: "author@example.com", name: "Author" },
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when commenter is the content author", async () => {
|
||||
const sent = await sendCommentNotification({
|
||||
email: mockEmail,
|
||||
comment: {
|
||||
authorName: "Author",
|
||||
authorEmail: "author@example.com",
|
||||
body: "My own comment",
|
||||
status: "approved",
|
||||
collection: "post",
|
||||
},
|
||||
contentAuthor: { email: "author@example.com", name: "Author" },
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("compares emails case-insensitively for self-comment check", async () => {
|
||||
const sent = await sendCommentNotification({
|
||||
email: mockEmail,
|
||||
comment: {
|
||||
authorName: "Author",
|
||||
authorEmail: "Author@Example.COM",
|
||||
body: "My own comment",
|
||||
status: "approved",
|
||||
collection: "post",
|
||||
},
|
||||
contentAuthor: { email: "author@example.com", name: "Author" },
|
||||
adminBaseUrl: "https://example.com/_emdash",
|
||||
});
|
||||
|
||||
expect(sent).toBe(false);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lookupContentAuthor", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("returns null for non-existent content", async () => {
|
||||
const result = await lookupContentAuthor(db, "post", "nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns slug and author for content with author", async () => {
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user1",
|
||||
email: "author@example.com",
|
||||
name: "Author Name",
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("ec_post" as never)
|
||||
.values({
|
||||
id: "post1",
|
||||
slug: "my-post",
|
||||
status: "published",
|
||||
author_id: "user1",
|
||||
} as never)
|
||||
.execute();
|
||||
|
||||
const result = await lookupContentAuthor(db, "post", "post1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.slug).toBe("my-post");
|
||||
expect(result!.author).toEqual({
|
||||
id: "user1",
|
||||
email: "author@example.com",
|
||||
name: "Author Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes author with unverified email", async () => {
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "unverified1",
|
||||
email: "unverified@example.com",
|
||||
name: "Unverified",
|
||||
role: 50,
|
||||
email_verified: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("ec_post" as never)
|
||||
.values({
|
||||
id: "post3",
|
||||
slug: "unverified-post",
|
||||
status: "published",
|
||||
author_id: "unverified1",
|
||||
} as never)
|
||||
.execute();
|
||||
|
||||
const result = await lookupContentAuthor(db, "post", "post3");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.slug).toBe("unverified-post");
|
||||
expect(result!.author).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid collection names", async () => {
|
||||
await expect(lookupContentAuthor(db, "'; DROP TABLE users; --", "post1")).rejects.toThrow(
|
||||
"collection",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns slug without author for content without author_id", async () => {
|
||||
await db
|
||||
.insertInto("ec_post" as never)
|
||||
.values({
|
||||
id: "post2",
|
||||
slug: "orphan-post",
|
||||
status: "published",
|
||||
author_id: null,
|
||||
} as never)
|
||||
.execute();
|
||||
|
||||
const result = await lookupContentAuthor(db, "post", "post2");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.slug).toBe("orphan-post");
|
||||
expect(result!.author).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
412
packages/core/tests/integration/comments/repository.test.ts
Normal file
412
packages/core/tests/integration/comments/repository.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { CommentRepository, type Comment } from "../../../src/database/repositories/comment.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("CommentRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: CommentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
repo = new CommentRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function makeInput(overrides: Partial<Parameters<CommentRepository["create"]>[0]> = {}) {
|
||||
return {
|
||||
collection: "post",
|
||||
contentId: "content-1",
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
body: "Great post!",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("CRUD", () => {
|
||||
it("creates a comment and returns it with id and timestamps", async () => {
|
||||
const comment = await repo.create(makeInput());
|
||||
|
||||
expect(comment.id).toBeTruthy();
|
||||
expect(comment.collection).toBe("post");
|
||||
expect(comment.contentId).toBe("content-1");
|
||||
expect(comment.authorName).toBe("Jane");
|
||||
expect(comment.authorEmail).toBe("jane@example.com");
|
||||
expect(comment.body).toBe("Great post!");
|
||||
expect(comment.status).toBe("pending");
|
||||
expect(comment.createdAt).toBeTruthy();
|
||||
expect(comment.updatedAt).toBeTruthy();
|
||||
expect(comment.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it("findById returns the comment", async () => {
|
||||
const created = await repo.create(makeInput());
|
||||
const found = await repo.findById(created.id);
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.authorName).toBe("Jane");
|
||||
});
|
||||
|
||||
it("findById returns null for non-existent id", async () => {
|
||||
const found = await repo.findById("nonexistent");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("findByContent returns matching comments", async () => {
|
||||
await repo.create(makeInput());
|
||||
await repo.create(makeInput({ body: "Second comment" }));
|
||||
await repo.create(makeInput({ contentId: "other-content" }));
|
||||
|
||||
const result = await repo.findByContent("post", "content-1");
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items.every((c) => c.contentId === "content-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("findByStatus filters by status", async () => {
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "pending" }));
|
||||
await repo.create(makeInput({ status: "spam" }));
|
||||
|
||||
const result = await repo.findByStatus("approved");
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.status).toBe("approved");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status transitions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Status transitions", () => {
|
||||
it("updateStatus changes status", async () => {
|
||||
const created = await repo.create(makeInput());
|
||||
const updated = await repo.updateStatus(created.id, "approved");
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.status).toBe("approved");
|
||||
expect(updated!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("bulkUpdateStatus returns count of updated rows", async () => {
|
||||
const c1 = await repo.create(makeInput());
|
||||
const c2 = await repo.create(makeInput({ body: "Second" }));
|
||||
|
||||
const count = await repo.bulkUpdateStatus([c1.id, c2.id], "approved");
|
||||
expect(count).toBe(2);
|
||||
|
||||
const found1 = await repo.findById(c1.id);
|
||||
const found2 = await repo.findById(c2.id);
|
||||
expect(found1!.status).toBe("approved");
|
||||
expect(found2!.status).toBe("approved");
|
||||
});
|
||||
|
||||
it("bulkUpdateStatus returns 0 for empty array", async () => {
|
||||
const count = await repo.bulkUpdateStatus([], "approved");
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Deletion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Deletion", () => {
|
||||
it("delete hard-deletes and returns true", async () => {
|
||||
const created = await repo.create(makeInput());
|
||||
const deleted = await repo.delete(created.id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(await repo.findById(created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("delete returns false for non-existent id", async () => {
|
||||
const deleted = await repo.delete("nonexistent");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("bulkDelete returns count", async () => {
|
||||
const c1 = await repo.create(makeInput());
|
||||
const c2 = await repo.create(makeInput({ body: "Second" }));
|
||||
|
||||
const count = await repo.bulkDelete([c1.id, c2.id]);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("bulkDelete returns 0 for empty array", async () => {
|
||||
const count = await repo.bulkDelete([]);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("deleteByContent removes all comments for content", async () => {
|
||||
await repo.create(makeInput());
|
||||
await repo.create(makeInput({ body: "Second" }));
|
||||
await repo.create(makeInput({ contentId: "other-content" }));
|
||||
|
||||
const count = await repo.deleteByContent("post", "content-1");
|
||||
expect(count).toBe(2);
|
||||
|
||||
const remaining = await repo.findByContent("post", "content-1");
|
||||
expect(remaining.items).toHaveLength(0);
|
||||
|
||||
const other = await repo.findByContent("post", "other-content");
|
||||
expect(other.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parent FK cascade deletes replies", async () => {
|
||||
const parent = await repo.create(makeInput());
|
||||
const reply = await repo.create(makeInput({ parentId: parent.id, body: "Reply" }));
|
||||
|
||||
await repo.delete(parent.id);
|
||||
|
||||
expect(await repo.findById(parent.id)).toBeNull();
|
||||
expect(await repo.findById(reply.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Counting
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Counting", () => {
|
||||
it("countByContent with and without status filter", async () => {
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "pending" }));
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
|
||||
const total = await repo.countByContent("post", "content-1");
|
||||
expect(total).toBe(3);
|
||||
|
||||
const approved = await repo.countByContent("post", "content-1", "approved");
|
||||
expect(approved).toBe(2);
|
||||
|
||||
const pending = await repo.countByContent("post", "content-1", "pending");
|
||||
expect(pending).toBe(1);
|
||||
});
|
||||
|
||||
it("countByStatus returns grouped counts", async () => {
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "pending" }));
|
||||
await repo.create(makeInput({ status: "spam" }));
|
||||
|
||||
const counts = await repo.countByStatus();
|
||||
expect(counts.approved).toBe(2);
|
||||
expect(counts.pending).toBe(1);
|
||||
expect(counts.spam).toBe(1);
|
||||
expect(counts.trash).toBe(0);
|
||||
});
|
||||
|
||||
it("countApprovedByEmail counts only approved comments", async () => {
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "pending" }));
|
||||
|
||||
const count = await repo.countApprovedByEmail("jane@example.com");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cursor pagination
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Cursor pagination", () => {
|
||||
it("findByContent paginates with cursor", async () => {
|
||||
// Create 5 comments
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await repo.create(makeInput({ body: `Comment ${i}` }));
|
||||
}
|
||||
|
||||
const page1 = await repo.findByContent("post", "content-1", { limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
const page2 = await repo.findByContent("post", "content-1", {
|
||||
limit: 2,
|
||||
cursor: page1.nextCursor,
|
||||
});
|
||||
expect(page2.items).toHaveLength(2);
|
||||
expect(page2.nextCursor).toBeTruthy();
|
||||
|
||||
const page3 = await repo.findByContent("post", "content-1", {
|
||||
limit: 2,
|
||||
cursor: page2.nextCursor,
|
||||
});
|
||||
expect(page3.items).toHaveLength(1);
|
||||
expect(page3.nextCursor).toBeUndefined();
|
||||
|
||||
// Ensure no duplicates across pages
|
||||
const allIds = [...page1.items, ...page2.items, ...page3.items].map((c) => c.id);
|
||||
expect(new Set(allIds).size).toBe(5);
|
||||
});
|
||||
|
||||
it("findByStatus paginates with cursor", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await repo.create(makeInput({ status: "approved", body: `Comment ${i}` }));
|
||||
}
|
||||
|
||||
const page1 = await repo.findByStatus("approved", { limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
const page2 = await repo.findByStatus("approved", {
|
||||
limit: 2,
|
||||
cursor: page1.nextCursor,
|
||||
});
|
||||
expect(page2.items).toHaveLength(2);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Threading
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Threading", () => {
|
||||
it("assembleThreads produces 1-level nesting", () => {
|
||||
const root: Comment = {
|
||||
id: "root",
|
||||
collection: "post",
|
||||
contentId: "c1",
|
||||
parentId: null,
|
||||
authorName: "A",
|
||||
authorEmail: "a@test.com",
|
||||
authorUserId: null,
|
||||
body: "Root",
|
||||
status: "approved",
|
||||
ipHash: null,
|
||||
userAgent: null,
|
||||
moderationMetadata: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const reply: Comment = {
|
||||
...root,
|
||||
id: "reply1",
|
||||
parentId: "root",
|
||||
body: "Reply",
|
||||
};
|
||||
|
||||
const threads = CommentRepository.assembleThreads([root, reply]);
|
||||
expect(threads).toHaveLength(1);
|
||||
expect((threads[0] as Comment & { _replies?: Comment[] })._replies).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("toPublicComment strips private fields", () => {
|
||||
const comment: Comment & { _replies?: Comment[] } = {
|
||||
id: "c1",
|
||||
collection: "post",
|
||||
contentId: "content-1",
|
||||
parentId: null,
|
||||
authorName: "Jane",
|
||||
authorEmail: "jane@example.com",
|
||||
authorUserId: "user-1",
|
||||
body: "Great!",
|
||||
status: "approved",
|
||||
ipHash: "abc123",
|
||||
userAgent: "Mozilla/5.0",
|
||||
moderationMetadata: { score: 0.9 },
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const pub = CommentRepository.toPublicComment(comment);
|
||||
|
||||
expect(pub.id).toBe("c1");
|
||||
expect(pub.authorName).toBe("Jane");
|
||||
expect(pub.isRegisteredUser).toBe(true);
|
||||
expect(pub.body).toBe("Great!");
|
||||
expect(pub.createdAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
|
||||
// Private fields should not be present
|
||||
expect("authorEmail" in pub).toBe(false);
|
||||
expect("ipHash" in pub).toBe(false);
|
||||
expect("userAgent" in pub).toBe(false);
|
||||
expect("moderationMetadata" in pub).toBe(false);
|
||||
expect("status" in pub).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("returns empty results for non-existent content", async () => {
|
||||
const result = await repo.findByContent("post", "nonexistent");
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moderationMetadata JSON round-trips correctly", async () => {
|
||||
const metadata = {
|
||||
aiScore: 0.95,
|
||||
categories: ["safe"],
|
||||
nested: { key: "value" },
|
||||
};
|
||||
|
||||
const created = await repo.create(makeInput({ moderationMetadata: metadata }));
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.moderationMetadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("moderationMetadata null round-trips", async () => {
|
||||
const created = await repo.create(makeInput());
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.moderationMetadata).toBeNull();
|
||||
});
|
||||
|
||||
it("findByStatus with search filters by body", async () => {
|
||||
await repo.create(makeInput({ status: "approved", body: "Hello world" }));
|
||||
await repo.create(makeInput({ status: "approved", body: "Goodbye world" }));
|
||||
|
||||
const result = await repo.findByStatus("approved", { search: "Hello" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.body).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("findByStatus with search filters by author name", async () => {
|
||||
await repo.create(makeInput({ status: "approved", authorName: "Alice" }));
|
||||
await repo.create(makeInput({ status: "approved", authorName: "Bob" }));
|
||||
|
||||
const result = await repo.findByStatus("approved", { search: "Alice" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.authorName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("findByContent with status filter", async () => {
|
||||
await repo.create(makeInput({ status: "approved" }));
|
||||
await repo.create(makeInput({ status: "pending" }));
|
||||
|
||||
const result = await repo.findByContent("post", "content-1", { status: "approved" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.status).toBe("approved");
|
||||
});
|
||||
|
||||
it("updateModerationMetadata updates the JSON field", async () => {
|
||||
const created = await repo.create(makeInput());
|
||||
await repo.updateModerationMetadata(created.id, { score: 0.5 });
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found!.moderationMetadata).toEqual({ score: 0.5 });
|
||||
});
|
||||
});
|
||||
});
|
||||
345
packages/core/tests/integration/database/dialect-compat.test.ts
Normal file
345
packages/core/tests/integration/database/dialect-compat.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Dialect compatibility tests
|
||||
*
|
||||
* Runs core database operations against every available dialect.
|
||||
* SQLite always runs (in-memory). Postgres runs when EMDASH_TEST_PG is set.
|
||||
*
|
||||
* These tests verify that migrations, schema registry, and content CRUD
|
||||
* work identically across dialects.
|
||||
*/
|
||||
|
||||
import { it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { runMigrations, getMigrationStatus } from "../../../src/database/migrations/runner.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import {
|
||||
createForDialect,
|
||||
describeEachDialect,
|
||||
setupForDialect,
|
||||
setupForDialectWithCollections,
|
||||
teardownForDialect,
|
||||
type DialectTestContext,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeEachDialect("Migrations", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Bare database — no migrations yet. Tests run them explicitly.
|
||||
ctx = await createForDialect(dialect);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("runs all migrations and creates system tables", async () => {
|
||||
await runMigrations(ctx.db);
|
||||
|
||||
const tables = [
|
||||
"revisions",
|
||||
"taxonomies",
|
||||
"content_taxonomies",
|
||||
"media",
|
||||
"users",
|
||||
"options",
|
||||
"audit_logs",
|
||||
"_emdash_migrations",
|
||||
"_emdash_collections",
|
||||
"_emdash_fields",
|
||||
"_plugin_storage",
|
||||
"_plugin_state",
|
||||
"_plugin_indexes",
|
||||
"_emdash_sections",
|
||||
"_emdash_bylines",
|
||||
"_emdash_content_bylines",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
const result = await ctx.db
|
||||
.selectFrom(table as keyof Database)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(Array.isArray(result), `table ${table} should exist`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks migrations in _emdash_migrations", async () => {
|
||||
await runMigrations(ctx.db);
|
||||
|
||||
const migrations = await ctx.db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(migrations).toHaveLength(31);
|
||||
expect(migrations[0]?.name).toBe("001_initial");
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await runMigrations(ctx.db);
|
||||
await runMigrations(ctx.db);
|
||||
|
||||
const migrations = await ctx.db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(migrations).toHaveLength(31);
|
||||
});
|
||||
|
||||
it("reports correct migration status", async () => {
|
||||
const before = await getMigrationStatus(ctx.db);
|
||||
expect(before.pending).toContain("001_initial");
|
||||
expect(before.applied).toHaveLength(0);
|
||||
|
||||
await runMigrations(ctx.db);
|
||||
|
||||
const after = await getMigrationStatus(ctx.db);
|
||||
expect(after.applied).toContain("001_initial");
|
||||
expect(after.pending).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeEachDialect("Schema registry", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialect(dialect);
|
||||
await runMigrations(ctx.db);
|
||||
registry = new SchemaRegistry(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("creates a collection and its dynamic table", async () => {
|
||||
await registry.createCollection({
|
||||
slug: "article",
|
||||
label: "Articles",
|
||||
labelSingular: "Article",
|
||||
});
|
||||
|
||||
// Dynamic table should exist
|
||||
const rows = await ctx.db
|
||||
.selectFrom("ec_article" as keyof Database)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
|
||||
// Registry should have the collection
|
||||
const collections = await registry.listCollections();
|
||||
expect(collections.map((c) => c.slug)).toContain("article");
|
||||
});
|
||||
|
||||
it("adds fields to a collection", async () => {
|
||||
await registry.createCollection({
|
||||
slug: "post",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("post", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
await registry.createField("post", {
|
||||
slug: "body",
|
||||
label: "Body",
|
||||
type: "portableText",
|
||||
});
|
||||
|
||||
await registry.createField("post", {
|
||||
slug: "views",
|
||||
label: "Views",
|
||||
type: "integer",
|
||||
});
|
||||
|
||||
const coll = await registry.getCollectionWithFields("post");
|
||||
expect(coll).not.toBeNull();
|
||||
const slugs = coll!.fields.map((f) => f.slug);
|
||||
expect(slugs).toContain("title");
|
||||
expect(slugs).toContain("body");
|
||||
expect(slugs).toContain("views");
|
||||
});
|
||||
|
||||
it("deletes a collection and drops its table", async () => {
|
||||
await registry.createCollection({
|
||||
slug: "temp",
|
||||
label: "Temp",
|
||||
labelSingular: "Temp",
|
||||
});
|
||||
|
||||
// Verify it exists
|
||||
const before = await registry.listCollections();
|
||||
expect(before.map((c) => c.slug)).toContain("temp");
|
||||
|
||||
await registry.deleteCollection("temp");
|
||||
|
||||
const after = await registry.listCollections();
|
||||
expect(after.map((c) => c.slug)).not.toContain("temp");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeEachDialect("Content CRUD", (dialect) => {
|
||||
let ctx: DialectTestContext;
|
||||
let repo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await setupForDialectWithCollections(dialect);
|
||||
repo = new ContentRepository(ctx.db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownForDialect(ctx);
|
||||
});
|
||||
|
||||
it("creates and retrieves content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "hello-world",
|
||||
data: {
|
||||
title: "Hello World",
|
||||
content: [{ _type: "block", children: [{ _type: "span", text: "Content" }] }],
|
||||
},
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
expect(created.id).toBeDefined();
|
||||
expect(created.slug).toBe("hello-world");
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.data.title).toBe("Hello World");
|
||||
expect(found!.slug).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("updates content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "original",
|
||||
data: { title: "Original" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated" },
|
||||
});
|
||||
|
||||
expect(updated.data.title).toBe("Updated");
|
||||
expect(updated.slug).toBe("original");
|
||||
});
|
||||
|
||||
it("lists content with pagination", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: `post-${i}`,
|
||||
data: { title: `Post ${i}` },
|
||||
status: "draft",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await repo.findMany("post", { limit: 3 });
|
||||
expect(result.items).toHaveLength(3);
|
||||
|
||||
if (result.nextCursor) {
|
||||
const page2 = await repo.findMany("post", {
|
||||
limit: 3,
|
||||
cursor: result.nextCursor,
|
||||
});
|
||||
expect(page2.items).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-deletes content", async () => {
|
||||
const created = await repo.create({
|
||||
type: "post",
|
||||
slug: "to-delete",
|
||||
data: { title: "To Delete" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const deleted = await repo.delete("post", created.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "draft-post",
|
||||
data: { title: "Draft Post" },
|
||||
status: "draft",
|
||||
});
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "published-post",
|
||||
data: { title: "Published Post" },
|
||||
status: "published",
|
||||
});
|
||||
|
||||
const drafts = await repo.findMany("post", { where: { status: "draft" } });
|
||||
expect(drafts.items).toHaveLength(1);
|
||||
expect(drafts.items[0]?.data.title).toBe("Draft Post");
|
||||
|
||||
const published = await repo.findMany("post", { where: { status: "published" } });
|
||||
expect(published.items).toHaveLength(1);
|
||||
expect(published.items[0]?.data.title).toBe("Published Post");
|
||||
});
|
||||
|
||||
it("enforces unique slug within a collection", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "same-slug",
|
||||
data: { title: "First" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.create({
|
||||
type: "post",
|
||||
slug: "same-slug",
|
||||
data: { title: "Second" },
|
||||
status: "draft",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("isolates collections", async () => {
|
||||
await repo.create({
|
||||
type: "post",
|
||||
slug: "shared-slug",
|
||||
data: { title: "A Post" },
|
||||
status: "draft",
|
||||
});
|
||||
await repo.create({
|
||||
type: "page",
|
||||
slug: "shared-slug",
|
||||
data: { title: "A Page" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const posts = await repo.findMany("post");
|
||||
const pages = await repo.findMany("page");
|
||||
|
||||
expect(posts.items).toHaveLength(1);
|
||||
expect(pages.items).toHaveLength(1);
|
||||
expect(posts.items[0]?.data.title).toBe("A Post");
|
||||
expect(pages.items[0]?.data.title).toBe("A Page");
|
||||
});
|
||||
});
|
||||
412
packages/core/tests/integration/database/migrations.test.ts
Normal file
412
packages/core/tests/integration/database/migrations.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { runMigrations, getMigrationStatus } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
|
||||
describe("Database Migrations (Integration)", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh in-memory database for each test
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Close the database connection
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("should create all tables from migrations", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Verify all tables exist by querying them
|
||||
// Note: No generic "content" table - collections create ec_* tables dynamically
|
||||
const tables = [
|
||||
"revisions",
|
||||
"taxonomies",
|
||||
"content_taxonomies",
|
||||
"media",
|
||||
"users",
|
||||
"options",
|
||||
"audit_logs",
|
||||
"_emdash_migrations",
|
||||
"_emdash_collections",
|
||||
"_emdash_fields",
|
||||
"_plugin_storage",
|
||||
"_plugin_state",
|
||||
"_plugin_indexes",
|
||||
"_emdash_sections",
|
||||
"_emdash_bylines",
|
||||
"_emdash_content_bylines",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
// Query table to verify it exists
|
||||
const result = await db
|
||||
.selectFrom(table as keyof Database)
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should track migration in _emdash_migrations table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const migrations = await db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
expect(migrations).toHaveLength(31);
|
||||
expect(migrations[0]?.name).toBe("001_initial");
|
||||
expect(migrations[0]?.timestamp).toBeDefined();
|
||||
expect(migrations[1]?.name).toBe("002_media_status");
|
||||
expect(migrations[1]?.timestamp).toBeDefined();
|
||||
expect(migrations[2]?.name).toBe("003_schema_registry");
|
||||
expect(migrations[2]?.timestamp).toBeDefined();
|
||||
expect(migrations[3]?.name).toBe("004_plugins");
|
||||
expect(migrations[3]?.timestamp).toBeDefined();
|
||||
expect(migrations[4]?.name).toBe("005_menus");
|
||||
expect(migrations[4]?.timestamp).toBeDefined();
|
||||
expect(migrations[5]?.name).toBe("006_taxonomy_defs");
|
||||
expect(migrations[5]?.timestamp).toBeDefined();
|
||||
expect(migrations[6]?.name).toBe("007_widgets");
|
||||
expect(migrations[6]?.timestamp).toBeDefined();
|
||||
expect(migrations[7]?.name).toBe("008_auth");
|
||||
expect(migrations[7]?.timestamp).toBeDefined();
|
||||
expect(migrations[8]?.name).toBe("009_user_disabled");
|
||||
expect(migrations[8]?.timestamp).toBeDefined();
|
||||
expect(migrations[9]?.name).toBe("011_sections");
|
||||
expect(migrations[9]?.timestamp).toBeDefined();
|
||||
expect(migrations[10]?.name).toBe("012_search");
|
||||
expect(migrations[10]?.timestamp).toBeDefined();
|
||||
expect(migrations[11]?.name).toBe("013_scheduled_publishing");
|
||||
expect(migrations[11]?.timestamp).toBeDefined();
|
||||
expect(migrations[12]?.name).toBe("014_draft_revisions");
|
||||
expect(migrations[12]?.timestamp).toBeDefined();
|
||||
expect(migrations[13]?.name).toBe("015_indexes");
|
||||
expect(migrations[13]?.timestamp).toBeDefined();
|
||||
expect(migrations[14]?.name).toBe("016_api_tokens");
|
||||
expect(migrations[14]?.timestamp).toBeDefined();
|
||||
expect(migrations[15]?.name).toBe("017_authorization_codes");
|
||||
expect(migrations[15]?.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it("should be idempotent (running twice is safe)", async () => {
|
||||
await runMigrations(db);
|
||||
await runMigrations(db);
|
||||
|
||||
const migrations = await db.selectFrom("_emdash_migrations").selectAll().execute();
|
||||
|
||||
// Should still only have thirty-one migration records
|
||||
expect(migrations).toHaveLength(31);
|
||||
});
|
||||
|
||||
it("should report correct migration status", async () => {
|
||||
const statusBefore = await getMigrationStatus(db);
|
||||
expect(statusBefore.pending).toContain("001_initial");
|
||||
expect(statusBefore.pending).toContain("002_media_status");
|
||||
expect(statusBefore.applied).toHaveLength(0);
|
||||
|
||||
await runMigrations(db);
|
||||
|
||||
const statusAfter = await getMigrationStatus(db);
|
||||
expect(statusAfter.applied).toContain("001_initial");
|
||||
expect(statusAfter.applied).toContain("002_media_status");
|
||||
expect(statusAfter.pending).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create schema registry tables", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Test collections table
|
||||
const testId = "test-collection";
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: testId,
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
label_singular: "Post",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const collection = await db
|
||||
.selectFrom("_emdash_collections")
|
||||
.selectAll()
|
||||
.where("id", "=", testId)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(collection).toBeDefined();
|
||||
expect(collection?.slug).toBe("posts");
|
||||
expect(collection?.label).toBe("Posts");
|
||||
expect(collection?.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on collection slug", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: "id1",
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Attempting to insert duplicate slug should fail
|
||||
await expect(
|
||||
db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: "id2",
|
||||
slug: "posts",
|
||||
label: "Posts Again",
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should create fields table with foreign key to collections", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Create collection first
|
||||
const collectionId = "collection-1";
|
||||
await db
|
||||
.insertInto("_emdash_collections")
|
||||
.values({
|
||||
id: collectionId,
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create field
|
||||
await db
|
||||
.insertInto("_emdash_fields")
|
||||
.values({
|
||||
id: "field-1",
|
||||
collection_id: collectionId,
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
column_type: "TEXT",
|
||||
required: 0,
|
||||
unique: 0,
|
||||
sort_order: 0,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const fields = await db
|
||||
.selectFrom("_emdash_fields")
|
||||
.selectAll()
|
||||
.where("collection_id", "=", collectionId)
|
||||
.execute();
|
||||
|
||||
expect(fields).toHaveLength(1);
|
||||
expect(fields[0]?.slug).toBe("title");
|
||||
});
|
||||
|
||||
it("should create revisions table with collection+entry_id", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Create revision for a content entry
|
||||
await db
|
||||
.insertInto("revisions")
|
||||
.values({
|
||||
id: "rev-1",
|
||||
collection: "posts",
|
||||
entry_id: "entry-1",
|
||||
data: JSON.stringify({ title: "Revised" }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const revisions = await db
|
||||
.selectFrom("revisions")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.where("entry_id", "=", "entry-1")
|
||||
.execute();
|
||||
|
||||
expect(revisions).toHaveLength(1);
|
||||
expect(revisions[0]?.collection).toBe("posts");
|
||||
});
|
||||
|
||||
it("should create users table with unique email constraint", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
role: 50, // ADMIN
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Duplicate email should fail
|
||||
await expect(
|
||||
db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: "user-2",
|
||||
email: "test@example.com",
|
||||
role: 10, // SUBSCRIBER
|
||||
email_verified: 1,
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should create taxonomies table with hierarchical support", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
// Create parent category
|
||||
const parentId = "cat-parent";
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: parentId,
|
||||
name: "category",
|
||||
slug: "parent",
|
||||
label: "Parent Category",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create child category
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: "cat-child",
|
||||
name: "category",
|
||||
slug: "child",
|
||||
label: "Child Category",
|
||||
parent_id: parentId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const child = await db
|
||||
.selectFrom("taxonomies")
|
||||
.selectAll()
|
||||
.where("id", "=", "cat-child")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(child?.parent_id).toBe(parentId);
|
||||
});
|
||||
|
||||
it("should create content_taxonomies junction table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
const taxonomyId = "tax-1";
|
||||
|
||||
// Create taxonomy
|
||||
await db
|
||||
.insertInto("taxonomies")
|
||||
.values({
|
||||
id: taxonomyId,
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Assign taxonomy to content entry (collection + entry_id)
|
||||
await db
|
||||
.insertInto("content_taxonomies")
|
||||
.values({
|
||||
collection: "posts",
|
||||
entry_id: "entry-1",
|
||||
taxonomy_id: taxonomyId,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const assignments = await db
|
||||
.selectFrom("content_taxonomies")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.where("entry_id", "=", "entry-1")
|
||||
.execute();
|
||||
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments[0]?.taxonomy_id).toBe(taxonomyId);
|
||||
});
|
||||
|
||||
it("should create media table", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("media")
|
||||
.values({
|
||||
id: "media-1",
|
||||
filename: "photo.jpg",
|
||||
mime_type: "image/jpeg",
|
||||
size: 1024000,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
alt: "Test photo",
|
||||
storage_key: "uploads/photo.jpg",
|
||||
status: "ready",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const media = await db
|
||||
.selectFrom("media")
|
||||
.selectAll()
|
||||
.where("id", "=", "media-1")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(media).toBeDefined();
|
||||
expect(media?.width).toBe(1920);
|
||||
expect(media?.height).toBe(1080);
|
||||
});
|
||||
|
||||
it("should create options table for key-value storage", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("options")
|
||||
.values({
|
||||
name: "site_title",
|
||||
value: JSON.stringify("My Site"),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const option = await db
|
||||
.selectFrom("options")
|
||||
.selectAll()
|
||||
.where("name", "=", "site_title")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(option).toBeDefined();
|
||||
expect(JSON.parse(option!.value)).toBe("My Site");
|
||||
});
|
||||
|
||||
it("should create audit_logs table with indexes", async () => {
|
||||
await runMigrations(db);
|
||||
|
||||
await db
|
||||
.insertInto("audit_logs")
|
||||
.values({
|
||||
id: "log-1",
|
||||
actor_id: "user-1",
|
||||
actor_ip: "192.168.1.1",
|
||||
action: "content:create",
|
||||
resource_type: "content",
|
||||
resource_id: "post-1",
|
||||
status: "success",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const logs = await db
|
||||
.selectFrom("audit_logs")
|
||||
.selectAll()
|
||||
.where("actor_id", "=", "user-1")
|
||||
.execute();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0]?.action).toBe("content:create");
|
||||
});
|
||||
});
|
||||
94
packages/core/tests/integration/fixture/.emdash/seed.json
Normal file
94
packages/core/tests/integration/fixture/.emdash/seed.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "E2E Test Fixture",
|
||||
"description": "Schema for E2E tests"
|
||||
},
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "categories",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "news", "label": "News" },
|
||||
{ "slug": "tutorials", "label": "Tutorials" },
|
||||
{ "slug": "opinion", "label": "Opinion" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"sections": [
|
||||
{
|
||||
"slug": "hero",
|
||||
"title": "Hero Section",
|
||||
"description": "Main hero area",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"_key": "b1",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "_key": "s1", "text": "Welcome to our site" }],
|
||||
"markDefs": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"labelSingular": "Post",
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"slug": "body",
|
||||
"label": "Body",
|
||||
"type": "portableText"
|
||||
},
|
||||
{
|
||||
"slug": "excerpt",
|
||||
"label": "Excerpt",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"slug": "theme_color",
|
||||
"label": "Theme Color",
|
||||
"type": "string",
|
||||
"widget": "color:picker"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"slug": "body",
|
||||
"label": "Body",
|
||||
"type": "portableText"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"bylines": [
|
||||
{
|
||||
"id": "fixture-editorial",
|
||||
"slug": "fixture-editorial",
|
||||
"displayName": "Fixture Editorial"
|
||||
}
|
||||
]
|
||||
}
|
||||
41
packages/core/tests/integration/fixture/astro.config.mjs
Normal file
41
packages/core/tests/integration/fixture/astro.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Minimal Astro config for e2e tests.
|
||||
*
|
||||
* Uses EMDASH_TEST_DB env var for the database path so each
|
||||
* test run gets an isolated database.
|
||||
*/
|
||||
import node from "@astrojs/node";
|
||||
import react from "@astrojs/react";
|
||||
import { colorPlugin } from "@emdashcms/plugin-color";
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
const dbUrl = process.env.EMDASH_TEST_DB || "file:./test.db";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: sqlite({ url: dbUrl }),
|
||||
plugins: [colorPlugin()],
|
||||
}),
|
||||
],
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "fr", "es"],
|
||||
fallback: { fr: "en", es: "en" },
|
||||
},
|
||||
devToolbar: { enabled: false },
|
||||
vite: {
|
||||
server: {
|
||||
fs: {
|
||||
// When running from a temp dir, node_modules is symlinked back to the
|
||||
// monorepo. Vite needs permission to serve files from the real paths.
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
39
packages/core/tests/integration/fixture/emdash-env.d.ts
vendored
Normal file
39
packages/core/tests/integration/fixture/emdash-env.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
// Generated by EmDash on dev server start
|
||||
// Do not edit manually
|
||||
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
body?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
body?: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
theme_color?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
16
packages/core/tests/integration/fixture/package.json
Normal file
16
packages/core/tests/integration/fixture/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "emdash-integration-fixture",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "catalog:",
|
||||
"@astrojs/react": "catalog:",
|
||||
"@emdashcms/auth": "workspace:*",
|
||||
"@emdashcms/plugin-color": "workspace:*",
|
||||
"astro": "catalog:",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"emdash": "workspace:*",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
}
|
||||
}
|
||||
1
packages/core/tests/integration/fixture/src/env.d.ts
vendored
Normal file
1
packages/core/tests/integration/fixture/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
---
|
||||
|
||||
<html>
|
||||
<body>
|
||||
<h1>Posts</h1>
|
||||
<ul id="post-list">
|
||||
{
|
||||
posts.map((p) => (
|
||||
<li>
|
||||
<a href={`/posts/${p.id}`}>{p.data.title}</a>
|
||||
{p.data.excerpt && <span class="excerpt">{p.data.excerpt}</span>}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{posts.length === 0 && <p id="empty">No posts</p>}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText, Comments, CommentForm } from "emdash/ui";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
if (!slug) return Astro.redirect("/404");
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
if (!post) return new Response("Not found", { status: 404 });
|
||||
---
|
||||
|
||||
<html>
|
||||
<body>
|
||||
<article>
|
||||
<h1 id="title">{post.data.title}</h1>
|
||||
{post.data.excerpt && <p id="excerpt">{post.data.excerpt}</p>}
|
||||
<div id="body"><PortableText value={post.data.body} /></div>
|
||||
</article>
|
||||
<Comments collection="posts" contentId={post.data.id} threaded />
|
||||
<CommentForm collection="posts" contentId={post.data.id} />
|
||||
</body>
|
||||
</html>
|
||||
5
packages/core/tests/integration/fixture/tsconfig.json
Normal file
5
packages/core/tests/integration/fixture/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": { "types": ["node"] },
|
||||
"include": ["src", ".astro/types.d.ts"]
|
||||
}
|
||||
839
packages/core/tests/integration/i18n/i18n.test.ts
Normal file
839
packages/core/tests/integration/i18n/i18n.test.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { FTSManager } from "../../../src/search/fts-manager.js";
|
||||
import { searchWithDb } from "../../../src/search/query.js";
|
||||
import { applySeed } from "../../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../../src/seed/types.js";
|
||||
import { validateSeed } from "../../../src/seed/validate.js";
|
||||
import { createPostFixture } from "../../utils/fixtures.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("i18n (Integration)", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
repo = new ContentRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
// ─── 1. Migration — i18n columns exist ──────────────────────────
|
||||
|
||||
describe("Migration — i18n columns", () => {
|
||||
it("should have locale and translation_group columns on content tables", async () => {
|
||||
const result = await sql<{ name: string }>`
|
||||
PRAGMA table_info(ec_post)
|
||||
`.execute(db);
|
||||
|
||||
const columnNames = result.rows.map((r) => r.name);
|
||||
expect(columnNames).toContain("locale");
|
||||
expect(columnNames).toContain("translation_group");
|
||||
});
|
||||
|
||||
it("should default locale to 'en'", async () => {
|
||||
const result = await sql<{ name: string; dflt_value: string | null }>`
|
||||
PRAGMA table_info(ec_post)
|
||||
`.execute(db);
|
||||
|
||||
const localeCol = result.rows.find((r) => r.name === "locale");
|
||||
expect(localeCol).toBeDefined();
|
||||
expect(localeCol!.dflt_value).toBe("'en'");
|
||||
});
|
||||
|
||||
it("should have translatable column on _emdash_fields", async () => {
|
||||
const result = await sql<{ name: string }>`
|
||||
PRAGMA table_info(_emdash_fields)
|
||||
`.execute(db);
|
||||
|
||||
const columnNames = result.rows.map((r) => r.name);
|
||||
expect(columnNames).toContain("translatable");
|
||||
});
|
||||
|
||||
it("should have compound unique constraint on slug+locale", async () => {
|
||||
// Insert same slug, different locale — should succeed
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, locale, translation_group, status, version, created_at, updated_at)
|
||||
VALUES ('id1', 'hello', 'en', 'id1', 'draft', 1, datetime('now'), datetime('now'))
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, locale, translation_group, status, version, created_at, updated_at)
|
||||
VALUES ('id2', 'hello', 'fr', 'id1', 'draft', 1, datetime('now'), datetime('now'))
|
||||
`.execute(db);
|
||||
|
||||
// Same slug, same locale — should fail
|
||||
await expect(
|
||||
sql`
|
||||
INSERT INTO ec_post (id, slug, locale, translation_group, status, version, created_at, updated_at)
|
||||
VALUES ('id3', 'hello', 'en', 'id3', 'draft', 1, datetime('now'), datetime('now'))
|
||||
`.execute(db),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should have locale and translation_group indexes", async () => {
|
||||
const result = await sql<{ name: string }>`
|
||||
PRAGMA index_list(ec_post)
|
||||
`.execute(db);
|
||||
|
||||
const indexNames = result.rows.map((r) => r.name);
|
||||
expect(indexNames).toContain("idx_ec_post_locale");
|
||||
expect(indexNames).toContain("idx_ec_post_translation_group");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. ContentRepository — locale-aware CRUD ───────────────────
|
||||
|
||||
describe("ContentRepository — locale-aware CRUD", () => {
|
||||
it("create() without locale defaults to 'en'", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
expect(post.locale).toBe("en");
|
||||
});
|
||||
|
||||
it("create() with explicit locale stores it", async () => {
|
||||
const post = await repo.create(createPostFixture({ locale: "fr", slug: "bonjour" }));
|
||||
expect(post.locale).toBe("fr");
|
||||
});
|
||||
|
||||
it("create() with translationOf links via translation_group", async () => {
|
||||
const enPost = await repo.create(createPostFixture({ slug: "hello-world", locale: "en" }));
|
||||
|
||||
const frPost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "bonjour-monde",
|
||||
locale: "fr",
|
||||
translationOf: enPost.id,
|
||||
data: { title: "Bonjour le Monde" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Both should share the same translation_group
|
||||
expect(frPost.translationGroup).toBe(enPost.translationGroup);
|
||||
// The group should be the original item's id (since it was first)
|
||||
expect(enPost.translationGroup).toBe(enPost.id);
|
||||
});
|
||||
|
||||
it("create() with translationOf on a chained translation uses the root group", async () => {
|
||||
const enPost = await repo.create(createPostFixture({ slug: "hello", locale: "en" }));
|
||||
|
||||
const frPost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "bonjour",
|
||||
locale: "fr",
|
||||
translationOf: enPost.id,
|
||||
data: { title: "Bonjour" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a third translation linked to the French version
|
||||
const dePost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "hallo",
|
||||
locale: "de",
|
||||
translationOf: frPost.id,
|
||||
data: { title: "Hallo" },
|
||||
}),
|
||||
);
|
||||
|
||||
// All three should share the same translation_group
|
||||
expect(dePost.translationGroup).toBe(enPost.id);
|
||||
expect(frPost.translationGroup).toBe(enPost.id);
|
||||
});
|
||||
|
||||
it("create() with translationOf pointing to non-existent ID throws", async () => {
|
||||
await expect(
|
||||
repo.create(
|
||||
createPostFixture({
|
||||
slug: "orphan",
|
||||
locale: "fr",
|
||||
translationOf: "NONEXISTENT_ID_12345678",
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Translation source content not found");
|
||||
});
|
||||
|
||||
it("same slug different locales are allowed", async () => {
|
||||
const en = await repo.create(createPostFixture({ slug: "about", locale: "en" }));
|
||||
const fr = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "about",
|
||||
locale: "fr",
|
||||
data: { title: "À propos" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(en.slug).toBe("about");
|
||||
expect(fr.slug).toBe("about");
|
||||
expect(en.id).not.toBe(fr.id);
|
||||
});
|
||||
|
||||
it("same slug same locale is rejected", async () => {
|
||||
await repo.create(createPostFixture({ slug: "unique-slug", locale: "en" }));
|
||||
|
||||
await expect(
|
||||
repo.create(
|
||||
createPostFixture({
|
||||
slug: "unique-slug",
|
||||
locale: "en",
|
||||
data: { title: "Duplicate" },
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── findBySlug ────────────────────────────────────────────────
|
||||
|
||||
it("findBySlug() without locale returns any match", async () => {
|
||||
await repo.create(createPostFixture({ slug: "shared-slug", locale: "en" }));
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "shared-slug",
|
||||
locale: "fr",
|
||||
data: { title: "Version FR" },
|
||||
}),
|
||||
);
|
||||
|
||||
const found = await repo.findBySlug("post", "shared-slug");
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.slug).toBe("shared-slug");
|
||||
});
|
||||
|
||||
it("findBySlug() with locale filters to that locale", async () => {
|
||||
await repo.create(createPostFixture({ slug: "about", locale: "en" }));
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "about",
|
||||
locale: "fr",
|
||||
data: { title: "À propos" },
|
||||
}),
|
||||
);
|
||||
|
||||
const en = await repo.findBySlug("post", "about", "en");
|
||||
expect(en).not.toBeNull();
|
||||
expect(en!.locale).toBe("en");
|
||||
|
||||
const fr = await repo.findBySlug("post", "about", "fr");
|
||||
expect(fr).not.toBeNull();
|
||||
expect(fr!.locale).toBe("fr");
|
||||
|
||||
const de = await repo.findBySlug("post", "about", "de");
|
||||
expect(de).toBeNull();
|
||||
});
|
||||
|
||||
// ── findByIdOrSlug ────────────────────────────────────────────
|
||||
|
||||
it("findByIdOrSlug() — ID lookup ignores locale param", async () => {
|
||||
const post = await repo.create(createPostFixture({ slug: "test-post", locale: "en" }));
|
||||
|
||||
// ID lookup should find it regardless of locale param
|
||||
const found = await repo.findByIdOrSlug("post", post.id, "fr");
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(post.id);
|
||||
expect(found!.locale).toBe("en");
|
||||
});
|
||||
|
||||
it("findByIdOrSlug() — slug lookup respects locale", async () => {
|
||||
const enPost = await repo.create(createPostFixture({ slug: "test", locale: "en" }));
|
||||
const frPost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "test",
|
||||
locale: "fr",
|
||||
data: { title: "Test FR" },
|
||||
}),
|
||||
);
|
||||
|
||||
const foundEn = await repo.findByIdOrSlug("post", "test", "en");
|
||||
expect(foundEn).not.toBeNull();
|
||||
expect(foundEn!.id).toBe(enPost.id);
|
||||
|
||||
const foundFr = await repo.findByIdOrSlug("post", "test", "fr");
|
||||
expect(foundFr).not.toBeNull();
|
||||
expect(foundFr!.id).toBe(frPost.id);
|
||||
});
|
||||
|
||||
// ── findMany ──────────────────────────────────────────────────
|
||||
|
||||
it("findMany() without locale returns all locales", async () => {
|
||||
await repo.create(createPostFixture({ slug: "en-post", locale: "en" }));
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "fr-post",
|
||||
locale: "fr",
|
||||
data: { title: "Post FR" },
|
||||
}),
|
||||
);
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "de-post",
|
||||
locale: "de",
|
||||
data: { title: "Post DE" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await repo.findMany("post");
|
||||
expect(result.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("findMany() with locale filters to that locale", async () => {
|
||||
await repo.create(createPostFixture({ slug: "en-post", locale: "en" }));
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "fr-post",
|
||||
locale: "fr",
|
||||
data: { title: "Post FR" },
|
||||
}),
|
||||
);
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "de-post",
|
||||
locale: "de",
|
||||
data: { title: "Post DE" },
|
||||
}),
|
||||
);
|
||||
|
||||
const frResult = await repo.findMany("post", {
|
||||
where: { locale: "fr" },
|
||||
});
|
||||
expect(frResult.items).toHaveLength(1);
|
||||
expect(frResult.items[0]!.locale).toBe("fr");
|
||||
});
|
||||
|
||||
// ── count ─────────────────────────────────────────────────────
|
||||
|
||||
it("count() without locale counts all", async () => {
|
||||
await repo.create(createPostFixture({ slug: "post-en", locale: "en" }));
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "post-fr",
|
||||
locale: "fr",
|
||||
data: { title: "FR" },
|
||||
}),
|
||||
);
|
||||
|
||||
const total = await repo.count("post");
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
it("count() with locale counts only that locale", async () => {
|
||||
await repo.create(createPostFixture({ slug: "post-en", locale: "en" }));
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "post-fr",
|
||||
locale: "fr",
|
||||
data: { title: "FR" },
|
||||
}),
|
||||
);
|
||||
|
||||
const enCount = await repo.count("post", { locale: "en" });
|
||||
expect(enCount).toBe(1);
|
||||
|
||||
const deCount = await repo.count("post", { locale: "de" });
|
||||
expect(deCount).toBe(0);
|
||||
});
|
||||
|
||||
// ── findTranslations ──────────────────────────────────────────
|
||||
|
||||
it("findTranslations() returns all locales for a translation group", async () => {
|
||||
const enPost = await repo.create(createPostFixture({ slug: "hello", locale: "en" }));
|
||||
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "bonjour",
|
||||
locale: "fr",
|
||||
translationOf: enPost.id,
|
||||
data: { title: "Bonjour" },
|
||||
}),
|
||||
);
|
||||
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "hallo",
|
||||
locale: "de",
|
||||
translationOf: enPost.id,
|
||||
data: { title: "Hallo" },
|
||||
}),
|
||||
);
|
||||
|
||||
const translations = await repo.findTranslations("post", enPost.translationGroup!);
|
||||
|
||||
expect(translations).toHaveLength(3);
|
||||
|
||||
const locales = translations
|
||||
.map((t) => t.locale)
|
||||
.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
|
||||
expect(locales).toEqual(["de", "en", "fr"]);
|
||||
});
|
||||
|
||||
it("findTranslations() returns only non-deleted items", async () => {
|
||||
const enPost = await repo.create(createPostFixture({ slug: "hello", locale: "en" }));
|
||||
|
||||
const frPost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "bonjour",
|
||||
locale: "fr",
|
||||
translationOf: enPost.id,
|
||||
data: { title: "Bonjour" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Soft-delete the French translation
|
||||
await repo.delete("post", frPost.id);
|
||||
|
||||
const translations = await repo.findTranslations("post", enPost.translationGroup!);
|
||||
|
||||
expect(translations).toHaveLength(1);
|
||||
expect(translations[0]!.locale).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. FTS — locale-aware search ───────────────────────────────
|
||||
|
||||
describe("FTS — locale-aware search", () => {
|
||||
let registry: SchemaRegistry;
|
||||
let ftsManager: FTSManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
registry = new SchemaRegistry(db);
|
||||
ftsManager = new FTSManager(db);
|
||||
|
||||
// Mark title as searchable and enable FTS
|
||||
await registry.updateField("post", "title", { searchable: true });
|
||||
await ftsManager.enableSearch("post");
|
||||
});
|
||||
|
||||
it("search with locale filter returns only that locale's results", async () => {
|
||||
// Create published posts in different locales
|
||||
const enPost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "hello-world",
|
||||
locale: "en",
|
||||
status: "published",
|
||||
data: { title: "Hello World" },
|
||||
}),
|
||||
);
|
||||
|
||||
const frPost = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "bonjour-monde",
|
||||
locale: "fr",
|
||||
status: "published",
|
||||
data: { title: "Bonjour le Monde" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Search for "world" — English only
|
||||
const enResults = await searchWithDb(db, "Hello", {
|
||||
collections: ["post"],
|
||||
locale: "en",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(enResults.items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(enResults.items.every((r) => r.locale === "en")).toBe(true);
|
||||
expect(enResults.items.some((r) => r.id === enPost.id)).toBe(true);
|
||||
|
||||
// Search for "Bonjour" — French only
|
||||
const frResults = await searchWithDb(db, "Bonjour", {
|
||||
collections: ["post"],
|
||||
locale: "fr",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(frResults.items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(frResults.items.every((r) => r.locale === "fr")).toBe(true);
|
||||
expect(frResults.items.some((r) => r.id === frPost.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("search without locale returns results from all locales", async () => {
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "universal-en",
|
||||
locale: "en",
|
||||
status: "published",
|
||||
data: { title: "Universal Content" },
|
||||
}),
|
||||
);
|
||||
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "universal-fr",
|
||||
locale: "fr",
|
||||
status: "published",
|
||||
data: { title: "Universal Contenu" },
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await searchWithDb(db, "Universal", {
|
||||
collections: ["post"],
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(results.items).toHaveLength(2);
|
||||
const locales = results.items.map((r) => r.locale).toSorted();
|
||||
expect(locales).toEqual(["en", "fr"]);
|
||||
});
|
||||
|
||||
it("FTS index includes locale column", async () => {
|
||||
// Verify the FTS table has the locale column by checking structure
|
||||
const exists = await ftsManager.ftsTableExists("post");
|
||||
expect(exists).toBe(true);
|
||||
|
||||
// Create a post and verify it appears in FTS results with locale
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "fts-test",
|
||||
locale: "ja",
|
||||
status: "published",
|
||||
data: { title: "FTS Locale Test" },
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await searchWithDb(db, "FTS Locale", {
|
||||
collections: ["post"],
|
||||
locale: "ja",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(results.items).toHaveLength(1);
|
||||
expect(results.items[0]!.locale).toBe("ja");
|
||||
});
|
||||
|
||||
it("rebuilt index preserves locale-aware search", async () => {
|
||||
// Create content before rebuild
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "pre-rebuild-en",
|
||||
locale: "en",
|
||||
status: "published",
|
||||
data: { title: "Rebuild Test English" },
|
||||
}),
|
||||
);
|
||||
|
||||
await repo.create(
|
||||
createPostFixture({
|
||||
slug: "pre-rebuild-fr",
|
||||
locale: "fr",
|
||||
status: "published",
|
||||
data: { title: "Rebuild Test French" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Rebuild the index
|
||||
await ftsManager.rebuildIndex("post", ["title"]);
|
||||
|
||||
// Verify locale-aware search still works
|
||||
const enResults = await searchWithDb(db, "Rebuild", {
|
||||
collections: ["post"],
|
||||
locale: "en",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(enResults.items).toHaveLength(1);
|
||||
expect(enResults.items[0]!.locale).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 4. Seed — locale-aware content ─────────────────────────────
|
||||
|
||||
describe("Seed — locale-aware content", () => {
|
||||
it("applySeed() creates content with locale and translationOf", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
post: [
|
||||
{
|
||||
id: "welcome",
|
||||
slug: "welcome",
|
||||
locale: "en",
|
||||
status: "published",
|
||||
data: { title: "Welcome" },
|
||||
},
|
||||
{
|
||||
id: "welcome-fr",
|
||||
slug: "bienvenue",
|
||||
locale: "fr",
|
||||
translationOf: "welcome",
|
||||
status: "draft",
|
||||
data: { title: "Bienvenue" },
|
||||
},
|
||||
{
|
||||
id: "welcome-de",
|
||||
slug: "willkommen",
|
||||
locale: "de",
|
||||
translationOf: "welcome",
|
||||
status: "published",
|
||||
data: { title: "Willkommen" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.content.created).toBe(3);
|
||||
expect(result.content.skipped).toBe(0);
|
||||
|
||||
// Verify the entries exist with correct locales
|
||||
const seedRepo = new ContentRepository(db);
|
||||
const enPost = await seedRepo.findBySlug("post", "welcome", "en");
|
||||
const frPost = await seedRepo.findBySlug("post", "bienvenue", "fr");
|
||||
const dePost = await seedRepo.findBySlug("post", "willkommen", "de");
|
||||
|
||||
expect(enPost).not.toBeNull();
|
||||
expect(frPost).not.toBeNull();
|
||||
expect(dePost).not.toBeNull();
|
||||
|
||||
expect(enPost!.locale).toBe("en");
|
||||
expect(frPost!.locale).toBe("fr");
|
||||
expect(dePost!.locale).toBe("de");
|
||||
|
||||
// All should share the same translation_group
|
||||
expect(frPost!.translationGroup).toBe(enPost!.translationGroup);
|
||||
expect(dePost!.translationGroup).toBe(enPost!.translationGroup);
|
||||
});
|
||||
|
||||
it("applySeed() without locale falls back to default", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
post: [
|
||||
{
|
||||
id: "plain",
|
||||
slug: "plain-post",
|
||||
data: { title: "No Locale" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
expect(result.content.created).toBe(1);
|
||||
|
||||
const plainRepo = new ContentRepository(db);
|
||||
const post = await plainRepo.findBySlug("post", "plain-post");
|
||||
expect(post).not.toBeNull();
|
||||
expect(post!.locale).toBe("en"); // default
|
||||
expect(post!.translationGroup).toBe(post!.id); // self-reference
|
||||
});
|
||||
|
||||
it("applySeed() skips existing entries with locale-aware lookup", async () => {
|
||||
// Pre-create an entry
|
||||
const skipRepo = new ContentRepository(db);
|
||||
await skipRepo.create(createPostFixture({ slug: "existing", locale: "fr" }));
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
post: [
|
||||
{
|
||||
id: "existing",
|
||||
slug: "existing",
|
||||
locale: "fr",
|
||||
data: { title: "Should Skip" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
expect(result.content.skipped).toBe(1);
|
||||
expect(result.content.created).toBe(0);
|
||||
});
|
||||
|
||||
it("applySeed() rejects missing translationOf via validation", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
content: {
|
||||
post: [
|
||||
{
|
||||
id: "orphan-fr",
|
||||
slug: "orphelin",
|
||||
locale: "fr",
|
||||
translationOf: "nonexistent",
|
||||
data: { title: "Orphan" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Validation catches the bad reference before applySeed runs
|
||||
await expect(applySeed(db, seed, { includeContent: true })).rejects.toThrow(
|
||||
'references "nonexistent" which is not in this collection',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 5. Seed validation — i18n fields ───────────────────────────
|
||||
|
||||
describe("Seed validation — i18n fields", () => {
|
||||
it("validates translationOf requires locale", () => {
|
||||
const seed = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{ id: "en", slug: "hello", data: { title: "Hello" } },
|
||||
{
|
||||
id: "fr",
|
||||
slug: "bonjour",
|
||||
translationOf: "en",
|
||||
data: { title: "Bonjour" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSeed(seed);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("locale is required when translationOf"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("validates translationOf references exist", () => {
|
||||
const seed = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "fr",
|
||||
slug: "bonjour",
|
||||
locale: "fr",
|
||||
translationOf: "nonexistent",
|
||||
data: { title: "Bonjour" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSeed(seed);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.errors.some((e) => e.includes('references "nonexistent" which is not in')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("valid seed with i18n fields passes validation", () => {
|
||||
const seed = {
|
||||
version: "1",
|
||||
content: {
|
||||
posts: [
|
||||
{ id: "en", slug: "hello", locale: "en", data: { title: "Hello" } },
|
||||
{
|
||||
id: "fr",
|
||||
slug: "bonjour",
|
||||
locale: "fr",
|
||||
translationOf: "en",
|
||||
data: { title: "Bonjour" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSeed(seed);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 6. Non-i18n regression ─────────────────────────────────────
|
||||
|
||||
describe("Non-i18n regression", () => {
|
||||
it("content created without locale has locale 'en'", async () => {
|
||||
const post = await repo.create({
|
||||
type: "post",
|
||||
slug: "no-locale",
|
||||
data: { title: "No Locale Specified" },
|
||||
});
|
||||
|
||||
expect(post.locale).toBe("en");
|
||||
});
|
||||
|
||||
it("findMany without locale param returns all results", async () => {
|
||||
await repo.create(createPostFixture({ slug: "post-1" }));
|
||||
await repo.create(createPostFixture({ slug: "post-2" }));
|
||||
|
||||
const result = await repo.findMany("post");
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("findBySlug works without locale param", async () => {
|
||||
const created = await repo.create(createPostFixture({ slug: "find-me" }));
|
||||
const found = await repo.findBySlug("post", "find-me");
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("findByIdOrSlug works without locale param", async () => {
|
||||
const created = await repo.create(createPostFixture({ slug: "lookup-test" }));
|
||||
|
||||
// By slug
|
||||
const bySlug = await repo.findByIdOrSlug("post", "lookup-test");
|
||||
expect(bySlug).not.toBeNull();
|
||||
expect(bySlug!.id).toBe(created.id);
|
||||
|
||||
// By ID
|
||||
const byId = await repo.findByIdOrSlug("post", created.id);
|
||||
expect(byId).not.toBeNull();
|
||||
expect(byId!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("slug uniqueness is still enforced within the same locale", async () => {
|
||||
await repo.create(createPostFixture({ slug: "dupe-test" }));
|
||||
|
||||
// Same slug, same default locale — should fail
|
||||
await expect(repo.create(createPostFixture({ slug: "dupe-test" }))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("count works without locale param", async () => {
|
||||
await repo.create(createPostFixture({ slug: "count-1" }));
|
||||
await repo.create(createPostFixture({ slug: "count-2" }));
|
||||
|
||||
const count = await repo.count("post");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("translation_group is auto-set to item id when no translationOf", async () => {
|
||||
const post = await repo.create(createPostFixture({ slug: "standalone" }));
|
||||
|
||||
expect(post.translationGroup).toBe(post.id);
|
||||
});
|
||||
|
||||
it("existing CRUD operations are unaffected by i18n columns", async () => {
|
||||
// Create
|
||||
const post = await repo.create(createPostFixture({ slug: "crud-test", status: "draft" }));
|
||||
expect(post.status).toBe("draft");
|
||||
|
||||
// Update
|
||||
const updated = await repo.update("post", post.id, {
|
||||
data: { title: "Updated Title" },
|
||||
});
|
||||
expect(updated.data.title).toBe("Updated Title");
|
||||
expect(updated.locale).toBe("en"); // locale unchanged
|
||||
|
||||
// Delete (soft)
|
||||
const deleted = await repo.delete("post", post.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Should not be found
|
||||
const notFound = await repo.findById("post", post.id);
|
||||
expect(notFound).toBeNull();
|
||||
|
||||
// Restore
|
||||
const restored = await repo.restore("post", post.id);
|
||||
expect(restored).toBe(true);
|
||||
|
||||
const found = await repo.findById("post", post.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.locale).toBe("en");
|
||||
});
|
||||
});
|
||||
});
|
||||
193
packages/core/tests/integration/openapi/openapi.test.ts
Normal file
193
packages/core/tests/integration/openapi/openapi.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generateOpenApiDocument } from "../../../src/api/openapi/document.js";
|
||||
|
||||
describe("OpenAPI spec validation", () => {
|
||||
it("produces a valid OpenAPI 3.1 document", async () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
|
||||
// swagger-parser.validate() resolves $refs and validates against the OAS JSON Schema.
|
||||
// It throws if the document is invalid.
|
||||
const validated = await SwaggerParser.validate(structuredClone(doc));
|
||||
|
||||
expect(validated.openapi).toBe("3.1.0");
|
||||
expect(validated.info.title).toBe("EmDash CMS API");
|
||||
});
|
||||
|
||||
it("resolves all $ref pointers without errors", async () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
|
||||
// dereference() resolves every $ref in the document tree.
|
||||
// If any $ref points to a missing schema, it throws.
|
||||
const dereferenced = await SwaggerParser.dereference(structuredClone(doc));
|
||||
|
||||
// After dereferencing, no $ref keys should remain.
|
||||
// Use a replacer to handle circular references (e.g. PublicComment.replies)
|
||||
const seen = new WeakSet();
|
||||
const json = JSON.stringify(dereferenced, (_key, value) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
expect(json).not.toContain('"$ref"');
|
||||
});
|
||||
|
||||
it("has all content paths with responses", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = doc.paths ?? {};
|
||||
|
||||
for (const [path, pathItem] of Object.entries(paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const op = (pathItem as Record<string, unknown>)?.[method] as
|
||||
| { responses?: Record<string, unknown>; operationId?: string }
|
||||
| undefined;
|
||||
if (!op) continue;
|
||||
|
||||
// Every operation must have responses
|
||||
expect(op.responses, `${method.toUpperCase()} ${path} missing responses`).toBeDefined();
|
||||
|
||||
// Every operation must have an operationId
|
||||
expect(op.operationId, `${method.toUpperCase()} ${path} missing operationId`).toBeDefined();
|
||||
|
||||
// Every operation must have at least one success response (2xx)
|
||||
const statusCodes = Object.keys(op.responses ?? {});
|
||||
const has2xx = statusCodes.some((code) => code.startsWith("2"));
|
||||
expect(has2xx, `${method.toUpperCase()} ${path} has no 2xx response`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps all success responses in the { data } envelope", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = doc.paths ?? {};
|
||||
|
||||
for (const [path, pathItem] of Object.entries(paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const op = (pathItem as Record<string, unknown>)?.[method] as
|
||||
| { responses?: Record<string, Record<string, unknown>> }
|
||||
| undefined;
|
||||
if (!op?.responses) continue;
|
||||
|
||||
for (const [statusCode, response] of Object.entries(op.responses)) {
|
||||
if (!statusCode.startsWith("2")) continue;
|
||||
|
||||
const content = (response as Record<string, unknown>)?.content as
|
||||
| Record<string, { schema?: Record<string, unknown> }>
|
||||
| undefined;
|
||||
if (!content?.["application/json"]) continue;
|
||||
|
||||
const schema = content["application/json"].schema;
|
||||
expect(
|
||||
schema,
|
||||
`${method.toUpperCase()} ${path} ${statusCode} missing schema`,
|
||||
).toBeDefined();
|
||||
|
||||
// The envelope must have a "data" property (either directly or via $ref that wraps it)
|
||||
// Check for direct properties or allOf/oneOf patterns
|
||||
const props = (schema as Record<string, unknown>)?.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (props) {
|
||||
expect(
|
||||
props,
|
||||
`${method.toUpperCase()} ${path} ${statusCode} envelope missing "data" property`,
|
||||
).toHaveProperty("data");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("includes auth error responses on authenticated endpoints", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = doc.paths ?? {};
|
||||
|
||||
// Public endpoints that don't require authentication
|
||||
const publicPaths = new Set(["/_emdash/api/comments/{collection}/{contentId}"]);
|
||||
|
||||
for (const [path, pathItem] of Object.entries(paths)) {
|
||||
if (publicPaths.has(path)) continue;
|
||||
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const op = (pathItem as Record<string, unknown>)?.[method] as
|
||||
| { responses?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (!op?.responses) continue;
|
||||
|
||||
const statusCodes = Object.keys(op.responses);
|
||||
expect(statusCodes, `${method.toUpperCase()} ${path} missing 401`).toContain("401");
|
||||
expect(statusCodes, `${method.toUpperCase()} ${path} missing 403`).toContain("403");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no duplicate operation IDs across all paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const operationIds: string[] = [];
|
||||
|
||||
for (const pathItem of Object.values(doc.paths ?? {})) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const op = (pathItem as Record<string, unknown>)?.[method] as
|
||||
| { operationId?: string }
|
||||
| undefined;
|
||||
if (op?.operationId) {
|
||||
operationIds.push(op.operationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const id of operationIds) {
|
||||
expect(seen.has(id), `duplicate operationId: ${id}`).toBe(false);
|
||||
seen.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers referenced schemas as reusable components", async () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const schemas = doc.components?.schemas ?? {};
|
||||
const schemaNames = Object.keys(schemas);
|
||||
|
||||
// Should have a reasonable number of reusable schemas
|
||||
expect(schemaNames.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// All registered schemas should be valid objects with type or properties
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
expect(schema, `component schema "${name}" is not an object`).toBeTypeOf("object");
|
||||
}
|
||||
});
|
||||
|
||||
it("uses consistent error response shape across all error codes", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = doc.paths ?? {};
|
||||
|
||||
for (const [path, pathItem] of Object.entries(paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const op = (pathItem as Record<string, unknown>)?.[method] as
|
||||
| { responses?: Record<string, Record<string, unknown>> }
|
||||
| undefined;
|
||||
if (!op?.responses) continue;
|
||||
|
||||
for (const [statusCode, response] of Object.entries(op.responses)) {
|
||||
// Only check error responses (4xx, 5xx)
|
||||
const code = Number(statusCode);
|
||||
if (code < 400) continue;
|
||||
|
||||
const content = (response as Record<string, unknown>)?.content as
|
||||
| Record<string, { schema?: Record<string, unknown> }>
|
||||
| undefined;
|
||||
if (!content?.["application/json"]) continue;
|
||||
|
||||
const schema = content["application/json"].schema;
|
||||
expect(
|
||||
schema,
|
||||
`${method.toUpperCase()} ${path} ${statusCode} error missing schema`,
|
||||
).toBeDefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
732
packages/core/tests/integration/plugins/capabilities.test.ts
Normal file
732
packages/core/tests/integration/plugins/capabilities.test.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
/**
|
||||
* Capability Enforcement Integration Tests (v2)
|
||||
*
|
||||
* Tests the capability-based access gating in the v2 plugin context.
|
||||
* v2 always enforces capabilities - there's no "trusted mode" bypass.
|
||||
*
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect, sql } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import { OptionsRepository } from "../../../src/database/repositories/options.js";
|
||||
import { UserRepository } from "../../../src/database/repositories/user.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import {
|
||||
PluginContextFactory,
|
||||
createContentAccess,
|
||||
createContentAccessWithWrite,
|
||||
createHttpAccess,
|
||||
createUnrestrictedHttpAccess,
|
||||
createBlockedHttpAccess,
|
||||
createLogAccess,
|
||||
createStorageAccess,
|
||||
createKVAccess,
|
||||
createSiteInfo,
|
||||
createUrlHelper,
|
||||
createUserAccess,
|
||||
} from "../../../src/plugins/context.js";
|
||||
import type { ResolvedPlugin } from "../../../src/plugins/types.js";
|
||||
|
||||
// Test regex patterns
|
||||
const NOT_ALLOWED_FETCH_REGEX = /not allowed to fetch from host/;
|
||||
const NO_ALLOWED_FETCH_REGEX = /not allowed to fetch/;
|
||||
const NO_NETWORK_FETCH_REGEX = /does not have the "network:fetch" capability/;
|
||||
|
||||
/**
|
||||
* Create a minimal resolved plugin for testing
|
||||
*/
|
||||
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: {
|
||||
pages: [],
|
||||
widgets: [],
|
||||
fieldWidgets: {},
|
||||
},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
settings: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Capability Enforcement Integration (v2)", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqliteDb = new Database(":memory:");
|
||||
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({
|
||||
database: sqliteDb,
|
||||
}),
|
||||
});
|
||||
|
||||
// Run migrations
|
||||
await runMigrations(db);
|
||||
|
||||
// Create test content table with actual field columns (not JSON data column)
|
||||
// The ContentRepository expects real columns for each field
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS ec_posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
author_id TEXT,
|
||||
primary_byline_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
published_at TEXT,
|
||||
deleted_at TEXT,
|
||||
version INTEGER DEFAULT 1,
|
||||
locale TEXT NOT NULL DEFAULT 'en',
|
||||
translation_group TEXT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
UNIQUE(slug, locale)
|
||||
)
|
||||
`.execute(db);
|
||||
|
||||
// Insert test content with actual column values
|
||||
await sql`
|
||||
INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group)
|
||||
VALUES
|
||||
('post-1', 'hello-world', 'published', 'Hello World', 'Content 1', 'en', 'post-1'),
|
||||
('post-2', 'second-post', 'draft', 'Second Post', 'Content 2', 'en', 'post-2')
|
||||
`.execute(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
describe("Content Access", () => {
|
||||
describe("createContentAccess (read-only)", () => {
|
||||
it("can read content by ID", async () => {
|
||||
const access = createContentAccess(db);
|
||||
const post = await access.get("posts", "post-1");
|
||||
|
||||
expect(post).not.toBeNull();
|
||||
expect(post!.id).toBe("post-1");
|
||||
expect(post!.data.title).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("can list content", async () => {
|
||||
const access = createContentAccess(db);
|
||||
const result = await access.list("posts");
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null for non-existent content", async () => {
|
||||
const access = createContentAccess(db);
|
||||
const post = await access.get("posts", "non-existent");
|
||||
expect(post).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContentAccessWithWrite", () => {
|
||||
it("includes read methods", async () => {
|
||||
const access = createContentAccessWithWrite(db);
|
||||
|
||||
expect(typeof access.get).toBe("function");
|
||||
expect(typeof access.list).toBe("function");
|
||||
});
|
||||
|
||||
it("includes write methods", async () => {
|
||||
const access = createContentAccessWithWrite(db);
|
||||
|
||||
expect(typeof access.create).toBe("function");
|
||||
expect(typeof access.update).toBe("function");
|
||||
expect(typeof access.delete).toBe("function");
|
||||
});
|
||||
|
||||
it("can create new content", async () => {
|
||||
const access = createContentAccessWithWrite(db);
|
||||
|
||||
const created = await access.create("posts", {
|
||||
title: "New Post",
|
||||
content: "New content",
|
||||
});
|
||||
|
||||
expect(created.id).toBeDefined();
|
||||
expect(created.data.title).toBe("New Post");
|
||||
|
||||
// Verify it was created
|
||||
const found = await access.get("posts", created.id);
|
||||
expect(found).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP Access", () => {
|
||||
describe("createHttpAccess (with host restrictions)", () => {
|
||||
it("allows requests to allowed hosts", async () => {
|
||||
const http = createHttpAccess("test-plugin", ["example.com"]);
|
||||
|
||||
// We can't actually make the request in tests, but we can verify
|
||||
// the function doesn't throw for allowed hosts
|
||||
expect(typeof http.fetch).toBe("function");
|
||||
});
|
||||
|
||||
it("blocks requests to non-allowed hosts", async () => {
|
||||
const http = createHttpAccess("test-plugin", ["example.com"]);
|
||||
|
||||
await expect(http.fetch("https://evil.com/api")).rejects.toThrow(NOT_ALLOWED_FETCH_REGEX);
|
||||
});
|
||||
|
||||
it("supports wildcard host patterns", { timeout: 15000 }, async () => {
|
||||
const http = createHttpAccess("test-plugin", ["*.example.com"]);
|
||||
|
||||
// Should not throw for subdomains
|
||||
// (Can't test actual fetch, but verify pattern matching logic)
|
||||
await expect(http.fetch("https://api.example.com/test")).rejects.not.toThrow(
|
||||
NO_ALLOWED_FETCH_REGEX,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBlockedHttpAccess", () => {
|
||||
it("always throws", async () => {
|
||||
const http = createBlockedHttpAccess("no-network-plugin");
|
||||
|
||||
await expect(http.fetch("https://example.com")).rejects.toThrow(NO_NETWORK_FETCH_REGEX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUnrestrictedHttpAccess", () => {
|
||||
it("returns an HttpAccess with a fetch function", () => {
|
||||
const http = createUnrestrictedHttpAccess("unrestricted-plugin");
|
||||
expect(typeof http.fetch).toBe("function");
|
||||
});
|
||||
|
||||
it("does not throw for any host", async () => {
|
||||
const http = createUnrestrictedHttpAccess("unrestricted-plugin");
|
||||
// Can't make a real request in tests, but verify it doesn't throw a
|
||||
// host-validation error — it will throw a network error instead.
|
||||
await expect(http.fetch("https://any-host-at-all.example.com/test")).rejects.not.toThrow(
|
||||
NOT_ALLOWED_FETCH_REGEX,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Storage Access", () => {
|
||||
it("creates collection accessors from config", () => {
|
||||
const storage = createStorageAccess(db, "test-plugin", {
|
||||
events: { indexes: ["type"] },
|
||||
cache: { indexes: ["key"] },
|
||||
});
|
||||
|
||||
expect(storage.events).toBeDefined();
|
||||
expect(storage.cache).toBeDefined();
|
||||
});
|
||||
|
||||
it("provides full StorageCollection API", () => {
|
||||
const storage = createStorageAccess(db, "test-plugin", {
|
||||
items: { indexes: [] },
|
||||
});
|
||||
|
||||
const collection = storage.items;
|
||||
expect(typeof collection.get).toBe("function");
|
||||
expect(typeof collection.put).toBe("function");
|
||||
expect(typeof collection.delete).toBe("function");
|
||||
expect(typeof collection.exists).toBe("function");
|
||||
expect(typeof collection.getMany).toBe("function");
|
||||
expect(typeof collection.putMany).toBe("function");
|
||||
expect(typeof collection.deleteMany).toBe("function");
|
||||
expect(typeof collection.query).toBe("function");
|
||||
expect(typeof collection.count).toBe("function");
|
||||
});
|
||||
|
||||
it("isolates storage between plugins", async () => {
|
||||
const storage1 = createStorageAccess(db, "plugin-1", {
|
||||
items: { indexes: [] },
|
||||
});
|
||||
const storage2 = createStorageAccess(db, "plugin-2", {
|
||||
items: { indexes: [] },
|
||||
});
|
||||
|
||||
await storage1.items.put("doc-1", { value: "from plugin 1" });
|
||||
|
||||
// Plugin 2 should not see plugin 1's data
|
||||
const fromPlugin2 = await storage2.items.get("doc-1");
|
||||
expect(fromPlugin2).toBeNull();
|
||||
|
||||
// Plugin 1 should still see its data
|
||||
const fromPlugin1 = await storage1.items.get("doc-1");
|
||||
expect(fromPlugin1).toEqual({ value: "from plugin 1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("KV Access", () => {
|
||||
it("prefixes keys with plugin ID", async () => {
|
||||
const optionsRepo = new OptionsRepository(db);
|
||||
const kv = createKVAccess(optionsRepo, "test-plugin");
|
||||
|
||||
await kv.set("my-key", { foo: "bar" });
|
||||
|
||||
// Verify the key is prefixed in the database
|
||||
const rawValue = await optionsRepo.get("plugin:test-plugin:my-key");
|
||||
expect(rawValue).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("isolates KV between plugins", async () => {
|
||||
const optionsRepo = new OptionsRepository(db);
|
||||
const kv1 = createKVAccess(optionsRepo, "plugin-1");
|
||||
const kv2 = createKVAccess(optionsRepo, "plugin-2");
|
||||
|
||||
await kv1.set("shared-key", "value from 1");
|
||||
await kv2.set("shared-key", "value from 2");
|
||||
|
||||
expect(await kv1.get("shared-key")).toBe("value from 1");
|
||||
expect(await kv2.get("shared-key")).toBe("value from 2");
|
||||
});
|
||||
|
||||
it("supports listing keys with prefix", async () => {
|
||||
const optionsRepo = new OptionsRepository(db);
|
||||
const kv = createKVAccess(optionsRepo, "test-plugin");
|
||||
|
||||
await kv.set("settings:theme", "dark");
|
||||
await kv.set("settings:lang", "en");
|
||||
await kv.set("cache:user-1", { name: "John" });
|
||||
|
||||
const settings = await kv.list("settings:");
|
||||
expect(settings).toHaveLength(2);
|
||||
expect(settings.map((s) => s.key).toSorted()).toEqual(["settings:lang", "settings:theme"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Log Access", () => {
|
||||
it("prefixes messages with plugin ID", () => {
|
||||
const log = createLogAccess("test-plugin");
|
||||
|
||||
// These just verify the methods exist and don't throw
|
||||
expect(() => log.debug("test message")).not.toThrow();
|
||||
expect(() => log.info("test message", { extra: "data" })).not.toThrow();
|
||||
expect(() => log.warn("test warning")).not.toThrow();
|
||||
expect(() => log.error("test error")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginContextFactory", () => {
|
||||
it("creates context with capability-gated access", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const readOnlyPlugin = createTestPlugin({
|
||||
id: "reader",
|
||||
capabilities: ["read:content"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(readOnlyPlugin);
|
||||
|
||||
// Content should be read-only (no create/update/delete)
|
||||
expect(ctx.content).toBeDefined();
|
||||
expect(typeof ctx.content!.get).toBe("function");
|
||||
expect(typeof ctx.content!.list).toBe("function");
|
||||
expect("create" in ctx.content!).toBe(false);
|
||||
});
|
||||
|
||||
it("provides undefined content for plugins without capability", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const noContentPlugin = createTestPlugin({
|
||||
id: "no-content",
|
||||
capabilities: ["network:fetch"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(noContentPlugin);
|
||||
expect(ctx.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("provides http for plugins with network:fetch", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const networkPlugin = createTestPlugin({
|
||||
id: "network",
|
||||
capabilities: ["network:fetch"],
|
||||
allowedHosts: ["api.example.com"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(networkPlugin);
|
||||
expect(ctx.http).toBeDefined();
|
||||
expect(typeof ctx.http!.fetch).toBe("function");
|
||||
});
|
||||
|
||||
it("provides undefined http for plugins without capability", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const noNetworkPlugin = createTestPlugin({
|
||||
id: "no-network",
|
||||
capabilities: [],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(noNetworkPlugin);
|
||||
expect(ctx.http).toBeUndefined();
|
||||
});
|
||||
|
||||
it("provides unrestricted http for plugins with network:fetch:any", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "unrestricted-network",
|
||||
capabilities: ["network:fetch:any", "network:fetch"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(plugin);
|
||||
expect(ctx.http).toBeDefined();
|
||||
expect(typeof ctx.http!.fetch).toBe("function");
|
||||
});
|
||||
|
||||
it("prefers network:fetch:any over network:fetch when both present", async () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "both-fetch",
|
||||
capabilities: ["network:fetch", "network:fetch:any"],
|
||||
allowedHosts: ["restricted.example.com"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(plugin);
|
||||
expect(ctx.http).toBeDefined();
|
||||
// With network:fetch:any, arbitrary hosts should not throw a host validation error
|
||||
await expect(ctx.http!.fetch("https://unrestricted.example.com/test")).rejects.not.toThrow(
|
||||
NOT_ALLOWED_FETCH_REGEX,
|
||||
);
|
||||
});
|
||||
|
||||
it("always provides kv, storage, and log", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const minimalPlugin = createTestPlugin({
|
||||
id: "minimal",
|
||||
capabilities: [],
|
||||
storage: {
|
||||
items: { indexes: [] },
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(minimalPlugin);
|
||||
|
||||
expect(ctx.kv).toBeDefined();
|
||||
expect(ctx.storage).toBeDefined();
|
||||
expect(ctx.storage.items).toBeDefined();
|
||||
expect(ctx.log).toBeDefined();
|
||||
});
|
||||
|
||||
it("provides write:content access with create/update/delete", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const writePlugin = createTestPlugin({
|
||||
id: "writer",
|
||||
capabilities: ["write:content"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(writePlugin);
|
||||
|
||||
expect(ctx.content).toBeDefined();
|
||||
expect("create" in ctx.content!).toBe(true);
|
||||
expect("update" in ctx.content!).toBe(true);
|
||||
expect("delete" in ctx.content!).toBe(true);
|
||||
});
|
||||
|
||||
it("always provides site info", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const plugin = createTestPlugin({ id: "site-test", capabilities: [] });
|
||||
const ctx = factory.createContext(plugin);
|
||||
|
||||
expect(ctx.site).toBeDefined();
|
||||
expect(typeof ctx.site.name).toBe("string");
|
||||
expect(typeof ctx.site.url).toBe("string");
|
||||
expect(typeof ctx.site.locale).toBe("string");
|
||||
});
|
||||
|
||||
it("always provides url() helper", () => {
|
||||
const factory = new PluginContextFactory({
|
||||
db,
|
||||
siteInfo: { siteUrl: "https://example.com" },
|
||||
});
|
||||
|
||||
const plugin = createTestPlugin({ id: "url-test", capabilities: [] });
|
||||
const ctx = factory.createContext(plugin);
|
||||
|
||||
expect(typeof ctx.url).toBe("function");
|
||||
expect(ctx.url("/posts")).toBe("https://example.com/posts");
|
||||
});
|
||||
|
||||
it("provides users for plugins with read:users", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "user-reader",
|
||||
capabilities: ["read:users"],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(plugin);
|
||||
expect(ctx.users).toBeDefined();
|
||||
expect(typeof ctx.users!.get).toBe("function");
|
||||
expect(typeof ctx.users!.getByEmail).toBe("function");
|
||||
expect(typeof ctx.users!.list).toBe("function");
|
||||
});
|
||||
|
||||
it("provides undefined users for plugins without read:users", () => {
|
||||
const factory = new PluginContextFactory({ db });
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-users",
|
||||
capabilities: [],
|
||||
});
|
||||
|
||||
const ctx = factory.createContext(plugin);
|
||||
expect(ctx.users).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Site Info", () => {
|
||||
it("creates site info with all options", () => {
|
||||
const info = createSiteInfo({
|
||||
siteName: "My Site",
|
||||
siteUrl: "https://example.com/",
|
||||
locale: "fr",
|
||||
});
|
||||
|
||||
expect(info.name).toBe("My Site");
|
||||
expect(info.url).toBe("https://example.com"); // trailing slash stripped
|
||||
expect(info.locale).toBe("fr");
|
||||
});
|
||||
|
||||
it("uses defaults for missing values", () => {
|
||||
const info = createSiteInfo({});
|
||||
|
||||
expect(info.name).toBe("");
|
||||
expect(info.url).toBe("");
|
||||
expect(info.locale).toBe("en");
|
||||
});
|
||||
|
||||
it("strips trailing slash from URL", () => {
|
||||
const info = createSiteInfo({ siteUrl: "https://example.com/" });
|
||||
expect(info.url).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Helper", () => {
|
||||
it("creates absolute URLs from paths", () => {
|
||||
const url = createUrlHelper("https://example.com");
|
||||
expect(url("/posts")).toBe("https://example.com/posts");
|
||||
expect(url("/")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("strips trailing slash from base URL", () => {
|
||||
const url = createUrlHelper("https://example.com/");
|
||||
expect(url("/posts")).toBe("https://example.com/posts");
|
||||
});
|
||||
|
||||
it("throws for paths not starting with /", () => {
|
||||
const url = createUrlHelper("https://example.com");
|
||||
expect(() => url("posts")).toThrow('URL path must start with "/"');
|
||||
});
|
||||
|
||||
it("works with empty base URL", () => {
|
||||
const url = createUrlHelper("");
|
||||
expect(url("/posts")).toBe("/posts");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative paths (//)", () => {
|
||||
const url = createUrlHelper("https://example.com");
|
||||
expect(() => url("//evil.com")).toThrow("protocol-relative");
|
||||
});
|
||||
|
||||
it("rejects protocol-relative paths with empty base URL", () => {
|
||||
const url = createUrlHelper("");
|
||||
expect(() => url("//evil.com/path")).toThrow("protocol-relative");
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Access", () => {
|
||||
let userRepo: UserRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
userRepo = new UserRepository(db);
|
||||
|
||||
// Create test users with all 5 role levels
|
||||
await userRepo.create({ email: "admin@test.com", name: "Admin User", role: "admin" });
|
||||
await userRepo.create({ email: "editor@test.com", name: "Editor User", role: "editor" });
|
||||
await userRepo.create({ email: "author@test.com", name: "Author User", role: "author" });
|
||||
await userRepo.create({
|
||||
email: "contrib@test.com",
|
||||
name: "Contributor User",
|
||||
role: "contributor",
|
||||
});
|
||||
await userRepo.create({
|
||||
email: "sub@test.com",
|
||||
name: "Subscriber User",
|
||||
role: "subscriber",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets user by ID", async () => {
|
||||
const user = await userRepo.findByEmail("admin@test.com");
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const result = await access.get(user!.id);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.email).toBe("admin@test.com");
|
||||
expect(result!.name).toBe("Admin User");
|
||||
expect(result!.role).toBe(50); // admin = 50
|
||||
});
|
||||
|
||||
it("gets user by email", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const result = await access.getByEmail("editor@test.com");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.email).toBe("editor@test.com");
|
||||
expect(result!.role).toBe(40); // editor = 40
|
||||
});
|
||||
|
||||
it("returns null for non-existent user", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
expect(await access.get("non-existent")).toBeNull();
|
||||
expect(await access.getByEmail("nobody@test.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists users", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const result = await access.list();
|
||||
expect(result.items).toHaveLength(5);
|
||||
// All users should have role as number
|
||||
for (const user of result.items) {
|
||||
expect(typeof user.role).toBe("number");
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes sensitive fields", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const result = await access.list();
|
||||
for (const user of result.items) {
|
||||
// UserInfo should only have: id, email, name, role, createdAt
|
||||
const keys = Object.keys(user);
|
||||
expect(keys).toContain("id");
|
||||
expect(keys).toContain("email");
|
||||
expect(keys).toContain("name");
|
||||
expect(keys).toContain("role");
|
||||
expect(keys).toContain("createdAt");
|
||||
// Should NOT have sensitive fields
|
||||
expect(keys).not.toContain("avatarUrl");
|
||||
expect(keys).not.toContain("emailVerified");
|
||||
expect(keys).not.toContain("data");
|
||||
expect(keys).not.toContain("password_hash");
|
||||
}
|
||||
});
|
||||
|
||||
it("converts role strings to numeric levels", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const admin = await access.getByEmail("admin@test.com");
|
||||
const editor = await access.getByEmail("editor@test.com");
|
||||
const subscriber = await access.getByEmail("sub@test.com");
|
||||
|
||||
expect(admin!.role).toBe(50);
|
||||
expect(editor!.role).toBe(40);
|
||||
expect(subscriber!.role).toBe(10);
|
||||
});
|
||||
|
||||
it("respects limit on list", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const result = await access.list({ limit: 2 });
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.nextCursor).toBeDefined();
|
||||
});
|
||||
|
||||
it("clamps limit to maximum of 100", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
// Should not throw for large limits — just clamp
|
||||
const result = await access.list({ limit: 500 });
|
||||
expect(result.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("clamps negative limit to minimum of 1", async () => {
|
||||
const access = createUserAccess(db);
|
||||
|
||||
// Negative limit should be clamped to 1, not passed through
|
||||
const result = await access.list({ limit: -999 });
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves contributor (20) and author (30) roles", async () => {
|
||||
// beforeEach creates users via UserRepository with all 5 roles.
|
||||
// Verify that contributor (20) and author (30) survive the round-trip.
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const contributor = await access.getByEmail("contrib@test.com");
|
||||
expect(contributor).not.toBeNull();
|
||||
expect(contributor!.role).toBe(20);
|
||||
|
||||
const author = await access.getByEmail("author@test.com");
|
||||
expect(author).not.toBeNull();
|
||||
expect(author!.role).toBe(30);
|
||||
});
|
||||
|
||||
it("filters users by exact role number", async () => {
|
||||
// beforeEach creates one user per role level (10, 20, 30, 40, 50)
|
||||
const access = createUserAccess(db);
|
||||
|
||||
const contributors = await access.list({ role: 20 });
|
||||
expect(contributors.items).toHaveLength(1);
|
||||
expect(contributors.items[0]!.email).toBe("contrib@test.com");
|
||||
expect(contributors.items[0]!.role).toBe(20);
|
||||
|
||||
const authors = await access.list({ role: 30 });
|
||||
expect(authors.items).toHaveLength(1);
|
||||
expect(authors.items[0]!.email).toBe("author@test.com");
|
||||
expect(authors.items[0]!.role).toBe(30);
|
||||
|
||||
const admins = await access.list({ role: 50 });
|
||||
expect(admins.items).toHaveLength(1);
|
||||
expect(admins.items[0]!.email).toBe("admin@test.com");
|
||||
});
|
||||
|
||||
it("supports cursor-based pagination", async () => {
|
||||
const access = createUserAccess(db);
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Page through all 5 users one at a time
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const page = await access.list({ limit: 1, cursor });
|
||||
if (page.items.length === 0) break;
|
||||
|
||||
expect(page.items).toHaveLength(1);
|
||||
const userId = page.items[0]!.id;
|
||||
expect(seen.has(userId)).toBe(false); // no duplicates
|
||||
seen.add(userId);
|
||||
pageCount++;
|
||||
|
||||
if (!page.nextCursor) break; // last page
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(5);
|
||||
expect(pageCount).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
packages/core/tests/integration/plugins/field-widgets.test.ts
Normal file
236
packages/core/tests/integration/plugins/field-widgets.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Integration tests for field widget manifest pipeline.
|
||||
*
|
||||
* Tests that field widgets declared on collections flow through
|
||||
* the manifest builder correctly, including the widget property
|
||||
* and select options for select/multiSelect fields.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("field widget on schema fields", () => {
|
||||
it("should store and retrieve widget property on a field", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "theme_color",
|
||||
label: "Theme Color",
|
||||
type: "string",
|
||||
widget: "color:picker",
|
||||
});
|
||||
|
||||
const collection = await registry.getCollectionWithFields("posts");
|
||||
expect(collection).toBeTruthy();
|
||||
|
||||
const colorField = collection!.fields.find((f) => f.slug === "theme_color");
|
||||
expect(colorField).toBeTruthy();
|
||||
expect(colorField!.widget).toBe("color:picker");
|
||||
expect(colorField!.type).toBe("string");
|
||||
});
|
||||
|
||||
it("should store and retrieve widget on a json field", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "pricing",
|
||||
label: "Pricing",
|
||||
type: "json",
|
||||
widget: "x402:pricing",
|
||||
});
|
||||
|
||||
const collection = await registry.getCollectionWithFields("posts");
|
||||
const pricingField = collection!.fields.find((f) => f.slug === "pricing");
|
||||
expect(pricingField).toBeTruthy();
|
||||
expect(pricingField!.widget).toBe("x402:pricing");
|
||||
expect(pricingField!.type).toBe("json");
|
||||
});
|
||||
|
||||
it("should return undefined widget when not set", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
const collection = await registry.getCollectionWithFields("posts");
|
||||
const titleField = collection!.fields.find((f) => f.slug === "title");
|
||||
expect(titleField).toBeTruthy();
|
||||
expect(titleField!.widget).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should update widget on an existing field", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "color",
|
||||
label: "Color",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
// Update to add widget
|
||||
await registry.updateField("posts", "color", {
|
||||
widget: "color:picker",
|
||||
});
|
||||
|
||||
const collection = await registry.getCollectionWithFields("posts");
|
||||
const colorField = collection!.fields.find((f) => f.slug === "color");
|
||||
expect(colorField!.widget).toBe("color:picker");
|
||||
});
|
||||
|
||||
it("should include select options from validation in manifest format", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "priority",
|
||||
label: "Priority",
|
||||
type: "select",
|
||||
validation: {
|
||||
options: ["low", "medium", "high"],
|
||||
},
|
||||
});
|
||||
|
||||
const collection = await registry.getCollectionWithFields("posts");
|
||||
const priorityField = collection!.fields.find((f) => f.slug === "priority");
|
||||
expect(priorityField).toBeTruthy();
|
||||
expect(priorityField!.type).toBe("select");
|
||||
expect(priorityField!.validation?.options).toEqual(["low", "medium", "high"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("field widget content CRUD", () => {
|
||||
it("should save and retrieve content with a widget field value", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "theme_color",
|
||||
label: "Theme Color",
|
||||
type: "string",
|
||||
widget: "color:picker",
|
||||
});
|
||||
|
||||
// Insert content with the widget field value
|
||||
const { ulid } = await import("ulidx");
|
||||
const id = ulid();
|
||||
await db
|
||||
.insertInto("ec_posts" as never)
|
||||
.values({
|
||||
id,
|
||||
slug: "test-post",
|
||||
status: "draft",
|
||||
title: "Test Post",
|
||||
theme_color: "#ff6600",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
version: 1,
|
||||
} as never)
|
||||
.execute();
|
||||
|
||||
// Read it back
|
||||
const row = await db
|
||||
.selectFrom("ec_posts" as never)
|
||||
.selectAll()
|
||||
.where("id" as never, "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect((row as Record<string, unknown>).theme_color).toBe("#ff6600");
|
||||
});
|
||||
|
||||
it("should save and retrieve json widget field value", async () => {
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createCollection({
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
});
|
||||
|
||||
await registry.createField("posts", {
|
||||
slug: "pricing",
|
||||
label: "Pricing",
|
||||
type: "json",
|
||||
widget: "x402:pricing",
|
||||
});
|
||||
|
||||
const { ulid } = await import("ulidx");
|
||||
const id = ulid();
|
||||
const pricingValue = JSON.stringify({ enabled: true, price: "$0.10", gateMode: "bots" });
|
||||
|
||||
await db
|
||||
.insertInto("ec_posts" as never)
|
||||
.values({
|
||||
id,
|
||||
slug: "premium-post",
|
||||
status: "draft",
|
||||
pricing: pricingValue,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
version: 1,
|
||||
} as never)
|
||||
.execute();
|
||||
|
||||
const row = await db
|
||||
.selectFrom("ec_posts" as never)
|
||||
.selectAll()
|
||||
.where("id" as never, "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
const pricing = JSON.parse((row as Record<string, unknown>).pricing as string);
|
||||
expect(pricing.enabled).toBe(true);
|
||||
expect(pricing.price).toBe("$0.10");
|
||||
expect(pricing.gateMode).toBe("bots");
|
||||
});
|
||||
});
|
||||
380
packages/core/tests/integration/plugins/storage-indexes.test.ts
Normal file
380
packages/core/tests/integration/plugins/storage-indexes.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { PluginStorageRepository } from "../../../src/database/repositories/plugin-storage.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import {
|
||||
createStorageIndexes,
|
||||
removeOrphanedIndexes,
|
||||
syncStorageIndexes,
|
||||
removeAllPluginIndexes,
|
||||
getPluginIndexStatus,
|
||||
} from "../../../src/plugins/storage-indexes.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
const UNIQUE_CONSTRAINT_PATTERN = /UNIQUE constraint failed/;
|
||||
|
||||
describe("Plugin Storage Indexes Integration", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("createStorageIndexes", () => {
|
||||
it("should create single-field index", async () => {
|
||||
const result = await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
|
||||
|
||||
expect(result.created).toContain("idx_plugin_my-plugin_events_eventType");
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create composite index", async () => {
|
||||
const result = await createStorageIndexes(db, "my-plugin", "events", [
|
||||
["status", "createdAt"],
|
||||
]);
|
||||
|
||||
expect(result.created).toContain("idx_plugin_my-plugin_events_status_createdAt");
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should create multiple indexes", async () => {
|
||||
const result = await createStorageIndexes(db, "my-plugin", "events", [
|
||||
"eventType",
|
||||
"userId",
|
||||
["status", "timestamp"],
|
||||
]);
|
||||
|
||||
expect(result.created).toHaveLength(3);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should track indexes in _plugin_indexes table", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]);
|
||||
|
||||
const indexes = await db
|
||||
.selectFrom("_plugin_indexes")
|
||||
.selectAll()
|
||||
.where("plugin_id", "=", "my-plugin")
|
||||
.execute();
|
||||
|
||||
expect(indexes).toHaveLength(2);
|
||||
expect(indexes.map((i) => JSON.parse(i.fields))).toContainEqual(["eventType"]);
|
||||
expect(indexes.map((i) => JSON.parse(i.fields))).toContainEqual(["userId"]);
|
||||
});
|
||||
|
||||
it("should be idempotent", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
|
||||
const result = await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
|
||||
|
||||
// Should still succeed
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
// Should not duplicate tracking records
|
||||
const indexes = await db
|
||||
.selectFrom("_plugin_indexes")
|
||||
.selectAll()
|
||||
.where("plugin_id", "=", "my-plugin")
|
||||
.execute();
|
||||
expect(indexes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOrphanedIndexes", () => {
|
||||
it("should remove indexes no longer in declaration", async () => {
|
||||
// Create initial indexes
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId", "status"]);
|
||||
|
||||
// Remove one
|
||||
const result = await removeOrphanedIndexes(db, "my-plugin", "events", [
|
||||
"eventType",
|
||||
"userId",
|
||||
]);
|
||||
|
||||
expect(result.removed).toContain("idx_plugin_my-plugin_events_status");
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should keep indexes that are still declared", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]);
|
||||
|
||||
const result = await removeOrphanedIndexes(db, "my-plugin", "events", [
|
||||
"eventType",
|
||||
"userId",
|
||||
]);
|
||||
|
||||
expect(result.removed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should update tracking table", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "status"]);
|
||||
await removeOrphanedIndexes(db, "my-plugin", "events", ["eventType"]);
|
||||
|
||||
const indexes = await db
|
||||
.selectFrom("_plugin_indexes")
|
||||
.selectAll()
|
||||
.where("plugin_id", "=", "my-plugin")
|
||||
.execute();
|
||||
|
||||
expect(indexes).toHaveLength(1);
|
||||
expect(JSON.parse(indexes[0].fields)).toEqual(["eventType"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncStorageIndexes", () => {
|
||||
it("should create new and remove old indexes in one call", async () => {
|
||||
// Initial state
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "oldField"]);
|
||||
|
||||
// Sync to new state
|
||||
const result = await syncStorageIndexes(db, "my-plugin", "events", ["eventType", "newField"]);
|
||||
|
||||
expect(result.created).toContain("idx_plugin_my-plugin_events_newField");
|
||||
expect(result.removed).toContain("idx_plugin_my-plugin_events_oldField");
|
||||
|
||||
const status = await getPluginIndexStatus(db, "my-plugin");
|
||||
const fields = status.map((s) => s.fields);
|
||||
expect(fields).toContainEqual(["eventType"]);
|
||||
expect(fields).toContainEqual(["newField"]);
|
||||
expect(fields).not.toContainEqual(["oldField"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllPluginIndexes", () => {
|
||||
it("should remove all indexes for a plugin", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]);
|
||||
await createStorageIndexes(db, "my-plugin", "cache", ["key", "expiresAt"]);
|
||||
|
||||
const result = await removeAllPluginIndexes(db, "my-plugin");
|
||||
|
||||
expect(result.removed).toHaveLength(4);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
const remaining = await db
|
||||
.selectFrom("_plugin_indexes")
|
||||
.selectAll()
|
||||
.where("plugin_id", "=", "my-plugin")
|
||||
.execute();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not affect other plugins", async () => {
|
||||
await createStorageIndexes(db, "plugin1", "events", ["eventType"]);
|
||||
await createStorageIndexes(db, "plugin2", "events", ["eventType"]);
|
||||
|
||||
await removeAllPluginIndexes(db, "plugin1");
|
||||
|
||||
const plugin2Indexes = await db
|
||||
.selectFrom("_plugin_indexes")
|
||||
.selectAll()
|
||||
.where("plugin_id", "=", "plugin2")
|
||||
.execute();
|
||||
expect(plugin2Indexes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginIndexStatus", () => {
|
||||
it("should return all indexes for a plugin", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType", ["status", "timestamp"]]);
|
||||
await createStorageIndexes(db, "my-plugin", "cache", ["key"]);
|
||||
|
||||
const status = await getPluginIndexStatus(db, "my-plugin");
|
||||
|
||||
expect(status).toHaveLength(3);
|
||||
expect(status).toContainEqual(
|
||||
expect.objectContaining({
|
||||
collection: "events",
|
||||
fields: ["eventType"],
|
||||
}),
|
||||
);
|
||||
expect(status).toContainEqual(
|
||||
expect.objectContaining({
|
||||
collection: "events",
|
||||
fields: ["status", "timestamp"],
|
||||
}),
|
||||
);
|
||||
expect(status).toContainEqual(
|
||||
expect.objectContaining({
|
||||
collection: "cache",
|
||||
fields: ["key"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array for plugin with no indexes", async () => {
|
||||
const status = await getPluginIndexStatus(db, "nonexistent-plugin");
|
||||
expect(status).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("query performance with indexes", () => {
|
||||
it("should efficiently query using indexed fields", async () => {
|
||||
const pluginId = "perf-test";
|
||||
const collection = "events";
|
||||
|
||||
// Create index first
|
||||
await createStorageIndexes(db, pluginId, collection, ["eventType"]);
|
||||
|
||||
// Create repository with the indexed field
|
||||
const repo = new PluginStorageRepository<{ eventType: string }>(db, pluginId, collection, [
|
||||
"eventType",
|
||||
]);
|
||||
|
||||
// Insert test data
|
||||
const items = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `event-${i}`,
|
||||
data: { eventType: i % 2 === 0 ? "pageview" : "click" },
|
||||
}));
|
||||
await repo.putMany(items);
|
||||
|
||||
// Query should work and use the index
|
||||
const result = await repo.query({
|
||||
where: { eventType: "pageview" },
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(50);
|
||||
expect(result.items.every((i) => i.data.eventType === "pageview")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("index verification", () => {
|
||||
it("should create actual SQLite index", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
|
||||
|
||||
// Query SQLite's index list
|
||||
const indexes = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name LIKE 'idx_plugin_%'
|
||||
`.execute(db);
|
||||
|
||||
expect(indexes.rows.map((r) => r.name)).toContain("idx_plugin_my-plugin_events_eventType");
|
||||
});
|
||||
|
||||
it("should drop actual SQLite index on removal", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
|
||||
await removeAllPluginIndexes(db, "my-plugin");
|
||||
|
||||
const indexes = await sql<{ name: string }>`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name LIKE 'idx_plugin_my-plugin_%'
|
||||
`.execute(db);
|
||||
|
||||
expect(indexes.rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unique indexes", () => {
|
||||
it("should create a unique index", async () => {
|
||||
const result = await createStorageIndexes(db, "my-plugin", "forms", [], {
|
||||
uniqueIndexes: ["slug"],
|
||||
});
|
||||
|
||||
expect(result.created).toContain("uidx_plugin_my-plugin_forms_slug");
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
// Verify it's actually a UNIQUE index in SQLite
|
||||
const indexSql = await sql<{ sql: string }>`
|
||||
SELECT sql FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name = 'uidx_plugin_my-plugin_forms_slug'
|
||||
`.execute(db);
|
||||
|
||||
expect(indexSql.rows).toHaveLength(1);
|
||||
expect(indexSql.rows[0].sql).toContain("UNIQUE");
|
||||
});
|
||||
|
||||
it("should enforce uniqueness on insert", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "forms", [], {
|
||||
uniqueIndexes: ["slug"],
|
||||
});
|
||||
|
||||
const repo = new PluginStorageRepository<{ slug: string; name: string }>(
|
||||
db,
|
||||
"my-plugin",
|
||||
"forms",
|
||||
["slug"],
|
||||
);
|
||||
|
||||
await repo.put("form-1", { slug: "contact", name: "Contact" });
|
||||
|
||||
// Second insert with a different ID but same slug should fail
|
||||
await expect(repo.put("form-2", { slug: "contact", name: "Contact Copy" })).rejects.toThrow(
|
||||
UNIQUE_CONSTRAINT_PATTERN,
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow updating the same document", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "forms", [], {
|
||||
uniqueIndexes: ["slug"],
|
||||
});
|
||||
|
||||
const repo = new PluginStorageRepository<{ slug: string; name: string }>(
|
||||
db,
|
||||
"my-plugin",
|
||||
"forms",
|
||||
["slug"],
|
||||
);
|
||||
|
||||
await repo.put("form-1", { slug: "contact", name: "Contact" });
|
||||
// Updating the same ID should succeed (upsert)
|
||||
await repo.put("form-1", { slug: "contact", name: "Contact Updated" });
|
||||
|
||||
const result = await repo.get("form-1");
|
||||
expect(result?.name).toBe("Contact Updated");
|
||||
});
|
||||
|
||||
it("should allow different slugs across different collections", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "forms", [], {
|
||||
uniqueIndexes: ["slug"],
|
||||
});
|
||||
await createStorageIndexes(db, "my-plugin", "templates", [], {
|
||||
uniqueIndexes: ["slug"],
|
||||
});
|
||||
|
||||
const formsRepo = new PluginStorageRepository<{ slug: string }>(db, "my-plugin", "forms", [
|
||||
"slug",
|
||||
]);
|
||||
const templatesRepo = new PluginStorageRepository<{ slug: string }>(
|
||||
db,
|
||||
"my-plugin",
|
||||
"templates",
|
||||
["slug"],
|
||||
);
|
||||
|
||||
// Same slug in different collections should work (partial index scoped by collection)
|
||||
await formsRepo.put("form-1", { slug: "contact" });
|
||||
await templatesRepo.put("tmpl-1", { slug: "contact" });
|
||||
|
||||
expect(await formsRepo.get("form-1")).toEqual({ slug: "contact" });
|
||||
expect(await templatesRepo.get("tmpl-1")).toEqual({ slug: "contact" });
|
||||
});
|
||||
|
||||
it("should include unique index fields in queryable fields", async () => {
|
||||
await createStorageIndexes(db, "my-plugin", "forms", ["status"], {
|
||||
uniqueIndexes: ["slug"],
|
||||
});
|
||||
|
||||
const repo = new PluginStorageRepository<{ slug: string; status: string }>(
|
||||
db,
|
||||
"my-plugin",
|
||||
"forms",
|
||||
["status", "slug"],
|
||||
);
|
||||
|
||||
await repo.put("form-1", { slug: "contact", status: "active" });
|
||||
await repo.put("form-2", { slug: "feedback", status: "active" });
|
||||
|
||||
// Query by unique field should work
|
||||
const result = await repo.query({ where: { slug: "contact" } });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].data.slug).toBe("contact");
|
||||
});
|
||||
});
|
||||
});
|
||||
293
packages/core/tests/integration/plugins/storage.test.ts
Normal file
293
packages/core/tests/integration/plugins/storage.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
PluginStorageRepository,
|
||||
createPluginStorageAccessor,
|
||||
deleteAllPluginStorage,
|
||||
deletePluginCollection,
|
||||
} from "../../../src/database/repositories/plugin-storage.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
interface AnalyticsEvent {
|
||||
eventType: string;
|
||||
userId: string;
|
||||
timestamp: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("Plugin Storage Integration", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("full storage flow", () => {
|
||||
it("should support complete CRUD cycle", async () => {
|
||||
const repo = new PluginStorageRepository<AnalyticsEvent>(db, "analytics-plugin", "events", [
|
||||
"eventType",
|
||||
"userId",
|
||||
"timestamp",
|
||||
]);
|
||||
|
||||
// Create
|
||||
const event: AnalyticsEvent = {
|
||||
eventType: "pageview",
|
||||
userId: "user123",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { page: "/home", referrer: "google.com" },
|
||||
};
|
||||
await repo.put("event1", event);
|
||||
|
||||
// Read
|
||||
const fetched = await repo.get("event1");
|
||||
expect(fetched).toEqual(event);
|
||||
|
||||
// Update
|
||||
const updatedEvent = {
|
||||
...event,
|
||||
metadata: { ...event.metadata, duration: 5000 },
|
||||
};
|
||||
await repo.put("event1", updatedEvent);
|
||||
const refetched = await repo.get("event1");
|
||||
expect(refetched?.metadata).toHaveProperty("duration", 5000);
|
||||
|
||||
// Delete
|
||||
const deleted = await repo.delete("event1");
|
||||
expect(deleted).toBe(true);
|
||||
expect(await repo.get("event1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should support complex queries with JSON extraction", async () => {
|
||||
const repo = new PluginStorageRepository<AnalyticsEvent>(db, "analytics-plugin", "events", [
|
||||
"eventType",
|
||||
"userId",
|
||||
"timestamp",
|
||||
]);
|
||||
|
||||
// Create events
|
||||
await repo.putMany([
|
||||
{
|
||||
id: "e1",
|
||||
data: {
|
||||
eventType: "pageview",
|
||||
userId: "user1",
|
||||
timestamp: "2024-01-01T10:00:00Z",
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e2",
|
||||
data: {
|
||||
eventType: "click",
|
||||
userId: "user1",
|
||||
timestamp: "2024-01-01T10:05:00Z",
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "e3",
|
||||
data: {
|
||||
eventType: "pageview",
|
||||
userId: "user2",
|
||||
timestamp: "2024-01-01T11:00:00Z",
|
||||
metadata: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Query by eventType
|
||||
const pageviews = await repo.query({ where: { eventType: "pageview" } });
|
||||
expect(pageviews.items).toHaveLength(2);
|
||||
|
||||
// Query by userId
|
||||
const user1Events = await repo.query({ where: { userId: "user1" } });
|
||||
expect(user1Events.items).toHaveLength(2);
|
||||
|
||||
// Combined query
|
||||
const user1Pageviews = await repo.query({
|
||||
where: { eventType: "pageview", userId: "user1" },
|
||||
});
|
||||
expect(user1Pageviews.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPluginStorageAccessor", () => {
|
||||
it("should create accessor with multiple collections", async () => {
|
||||
const accessor = createPluginStorageAccessor(db, "my-plugin", {
|
||||
events: { indexes: ["eventType", "timestamp"] },
|
||||
cache: { indexes: ["key", "expiresAt"] },
|
||||
});
|
||||
|
||||
expect(accessor).toHaveProperty("events");
|
||||
expect(accessor).toHaveProperty("cache");
|
||||
|
||||
// Use events collection
|
||||
await accessor.events.put("e1", {
|
||||
eventType: "test",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
const event = await accessor.events.get("e1");
|
||||
expect(event).toBeDefined();
|
||||
|
||||
// Use cache collection
|
||||
await accessor.cache.put("c1", {
|
||||
key: "test-key",
|
||||
value: "test-value",
|
||||
expiresAt: new Date().toISOString(),
|
||||
});
|
||||
const cached = await accessor.cache.get("c1");
|
||||
expect(cached).toBeDefined();
|
||||
});
|
||||
|
||||
it("should isolate collections from each other", async () => {
|
||||
const accessor = createPluginStorageAccessor(db, "my-plugin", {
|
||||
events: { indexes: ["eventType"] },
|
||||
cache: { indexes: ["key"] },
|
||||
});
|
||||
|
||||
await accessor.events.put("item1", { eventType: "test" });
|
||||
await accessor.cache.put("item1", { key: "test" });
|
||||
|
||||
// Both should exist independently
|
||||
expect(await accessor.events.get("item1")).toEqual({ eventType: "test" });
|
||||
expect(await accessor.cache.get("item1")).toEqual({ key: "test" });
|
||||
|
||||
// Count should be separate
|
||||
expect(
|
||||
await (accessor.events as PluginStorageRepository<any>).count({
|
||||
eventType: "test",
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await (accessor.cache as PluginStorageRepository<any>).count({
|
||||
key: "test",
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAllPluginStorage", () => {
|
||||
it("should delete all data for a plugin", async () => {
|
||||
const accessor = createPluginStorageAccessor(db, "cleanup-plugin", {
|
||||
events: { indexes: ["eventType"] },
|
||||
cache: { indexes: ["key"] },
|
||||
});
|
||||
|
||||
// Add data
|
||||
await accessor.events.put("e1", { eventType: "test" });
|
||||
await accessor.events.put("e2", { eventType: "test2" });
|
||||
await accessor.cache.put("c1", { key: "test" });
|
||||
|
||||
// Delete all
|
||||
const deleted = await deleteAllPluginStorage(db, "cleanup-plugin");
|
||||
expect(deleted).toBe(3);
|
||||
|
||||
// Verify empty
|
||||
expect(await accessor.events.get("e1")).toBeNull();
|
||||
expect(await accessor.events.get("e2")).toBeNull();
|
||||
expect(await accessor.cache.get("c1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should not affect other plugins", async () => {
|
||||
const plugin1 = createPluginStorageAccessor(db, "plugin1", {
|
||||
data: { indexes: ["key"] },
|
||||
});
|
||||
const plugin2 = createPluginStorageAccessor(db, "plugin2", {
|
||||
data: { indexes: ["key"] },
|
||||
});
|
||||
|
||||
await plugin1.data.put("item1", { key: "test" });
|
||||
await plugin2.data.put("item1", { key: "test" });
|
||||
|
||||
await deleteAllPluginStorage(db, "plugin1");
|
||||
|
||||
expect(await plugin1.data.get("item1")).toBeNull();
|
||||
expect(await plugin2.data.get("item1")).toEqual({ key: "test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePluginCollection", () => {
|
||||
it("should delete specific collection", async () => {
|
||||
const accessor = createPluginStorageAccessor(db, "my-plugin", {
|
||||
events: { indexes: ["eventType"] },
|
||||
cache: { indexes: ["key"] },
|
||||
});
|
||||
|
||||
await accessor.events.put("e1", { eventType: "test" });
|
||||
await accessor.cache.put("c1", { key: "test" });
|
||||
|
||||
await deletePluginCollection(db, "my-plugin", "events");
|
||||
|
||||
expect(await accessor.events.get("e1")).toBeNull();
|
||||
expect(await accessor.cache.get("c1")).toEqual({ key: "test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
it("should paginate through large datasets", async () => {
|
||||
const repo = new PluginStorageRepository<{ index: number }>(
|
||||
db,
|
||||
"pagination-test",
|
||||
"items",
|
||||
[],
|
||||
);
|
||||
|
||||
// Create 25 items
|
||||
const items = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `item-${String(i).padStart(3, "0")}`,
|
||||
data: { index: i },
|
||||
}));
|
||||
await repo.putMany(items);
|
||||
|
||||
// Paginate with limit of 10
|
||||
const pages: Array<Array<{ id: string; data: { index: number } }>> = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await repo.query({ limit: 10, cursor });
|
||||
pages.push(result.items);
|
||||
cursor = result.cursor;
|
||||
} while (cursor);
|
||||
|
||||
expect(pages).toHaveLength(3);
|
||||
expect(pages[0]).toHaveLength(10);
|
||||
expect(pages[1]).toHaveLength(10);
|
||||
expect(pages[2]).toHaveLength(5);
|
||||
|
||||
// Verify all items were retrieved
|
||||
const allItems = pages.flat();
|
||||
expect(allItems).toHaveLength(25);
|
||||
expect(new Set(allItems.map((i) => i.id)).size).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent operations", () => {
|
||||
it("should handle concurrent puts", async () => {
|
||||
const repo = new PluginStorageRepository<{ value: number }>(
|
||||
db,
|
||||
"concurrent-test",
|
||||
"items",
|
||||
[],
|
||||
);
|
||||
|
||||
// Concurrent puts
|
||||
await Promise.all([
|
||||
repo.put("item1", { value: 1 }),
|
||||
repo.put("item2", { value: 2 }),
|
||||
repo.put("item3", { value: 3 }),
|
||||
repo.put("item4", { value: 4 }),
|
||||
repo.put("item5", { value: 5 }),
|
||||
]);
|
||||
|
||||
const count = await repo.count();
|
||||
expect(count).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { RedirectRepository } from "../../../src/database/repositories/redirect.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("RedirectRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: RedirectRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
repo = new RedirectRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
// --- CRUD ---------------------------------------------------------------
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a redirect with defaults", async () => {
|
||||
const redirect = await repo.create({
|
||||
source: "/old",
|
||||
destination: "/new",
|
||||
});
|
||||
|
||||
expect(redirect.source).toBe("/old");
|
||||
expect(redirect.destination).toBe("/new");
|
||||
expect(redirect.type).toBe(301);
|
||||
expect(redirect.isPattern).toBe(false);
|
||||
expect(redirect.enabled).toBe(true);
|
||||
expect(redirect.hits).toBe(0);
|
||||
expect(redirect.lastHitAt).toBeNull();
|
||||
expect(redirect.auto).toBe(false);
|
||||
expect(redirect.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a redirect with custom values", async () => {
|
||||
const redirect = await repo.create({
|
||||
source: "/temp",
|
||||
destination: "/target",
|
||||
type: 302,
|
||||
enabled: false,
|
||||
groupName: "Temporary",
|
||||
auto: true,
|
||||
});
|
||||
|
||||
expect(redirect.type).toBe(302);
|
||||
expect(redirect.enabled).toBe(false);
|
||||
expect(redirect.groupName).toBe("Temporary");
|
||||
expect(redirect.auto).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-detects pattern sources", async () => {
|
||||
const redirect = await repo.create({
|
||||
source: "/old-blog/[...path]",
|
||||
destination: "/blog/[...path]",
|
||||
});
|
||||
|
||||
expect(redirect.isPattern).toBe(true);
|
||||
});
|
||||
|
||||
it("respects explicit isPattern=false override", async () => {
|
||||
const redirect = await repo.create({
|
||||
source: "/literal-with-brackets",
|
||||
destination: "/target",
|
||||
isPattern: false,
|
||||
});
|
||||
|
||||
expect(redirect.isPattern).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("returns null for non-existent id", async () => {
|
||||
expect(await repo.findById("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
it("finds a redirect by id", async () => {
|
||||
const created = await repo.create({
|
||||
source: "/a",
|
||||
destination: "/b",
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found?.source).toBe("/a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBySource", () => {
|
||||
it("returns null for non-existent source", async () => {
|
||||
expect(await repo.findBySource("/nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("finds a redirect by source", async () => {
|
||||
await repo.create({ source: "/old", destination: "/new" });
|
||||
const found = await repo.findBySource("/old");
|
||||
expect(found?.destination).toBe("/new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("returns null for non-existent id", async () => {
|
||||
expect(await repo.update("nonexistent", { destination: "/x" })).toBeNull();
|
||||
});
|
||||
|
||||
it("updates destination", async () => {
|
||||
const created = await repo.create({
|
||||
source: "/a",
|
||||
destination: "/b",
|
||||
});
|
||||
const updated = await repo.update(created.id, { destination: "/c" });
|
||||
expect(updated?.destination).toBe("/c");
|
||||
});
|
||||
|
||||
it("updates type and enabled", async () => {
|
||||
const created = await repo.create({
|
||||
source: "/a",
|
||||
destination: "/b",
|
||||
type: 301,
|
||||
});
|
||||
const updated = await repo.update(created.id, {
|
||||
type: 302,
|
||||
enabled: false,
|
||||
});
|
||||
expect(updated?.type).toBe(302);
|
||||
expect(updated?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-detects isPattern when source changes", async () => {
|
||||
const created = await repo.create({
|
||||
source: "/literal",
|
||||
destination: "/target",
|
||||
});
|
||||
expect(created.isPattern).toBe(false);
|
||||
|
||||
const updated = await repo.update(created.id, {
|
||||
source: "/[slug]",
|
||||
});
|
||||
expect(updated?.isPattern).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("returns false for non-existent id", async () => {
|
||||
expect(await repo.delete("nonexistent")).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes and returns true", async () => {
|
||||
const created = await repo.create({
|
||||
source: "/a",
|
||||
destination: "/b",
|
||||
});
|
||||
expect(await repo.delete(created.id)).toBe(true);
|
||||
expect(await repo.findById(created.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
it("returns empty list when no redirects", async () => {
|
||||
const result = await repo.findMany({});
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns all redirects", async () => {
|
||||
await repo.create({ source: "/a", destination: "/b" });
|
||||
await repo.create({ source: "/c", destination: "/d" });
|
||||
const result = await repo.findMany({});
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("paginates with cursor", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await repo.create({ source: `/s${i}`, destination: `/d${i}` });
|
||||
}
|
||||
|
||||
const page1 = await repo.findMany({ limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
const page2 = await repo.findMany({ limit: 2, cursor: page1.nextCursor });
|
||||
expect(page2.items).toHaveLength(2);
|
||||
expect(page2.nextCursor).toBeTruthy();
|
||||
|
||||
// Ensure no overlap
|
||||
const page1Ids = new Set(page1.items.map((r) => r.id));
|
||||
for (const item of page2.items) {
|
||||
expect(page1Ids.has(item.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("filters by search term", async () => {
|
||||
await repo.create({ source: "/blog/hello", destination: "/new/hello" });
|
||||
await repo.create({ source: "/about", destination: "/info" });
|
||||
|
||||
const result = await repo.findMany({ search: "blog" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.source).toBe("/blog/hello");
|
||||
});
|
||||
|
||||
it("filters by enabled status", async () => {
|
||||
await repo.create({ source: "/a", destination: "/b", enabled: true });
|
||||
await repo.create({ source: "/c", destination: "/d", enabled: false });
|
||||
|
||||
const enabled = await repo.findMany({ enabled: true });
|
||||
expect(enabled.items).toHaveLength(1);
|
||||
expect(enabled.items[0]!.source).toBe("/a");
|
||||
|
||||
const disabled = await repo.findMany({ enabled: false });
|
||||
expect(disabled.items).toHaveLength(1);
|
||||
expect(disabled.items[0]!.source).toBe("/c");
|
||||
});
|
||||
|
||||
it("filters by group", async () => {
|
||||
await repo.create({
|
||||
source: "/a",
|
||||
destination: "/b",
|
||||
groupName: "wp-import",
|
||||
});
|
||||
await repo.create({ source: "/c", destination: "/d" });
|
||||
|
||||
const result = await repo.findMany({ group: "wp-import" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.groupName).toBe("wp-import");
|
||||
});
|
||||
|
||||
it("filters by auto flag", async () => {
|
||||
await repo.create({ source: "/a", destination: "/b", auto: true });
|
||||
await repo.create({ source: "/c", destination: "/d", auto: false });
|
||||
|
||||
const autoOnly = await repo.findMany({ auto: true });
|
||||
expect(autoOnly.items).toHaveLength(1);
|
||||
expect(autoOnly.items[0]!.auto).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps limit to 1-100", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await repo.create({ source: `/s${i}`, destination: `/d${i}` });
|
||||
}
|
||||
|
||||
// limit=0 should clamp to 1
|
||||
const min = await repo.findMany({ limit: 0 });
|
||||
expect(min.items.length).toBeLessThanOrEqual(1);
|
||||
|
||||
// limit=200 should clamp to 100
|
||||
const max = await repo.findMany({ limit: 200 });
|
||||
expect(max.items).toHaveLength(3); // only 3 exist
|
||||
});
|
||||
});
|
||||
|
||||
// --- Matching -----------------------------------------------------------
|
||||
|
||||
describe("matchPath", () => {
|
||||
it("returns null when no redirects exist", async () => {
|
||||
expect(await repo.matchPath("/anything")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches exact paths", async () => {
|
||||
await repo.create({ source: "/old", destination: "/new" });
|
||||
const match = await repo.matchPath("/old");
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.resolvedDestination).toBe("/new");
|
||||
});
|
||||
|
||||
it("does not match disabled redirects", async () => {
|
||||
await repo.create({
|
||||
source: "/old",
|
||||
destination: "/new",
|
||||
enabled: false,
|
||||
});
|
||||
expect(await repo.matchPath("/old")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches pattern redirects", async () => {
|
||||
await repo.create({
|
||||
source: "/old-blog/[...path]",
|
||||
destination: "/blog/[...path]",
|
||||
});
|
||||
const match = await repo.matchPath("/old-blog/2024/01/post");
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.resolvedDestination).toBe("/blog/2024/01/post");
|
||||
});
|
||||
|
||||
it("prefers exact match over pattern match", async () => {
|
||||
await repo.create({
|
||||
source: "/blog/[slug]",
|
||||
destination: "/articles/[slug]",
|
||||
});
|
||||
await repo.create({
|
||||
source: "/blog/special",
|
||||
destination: "/special-page",
|
||||
});
|
||||
|
||||
const match = await repo.matchPath("/blog/special");
|
||||
expect(match!.resolvedDestination).toBe("/special-page");
|
||||
});
|
||||
|
||||
it("matches [param] in single segment", async () => {
|
||||
await repo.create({
|
||||
source: "/category/[slug]",
|
||||
destination: "/tags/[slug]",
|
||||
});
|
||||
const match = await repo.matchPath("/category/typescript");
|
||||
expect(match!.resolvedDestination).toBe("/tags/typescript");
|
||||
|
||||
// Should not match multi-segment
|
||||
expect(await repo.matchPath("/category/a/b")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Hit tracking -------------------------------------------------------
|
||||
|
||||
describe("recordHit", () => {
|
||||
it("increments hit count and updates lastHitAt", async () => {
|
||||
const redirect = await repo.create({
|
||||
source: "/a",
|
||||
destination: "/b",
|
||||
});
|
||||
expect(redirect.hits).toBe(0);
|
||||
expect(redirect.lastHitAt).toBeNull();
|
||||
|
||||
await repo.recordHit(redirect.id);
|
||||
const updated = await repo.findById(redirect.id);
|
||||
expect(updated!.hits).toBe(1);
|
||||
expect(updated!.lastHitAt).toBeTruthy();
|
||||
|
||||
await repo.recordHit(redirect.id);
|
||||
const again = await repo.findById(redirect.id);
|
||||
expect(again!.hits).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auto-redirects -----------------------------------------------------
|
||||
|
||||
describe("createAutoRedirect", () => {
|
||||
it("creates a redirect for slug change with url pattern", async () => {
|
||||
const redirect = await repo.createAutoRedirect(
|
||||
"posts",
|
||||
"old-title",
|
||||
"new-title",
|
||||
"id1",
|
||||
"/blog/{slug}",
|
||||
);
|
||||
|
||||
expect(redirect.source).toBe("/blog/old-title");
|
||||
expect(redirect.destination).toBe("/blog/new-title");
|
||||
expect(redirect.auto).toBe(true);
|
||||
expect(redirect.groupName).toBe("Auto: slug change");
|
||||
expect(redirect.type).toBe(301);
|
||||
});
|
||||
|
||||
it("uses fallback URL when no url pattern", async () => {
|
||||
const redirect = await repo.createAutoRedirect("posts", "old-slug", "new-slug", "id1", null);
|
||||
|
||||
expect(redirect.source).toBe("/posts/old-slug");
|
||||
expect(redirect.destination).toBe("/posts/new-slug");
|
||||
});
|
||||
|
||||
it("collapses existing chains", async () => {
|
||||
// First rename: A -> B
|
||||
await repo.createAutoRedirect("posts", "title-a", "title-b", "id1", "/blog/{slug}");
|
||||
|
||||
// Second rename: B -> C (should update A's destination to C)
|
||||
await repo.createAutoRedirect("posts", "title-b", "title-c", "id1", "/blog/{slug}");
|
||||
|
||||
// Check that the A -> B redirect now points to C
|
||||
const aRedirect = await repo.findBySource("/blog/title-a");
|
||||
expect(aRedirect!.destination).toBe("/blog/title-c");
|
||||
|
||||
// And B -> C also exists
|
||||
const bRedirect = await repo.findBySource("/blog/title-b");
|
||||
expect(bRedirect!.destination).toBe("/blog/title-c");
|
||||
});
|
||||
|
||||
it("updates existing redirect from same source instead of duplicating", async () => {
|
||||
// Create A -> B
|
||||
await repo.createAutoRedirect("posts", "a", "b", "id1", "/blog/{slug}");
|
||||
|
||||
// Create A -> C (same source /blog/a, different dest)
|
||||
// This calls collapseChains first, which doesn't touch /blog/a since
|
||||
// nothing points to /blog/a as destination.
|
||||
// Then it finds existing source=/blog/a and updates its destination.
|
||||
await repo.createAutoRedirect("posts", "a", "c", "id1", "/blog/{slug}");
|
||||
|
||||
const all = await repo.findMany({});
|
||||
// Should only have one redirect from /blog/a
|
||||
const fromA = all.items.filter((r) => r.source === "/blog/a");
|
||||
expect(fromA).toHaveLength(1);
|
||||
expect(fromA[0]!.destination).toBe("/blog/c");
|
||||
});
|
||||
});
|
||||
|
||||
// --- 404 log ------------------------------------------------------------
|
||||
|
||||
describe("log404", () => {
|
||||
it("logs a 404 entry", async () => {
|
||||
await repo.log404({ path: "/missing" });
|
||||
const result = await repo.find404s({});
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.path).toBe("/missing");
|
||||
});
|
||||
|
||||
it("logs with metadata", async () => {
|
||||
await repo.log404({
|
||||
path: "/missing",
|
||||
referrer: "https://google.com",
|
||||
userAgent: "Mozilla/5.0",
|
||||
ip: "1.2.3.4",
|
||||
});
|
||||
const result = await repo.find404s({});
|
||||
const entry = result.items[0]!;
|
||||
expect(entry.referrer).toBe("https://google.com");
|
||||
expect(entry.userAgent).toBe("Mozilla/5.0");
|
||||
expect(entry.ip).toBe("1.2.3.4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("find404s", () => {
|
||||
it("filters by search", async () => {
|
||||
await repo.log404({ path: "/missing-blog-post" });
|
||||
await repo.log404({ path: "/about-us" });
|
||||
|
||||
const result = await repo.find404s({ search: "blog" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.path).toBe("/missing-blog-post");
|
||||
});
|
||||
|
||||
it("paginates", async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await repo.log404({ path: `/missing-${i}` });
|
||||
}
|
||||
|
||||
const page1 = await repo.find404s({ limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
const page2 = await repo.find404s({ limit: 2, cursor: page1.nextCursor });
|
||||
expect(page2.items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get404Summary", () => {
|
||||
it("groups by path and counts", async () => {
|
||||
await repo.log404({ path: "/a" });
|
||||
await repo.log404({ path: "/a" });
|
||||
await repo.log404({ path: "/a" });
|
||||
await repo.log404({ path: "/b" });
|
||||
|
||||
const summary = await repo.get404Summary();
|
||||
expect(summary).toHaveLength(2);
|
||||
// Ordered by count desc
|
||||
expect(summary[0]!.path).toBe("/a");
|
||||
expect(summary[0]!.count).toBe(3);
|
||||
expect(summary[1]!.path).toBe("/b");
|
||||
expect(summary[1]!.count).toBe(1);
|
||||
});
|
||||
|
||||
it("includes top referrer", async () => {
|
||||
await repo.log404({ path: "/x", referrer: "https://google.com" });
|
||||
await repo.log404({ path: "/x", referrer: "https://google.com" });
|
||||
await repo.log404({ path: "/x", referrer: "https://bing.com" });
|
||||
|
||||
const summary = await repo.get404Summary();
|
||||
expect(summary[0]!.topReferrer).toBe("https://google.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete404", () => {
|
||||
it("deletes a single 404 entry", async () => {
|
||||
await repo.log404({ path: "/a" });
|
||||
await repo.log404({ path: "/b" });
|
||||
|
||||
const all = await repo.find404s({});
|
||||
expect(all.items).toHaveLength(2);
|
||||
|
||||
await repo.delete404(all.items[0]!.id);
|
||||
const remaining = await repo.find404s({});
|
||||
expect(remaining.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear404s", () => {
|
||||
it("removes all 404 entries", async () => {
|
||||
await repo.log404({ path: "/a" });
|
||||
await repo.log404({ path: "/b" });
|
||||
|
||||
const count = await repo.clear404s();
|
||||
expect(count).toBe(2);
|
||||
|
||||
const result = await repo.find404s({});
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prune404s", () => {
|
||||
it("removes entries older than cutoff", async () => {
|
||||
await repo.log404({ path: "/old" });
|
||||
// All entries were just created, so pruning with a future date should clear them
|
||||
const count = await repo.prune404s("2099-01-01T00:00:00.000Z");
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps entries newer than cutoff", async () => {
|
||||
await repo.log404({ path: "/new" });
|
||||
const count = await repo.prune404s("2000-01-01T00:00:00.000Z");
|
||||
expect(count).toBe(0);
|
||||
|
||||
const result = await repo.find404s({});
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
579
packages/core/tests/integration/seed-on-conflict.test.ts
Normal file
579
packages/core/tests/integration/seed-on-conflict.test.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* Tests for seed --on-conflict modes: skip, update, error
|
||||
*
|
||||
* Verifies that applySeed() correctly handles conflicts when records
|
||||
* already exist in the database.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
import { applySeed } from "../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../src/seed/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../utils/test-db.js";
|
||||
|
||||
/**
|
||||
* Minimal seed file with one collection, one byline, one redirect, and one section
|
||||
*/
|
||||
function createTestSeed(overrides?: Partial<SeedFile>): SeedFile {
|
||||
return {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
fields: [
|
||||
{ slug: "title", label: "Title", type: "string" },
|
||||
{ slug: "body", label: "Body", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
bylines: [
|
||||
{
|
||||
id: "byline-1",
|
||||
slug: "jane-doe",
|
||||
displayName: "Jane Doe",
|
||||
bio: "Original bio",
|
||||
},
|
||||
],
|
||||
redirects: [
|
||||
{
|
||||
source: "/old-page",
|
||||
destination: "/new-page",
|
||||
type: 301,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
slug: "hero",
|
||||
title: "Hero Section",
|
||||
description: "Original description",
|
||||
content: [{ _type: "block", _key: "1" }],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello World", body: "Original body" },
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed file with updated values for all entities
|
||||
*/
|
||||
function createUpdatedSeed(): SeedFile {
|
||||
return {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Blog Posts",
|
||||
labelSingular: "Blog Post",
|
||||
fields: [
|
||||
{ slug: "title", label: "Post Title", type: "string" },
|
||||
{ slug: "body", label: "Post Body", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
bylines: [
|
||||
{
|
||||
id: "byline-1",
|
||||
slug: "jane-doe",
|
||||
displayName: "Jane Smith",
|
||||
bio: "Updated bio",
|
||||
},
|
||||
],
|
||||
redirects: [
|
||||
{
|
||||
source: "/old-page",
|
||||
destination: "/newer-page",
|
||||
type: 302,
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
slug: "hero",
|
||||
title: "Updated Hero",
|
||||
description: "Updated description",
|
||||
content: [{ _type: "block", _key: "2" }],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello World Updated", body: "Updated body" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("applySeed onConflict modes", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("onConflict: skip (default)", () => {
|
||||
it("skips existing collections", async () => {
|
||||
const seed = createTestSeed();
|
||||
// First apply
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
// Second apply with default (skip)
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.collections.created).toBe(0);
|
||||
expect(result.collections.skipped).toBe(1);
|
||||
expect(result.collections.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing bylines", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.bylines.created).toBe(0);
|
||||
expect(result.bylines.skipped).toBe(1);
|
||||
expect(result.bylines.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing redirects", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
const result = await applySeed(db, seed);
|
||||
|
||||
expect(result.redirects.created).toBe(0);
|
||||
expect(result.redirects.skipped).toBe(1);
|
||||
expect(result.redirects.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing sections", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
const result = await applySeed(db, seed);
|
||||
|
||||
expect(result.sections.created).toBe(0);
|
||||
expect(result.sections.skipped).toBe(1);
|
||||
expect(result.sections.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("skips existing content", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.content.created).toBe(0);
|
||||
expect(result.content.skipped).toBe(1);
|
||||
expect(result.content.updated).toBe(0);
|
||||
});
|
||||
|
||||
it("defaults to skip when onConflict is not specified", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
// No onConflict specified -- should default to skip
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.collections.skipped).toBe(1);
|
||||
expect(result.collections.created).toBe(0);
|
||||
expect(result.collections.updated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onConflict: update", () => {
|
||||
it("updates existing collections and fields", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.collections.updated).toBe(1);
|
||||
expect(result.collections.created).toBe(0);
|
||||
expect(result.fields.updated).toBe(2);
|
||||
|
||||
// Verify the collection was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_collections")
|
||||
.selectAll()
|
||||
.where("slug", "=", "posts")
|
||||
.executeTakeFirst();
|
||||
expect(row?.label).toBe("Blog Posts");
|
||||
expect(row?.label_singular).toBe("Blog Post");
|
||||
});
|
||||
|
||||
it("updates existing bylines", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.bylines.updated).toBe(1);
|
||||
expect(result.bylines.created).toBe(0);
|
||||
|
||||
// Verify the byline was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_bylines")
|
||||
.selectAll()
|
||||
.where("slug", "=", "jane-doe")
|
||||
.executeTakeFirst();
|
||||
expect(row?.display_name).toBe("Jane Smith");
|
||||
expect(row?.bio).toBe("Updated bio");
|
||||
});
|
||||
|
||||
it("updates existing redirects", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.redirects.updated).toBe(1);
|
||||
expect(result.redirects.created).toBe(0);
|
||||
|
||||
// Verify the redirect was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_redirects")
|
||||
.selectAll()
|
||||
.where("source", "=", "/old-page")
|
||||
.executeTakeFirst();
|
||||
expect(row?.destination).toBe("/newer-page");
|
||||
expect(row?.type).toBe(302);
|
||||
});
|
||||
|
||||
it("updates existing sections", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed);
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.sections.updated).toBe(1);
|
||||
expect(result.sections.created).toBe(0);
|
||||
|
||||
// Verify the section was actually updated
|
||||
const row = await db
|
||||
.selectFrom("_emdash_sections")
|
||||
.selectAll()
|
||||
.where("slug", "=", "hero")
|
||||
.executeTakeFirst();
|
||||
expect(row?.title).toBe("Updated Hero");
|
||||
expect(row?.description).toBe("Updated description");
|
||||
});
|
||||
|
||||
it("updates existing content", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
const updatedSeed = createUpdatedSeed();
|
||||
const result = await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.content.updated).toBe(1);
|
||||
expect(result.content.created).toBe(0);
|
||||
|
||||
// Verify the content was actually updated
|
||||
const row = await db
|
||||
.selectFrom("ec_posts" as any)
|
||||
.selectAll()
|
||||
.where("slug", "=", "hello-world")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((row as Record<string, unknown>).title).toBe("Hello World Updated");
|
||||
expect((row as Record<string, unknown>).body).toBe("Updated body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onConflict: error", () => {
|
||||
it("throws on existing collection", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
await expect(
|
||||
applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
onConflict: "error",
|
||||
}),
|
||||
).rejects.toThrow('Conflict: collection "posts" already exists');
|
||||
});
|
||||
|
||||
it("throws on existing byline", async () => {
|
||||
// Seed without collections to get past collections step
|
||||
const seed = createTestSeed({ collections: [] });
|
||||
await applySeed(db, seed);
|
||||
|
||||
await expect(applySeed(db, seed, { onConflict: "error" })).rejects.toThrow(
|
||||
'Conflict: byline "jane-doe" already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on existing redirect", async () => {
|
||||
const seed = createTestSeed({
|
||||
collections: [],
|
||||
bylines: [],
|
||||
sections: [],
|
||||
});
|
||||
await applySeed(db, seed);
|
||||
|
||||
await expect(applySeed(db, seed, { onConflict: "error" })).rejects.toThrow(
|
||||
'Conflict: redirect "/old-page" already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on existing section", async () => {
|
||||
const seed = createTestSeed({
|
||||
collections: [],
|
||||
bylines: [],
|
||||
redirects: [],
|
||||
});
|
||||
await applySeed(db, seed);
|
||||
|
||||
await expect(applySeed(db, seed, { onConflict: "error" })).rejects.toThrow(
|
||||
'Conflict: section "hero" already exists',
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on existing content", async () => {
|
||||
// First apply creates collections and content
|
||||
const seed = createTestSeed({
|
||||
bylines: [],
|
||||
redirects: [],
|
||||
sections: [],
|
||||
});
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Second apply with only content (collections already exist, skip them)
|
||||
const contentOnlySeed = createTestSeed({
|
||||
collections: [],
|
||||
bylines: [],
|
||||
redirects: [],
|
||||
sections: [],
|
||||
});
|
||||
await expect(
|
||||
applySeed(db, contentOnlySeed, {
|
||||
includeContent: true,
|
||||
onConflict: "error",
|
||||
}),
|
||||
).rejects.toThrow('Conflict: content "hello-world" in "posts" already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed scenarios", () => {
|
||||
it("creates new records alongside existing ones in update mode", async () => {
|
||||
const seed = createTestSeed();
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Add a new content entry to the seed
|
||||
const extendedSeed = createUpdatedSeed();
|
||||
const posts = extendedSeed.content!["posts"];
|
||||
if (!posts) throw new Error("posts missing from seed");
|
||||
posts.push({
|
||||
id: "post-2",
|
||||
slug: "second-post",
|
||||
status: "published",
|
||||
data: { title: "Second Post", body: "New content" },
|
||||
});
|
||||
|
||||
const result = await applySeed(db, extendedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
expect(result.content.updated).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
});
|
||||
|
||||
it("clears taxonomy assignments on content update when seed removes them", async () => {
|
||||
// Seed with a taxonomy and content that has taxonomy assignments
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
taxonomies: [
|
||||
{
|
||||
name: "categories",
|
||||
label: "Categories",
|
||||
hierarchical: false,
|
||||
collections: ["posts"],
|
||||
terms: [
|
||||
{ slug: "news", label: "News" },
|
||||
{ slug: "tech", label: "Tech" },
|
||||
],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello" },
|
||||
taxonomies: { categories: ["news", "tech"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Verify both terms are attached
|
||||
const beforeRows = await db
|
||||
.selectFrom("content_taxonomies")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.execute();
|
||||
expect(beforeRows).toHaveLength(2);
|
||||
|
||||
// Re-apply with only one taxonomy term
|
||||
const updatedSeed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
taxonomies: [
|
||||
{
|
||||
name: "categories",
|
||||
label: "Categories",
|
||||
hierarchical: false,
|
||||
collections: ["posts"],
|
||||
terms: [
|
||||
{ slug: "news", label: "News" },
|
||||
{ slug: "tech", label: "Tech" },
|
||||
],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello Updated" },
|
||||
taxonomies: { categories: ["tech"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
// Should only have "tech" now, not both
|
||||
const afterRows = await db
|
||||
.selectFrom("content_taxonomies")
|
||||
.selectAll()
|
||||
.where("collection", "=", "posts")
|
||||
.execute();
|
||||
expect(afterRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears byline assignments on content update when seed removes them", async () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
bylines: [{ id: "byline-1", slug: "jane-doe", displayName: "Jane Doe" }],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello" },
|
||||
bylines: [{ byline: "byline-1" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
// Verify byline is attached
|
||||
const beforeRows = await db
|
||||
.selectFrom("_emdash_content_bylines")
|
||||
.selectAll()
|
||||
.where("collection_slug", "=", "posts")
|
||||
.execute();
|
||||
expect(beforeRows).toHaveLength(1);
|
||||
|
||||
// Re-apply without bylines on the content entry
|
||||
const updatedSeed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
bylines: [{ id: "byline-1", slug: "jane-doe", displayName: "Jane Doe" }],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello Updated" },
|
||||
// No bylines -- should clear existing
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await applySeed(db, updatedSeed, {
|
||||
includeContent: true,
|
||||
onConflict: "update",
|
||||
});
|
||||
|
||||
// Should have no bylines now
|
||||
const afterRows = await db
|
||||
.selectFrom("_emdash_content_bylines")
|
||||
.selectAll()
|
||||
.where("collection_slug", "=", "posts")
|
||||
.execute();
|
||||
expect(afterRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
1059
packages/core/tests/integration/seo/seo.test.ts
Normal file
1059
packages/core/tests/integration/seo/seo.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
374
packages/core/tests/integration/server.ts
Normal file
374
packages/core/tests/integration/server.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Integration test server helper.
|
||||
*
|
||||
* Bootstraps an isolated Astro dev server from a minimal fixture,
|
||||
* runs setup, seeds test data, and creates auth tokens. Each test
|
||||
* suite gets a fresh database and server process.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const ctx = await createTestServer({ port: 4399 });
|
||||
* // ctx.client — EmDashClient (devBypass auth)
|
||||
* // ctx.token — PAT bearer token for CLI tests
|
||||
* // ctx.baseUrl — http://localhost:4399
|
||||
* // ctx.cwd — working directory of the running server
|
||||
* await ctx.cleanup();
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, symlinkSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { EmDashClient } from "../../src/client/index.js";
|
||||
|
||||
const execAsync = promisify(execFile);
|
||||
|
||||
// Test regex patterns
|
||||
const SESSION_COOKIE_REGEX = /^([^;]+)/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FIXTURE_DIR = resolve(import.meta.dirname, "fixture");
|
||||
// Borrow node_modules from demos/simple — it has all the deps we need
|
||||
// and is maintained by pnpm workspace resolution.
|
||||
const DONOR_NODE_MODULES = resolve(import.meta.dirname, "../../../../demos/simple/node_modules");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestServerOptions {
|
||||
port: number;
|
||||
/** Server startup timeout in ms (default: 30_000) */
|
||||
timeout?: number;
|
||||
/** Seed test data after setup (default: true) */
|
||||
seed?: boolean;
|
||||
}
|
||||
|
||||
export interface TestServerContext {
|
||||
/** Base URL of the running server */
|
||||
baseUrl: string;
|
||||
/** Working directory containing the fixture */
|
||||
cwd: string;
|
||||
/** EmDashClient authenticated via dev-bypass session */
|
||||
client: EmDashClient;
|
||||
/** PAT bearer token with full scopes (for CLI / raw fetch tests) */
|
||||
token: string;
|
||||
/** Seeded collection slugs */
|
||||
collections: string[];
|
||||
/** Seeded content IDs keyed by collection */
|
||||
contentIds: Record<string, string[]>;
|
||||
/** Session cookie string for raw fetch calls needing session auth */
|
||||
sessionCookie: string;
|
||||
/** Stop the server and remove the temp directory */
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node.js version guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Astro requires Node.js >= 22.12.0. Call from a `beforeAll` to fail the
|
||||
* suite immediately when the environment is misconfigured rather than
|
||||
* silently skipping.
|
||||
*/
|
||||
export function assertNodeVersion(): void {
|
||||
const [major, minor] = process.versions.node.split(".").map(Number) as [number, number];
|
||||
const ok = major! > 22 || (major === 22 && minor! >= 12);
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
`Integration tests require Node.js >= 22.12.0 (running ${process.versions.node}). ` +
|
||||
`Update your Node version instead of skipping tests.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../..");
|
||||
const CLI_BINARY = resolve(import.meta.dirname, "../../dist/cli/index.mjs");
|
||||
|
||||
let buildPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Ensure the workspace is built before starting integration tests.
|
||||
* Runs `pnpm build` once (cached across test suites via module-level promise).
|
||||
* Skips if the CLI binary already exists.
|
||||
*/
|
||||
export function ensureBuilt(): Promise<void> {
|
||||
if (!buildPromise) {
|
||||
buildPromise = doBuild();
|
||||
}
|
||||
return buildPromise;
|
||||
}
|
||||
|
||||
async function doBuild(): Promise<void> {
|
||||
if (existsSync(CLI_BINARY)) return;
|
||||
|
||||
console.log("[integration] Built artifacts missing — running pnpm build...");
|
||||
await execAsync("pnpm", ["build"], {
|
||||
cwd: WORKSPACE_ROOT,
|
||||
timeout: 120_000,
|
||||
});
|
||||
console.log("[integration] Build complete.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
||||
// Any HTTP response (even 500) means the server is up.
|
||||
// We only keep waiting on connection errors (caught below).
|
||||
if (res.status > 0) return;
|
||||
} catch {
|
||||
// Server not ready yet — connection refused / timeout
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Astro dev server for integration testing.
|
||||
*
|
||||
* Runs the fixture in-place to avoid Astro virtual module resolution
|
||||
* issues with symlinked temp dirs. Uses a temp directory only for the
|
||||
* database file — source files stay at their real paths.
|
||||
*/
|
||||
export async function createTestServer(options: TestServerOptions): Promise<TestServerContext> {
|
||||
const { port, timeout = 60_000, seed = true } = options;
|
||||
const baseUrl = `http://localhost:${port}`;
|
||||
|
||||
// --- 0. Ensure workspace is built ---
|
||||
await ensureBuilt();
|
||||
|
||||
// --- 1. Run fixture in-place, temp dir only for DB ---
|
||||
const workDir = FIXTURE_DIR;
|
||||
const tempDataDir = mkdtempSync(join(tmpdir(), "emdash-integration-"));
|
||||
const dbPath = join(tempDataDir, "test.db");
|
||||
|
||||
// Ensure node_modules symlink exists in the fixture dir.
|
||||
// Multiple test suites may race to create this — handle EEXIST gracefully.
|
||||
const fixtureNodeModules = join(FIXTURE_DIR, "node_modules");
|
||||
let createdSymlink = false;
|
||||
if (!existsSync(fixtureNodeModules)) {
|
||||
try {
|
||||
symlinkSync(DONOR_NODE_MODULES, fixtureNodeModules);
|
||||
createdSymlink = true;
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Start dev server ---
|
||||
const astroBin = join(fixtureNodeModules, ".bin", "astro");
|
||||
const server = spawn(astroBin, ["dev", "--port", String(port)], {
|
||||
cwd: workDir,
|
||||
env: {
|
||||
...process.env,
|
||||
EMDASH_TEST_DB: `file:${dbPath}`,
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
// Always capture server output. Forward to stderr when DEBUG is set,
|
||||
// and always keep a ring buffer of the last 5 KB for error reporting.
|
||||
let serverOutput = "";
|
||||
const MAX_OUTPUT = 5000;
|
||||
function appendOutput(chunk: string): void {
|
||||
if (process.env.DEBUG) process.stderr.write(`[integration:${port}] ${chunk}`);
|
||||
serverOutput += chunk;
|
||||
if (serverOutput.length > MAX_OUTPUT * 2) {
|
||||
serverOutput = serverOutput.slice(-MAX_OUTPUT);
|
||||
}
|
||||
}
|
||||
server.stdout?.on("data", (data: Buffer) => appendOutput(data.toString()));
|
||||
server.stderr?.on("data", (data: Buffer) => appendOutput(data.toString()));
|
||||
|
||||
// Track for cleanup
|
||||
let stopped = false;
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Force kill if still alive
|
||||
if (!server.killed) {
|
||||
server.kill("SIGKILL");
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
// Remove temp data directory
|
||||
rmSync(tempDataDir, { recursive: true, force: true });
|
||||
|
||||
// Remove symlink if we created it
|
||||
if (createdSymlink && existsSync(fixtureNodeModules)) {
|
||||
try {
|
||||
unlinkSync(fixtureNodeModules);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// --- 3. Wait for server to be ready ---
|
||||
await waitForServer(`${baseUrl}/_emdash/api/setup/dev-bypass`, timeout);
|
||||
|
||||
// --- 4. Run setup + create PAT in one request ---
|
||||
// The ?token query param tells the dev-bypass endpoint to also
|
||||
// create a PAT with full scopes and return it in the response.
|
||||
const setupRes = await fetch(`${baseUrl}/_emdash/api/setup/dev-bypass?token=1`);
|
||||
if (!setupRes.ok) {
|
||||
const body = await setupRes.text().catch(() => "");
|
||||
throw new Error(`Setup bypass failed (${setupRes.status}): ${body}`);
|
||||
}
|
||||
const setupJson = (await setupRes.json()) as {
|
||||
data: { user: { id: string; email: string }; token?: string };
|
||||
};
|
||||
const setupData = setupJson.data;
|
||||
const token = setupData.token;
|
||||
if (!token) {
|
||||
throw new Error("Setup bypass did not return a PAT token");
|
||||
}
|
||||
|
||||
// Extract session cookie for raw fetch calls that need session auth
|
||||
const setCookie = setupRes.headers.get("set-cookie");
|
||||
let sessionCookie = "";
|
||||
if (setCookie) {
|
||||
const match = setCookie.match(SESSION_COOKIE_REGEX);
|
||||
if (match) sessionCookie = match[1]!;
|
||||
}
|
||||
|
||||
// --- 5. Create client authenticated via PAT ---
|
||||
const client = new EmDashClient({
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
// --- 8. Seed test data ---
|
||||
const collections: string[] = [];
|
||||
const contentIds: Record<string, string[]> = {};
|
||||
|
||||
if (seed) {
|
||||
await seedTestData(client, collections, contentIds);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
cwd: workDir,
|
||||
client,
|
||||
token,
|
||||
collections,
|
||||
contentIds,
|
||||
sessionCookie,
|
||||
cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
// Include server output in error for CI debugging
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
await cleanup();
|
||||
throw new Error(
|
||||
`${msg}\n\nServer output (last ${MAX_OUTPUT} chars):\n${serverOutput.slice(-MAX_OUTPUT)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Seeds sample content into the test server.
|
||||
*
|
||||
* Collections and fields are created by the seed file
|
||||
* (fixture/.emdash/seed.json) during dev-bypass setup.
|
||||
* This function only creates content entries.
|
||||
*
|
||||
* Content:
|
||||
* - posts: 3 items (2 published, 1 draft)
|
||||
* - pages: 2 items (1 published, 1 draft)
|
||||
*/
|
||||
async function seedTestData(
|
||||
client: EmDashClient,
|
||||
collections: string[],
|
||||
contentIds: Record<string, string[]>,
|
||||
): Promise<void> {
|
||||
collections.push("posts");
|
||||
collections.push("pages");
|
||||
|
||||
const postIds: string[] = [];
|
||||
|
||||
const post1 = await client.create("posts", {
|
||||
data: {
|
||||
title: "First Post",
|
||||
body: "Hello **world**. This is the first post.",
|
||||
excerpt: "The very first post",
|
||||
},
|
||||
slug: "first-post",
|
||||
});
|
||||
postIds.push(post1.id);
|
||||
await client.publish("posts", post1.id);
|
||||
|
||||
const post2 = await client.create("posts", {
|
||||
data: {
|
||||
title: "Second Post",
|
||||
body: "A second post with a [link](https://example.com).",
|
||||
excerpt: "Another post",
|
||||
},
|
||||
slug: "second-post",
|
||||
});
|
||||
postIds.push(post2.id);
|
||||
await client.publish("posts", post2.id);
|
||||
|
||||
const post3 = await client.create("posts", {
|
||||
data: {
|
||||
title: "Draft Post",
|
||||
body: "This post is still a draft.",
|
||||
excerpt: "Not published yet",
|
||||
},
|
||||
slug: "draft-post",
|
||||
});
|
||||
postIds.push(post3.id);
|
||||
|
||||
contentIds["posts"] = postIds;
|
||||
|
||||
const pageIds: string[] = [];
|
||||
|
||||
const page1 = await client.create("pages", {
|
||||
data: {
|
||||
title: "About",
|
||||
body: "# About Us\n\nWe are a **test** fixture.",
|
||||
},
|
||||
slug: "about",
|
||||
});
|
||||
pageIds.push(page1.id);
|
||||
await client.publish("pages", page1.id);
|
||||
|
||||
const page2 = await client.create("pages", {
|
||||
data: {
|
||||
title: "Contact",
|
||||
body: "Get in touch.",
|
||||
},
|
||||
slug: "contact",
|
||||
});
|
||||
pageIds.push(page2.id);
|
||||
|
||||
contentIds["pages"] = pageIds;
|
||||
}
|
||||
259
packages/core/tests/integration/smoke/site-matrix-smoke.test.ts
Normal file
259
packages/core/tests/integration/smoke/site-matrix-smoke.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ensureBuilt } from "../server.js";
|
||||
|
||||
interface RuntimeSiteCase {
|
||||
name: string;
|
||||
dir: string;
|
||||
port: number;
|
||||
mode: "runtime";
|
||||
startupTimeoutMs: number;
|
||||
waitPath?: string;
|
||||
setupPath?: string | null;
|
||||
frontendPath?: string;
|
||||
frontendStatuses?: number[];
|
||||
requireDoctype?: boolean;
|
||||
}
|
||||
|
||||
interface TypecheckSiteCase {
|
||||
name: string;
|
||||
dir: string;
|
||||
mode: "typecheck";
|
||||
}
|
||||
|
||||
type SiteCase = RuntimeSiteCase | TypecheckSiteCase;
|
||||
|
||||
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../..");
|
||||
const execAsync = promisify(execFile);
|
||||
|
||||
const SITE_MATRIX: SiteCase[] = [
|
||||
// Demos
|
||||
{
|
||||
name: "demos/simple",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/simple"),
|
||||
port: 4601,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
name: "demos/cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/cloudflare"),
|
||||
port: 4602,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: "demos/playground",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/playground"),
|
||||
port: 4603,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
waitPath: "/playground",
|
||||
frontendPath: "/playground",
|
||||
requireDoctype: false,
|
||||
},
|
||||
{
|
||||
name: "demos/preview",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/preview"),
|
||||
port: 4604,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
setupPath: null,
|
||||
frontendStatuses: [400],
|
||||
requireDoctype: false,
|
||||
},
|
||||
// Postgres demo requires DATABASE_URL — skip when not available
|
||||
...(process.env.DATABASE_URL
|
||||
? [
|
||||
{
|
||||
name: "demos/postgres",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/postgres"),
|
||||
port: 4605,
|
||||
mode: "runtime" as const,
|
||||
startupTimeoutMs: 90_000,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "demos/plugins-demo",
|
||||
dir: resolve(WORKSPACE_ROOT, "demos/plugins-demo"),
|
||||
port: 4606,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 90_000,
|
||||
},
|
||||
|
||||
// Templates
|
||||
{
|
||||
name: "templates/blank",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/blank"),
|
||||
port: 4611,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
name: "templates/blog",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/blog"),
|
||||
port: 4612,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 60_000,
|
||||
},
|
||||
{
|
||||
name: "templates/blog-cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/blog-cloudflare"),
|
||||
port: 4613,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: "templates/marketing",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/marketing"),
|
||||
port: 4614,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 90_000,
|
||||
},
|
||||
{
|
||||
name: "templates/marketing-cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/marketing-cloudflare"),
|
||||
port: 4615,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: "templates/portfolio",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/portfolio"),
|
||||
port: 4616,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 90_000,
|
||||
},
|
||||
{
|
||||
name: "templates/portfolio-cloudflare",
|
||||
dir: resolve(WORKSPACE_ROOT, "templates/portfolio-cloudflare"),
|
||||
port: 4617,
|
||||
mode: "runtime",
|
||||
startupTimeoutMs: 120_000,
|
||||
},
|
||||
];
|
||||
|
||||
async function waitForServer(url: string, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.status > 0) return;
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((resolveSleep) => setTimeout(resolveSleep, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, retries = 10, delayMs = 1500): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (res.status < 500) return res;
|
||||
lastError = new Error(`${url} returned ${res.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
await new Promise((resolveSleep) => setTimeout(resolveSleep, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(`Request failed for ${url}`);
|
||||
}
|
||||
|
||||
describe.sequential("Site smoke matrix", () => {
|
||||
for (const site of SITE_MATRIX) {
|
||||
if (site.mode === "typecheck") {
|
||||
it(`${site.name} typechecks`, { timeout: 120_000 }, async () => {
|
||||
await execAsync("pnpm", ["run", "typecheck"], {
|
||||
cwd: site.dir,
|
||||
timeout: 120_000,
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const waitPath = site.waitPath ?? "/_emdash/admin/";
|
||||
const setupPath = site.setupPath ?? "/_emdash/api/setup/dev-bypass?redirect=/";
|
||||
const frontendPath = site.frontendPath ?? "/";
|
||||
const frontendStatuses = site.frontendStatuses ?? [200, 302, 307, 308];
|
||||
const requireDoctype = site.requireDoctype ?? true;
|
||||
|
||||
it(
|
||||
`${site.name} boots and serves admin + frontend`,
|
||||
{ timeout: site.startupTimeoutMs + 120_000 },
|
||||
async () => {
|
||||
await ensureBuilt();
|
||||
|
||||
const baseUrl = `http://localhost:${site.port}`;
|
||||
const serverProcess = spawn("pnpm", ["exec", "astro", "dev", "--port", String(site.port)], {
|
||||
cwd: site.dir,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
let output = "";
|
||||
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(`${baseUrl}${waitPath}`, site.startupTimeoutMs);
|
||||
|
||||
if (setupPath) {
|
||||
const setupRes = await fetchWithRetry(`${baseUrl}${setupPath}`);
|
||||
expect(setupRes.status).toBeLessThan(500);
|
||||
}
|
||||
|
||||
const adminRes = await fetchWithRetry(`${baseUrl}/_emdash/admin/`);
|
||||
expect(adminRes.status).toBeLessThan(500);
|
||||
|
||||
const frontendRes = await fetchWithRetry(`${baseUrl}${frontendPath}`);
|
||||
expect(frontendStatuses).toContain(frontendRes.status);
|
||||
|
||||
const body = await frontendRes.text();
|
||||
if (requireDoctype) {
|
||||
expect(body).toContain("<!DOCTYPE html>");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${site.name} smoke failed: ${error instanceof Error ? error.message : String(error)}\n\n` +
|
||||
output.slice(-3000),
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
serverProcess.kill("SIGTERM");
|
||||
await new Promise((resolveSleep) => setTimeout(resolveSleep, 1200));
|
||||
if (!serverProcess.killed) {
|
||||
serverProcess.kill("SIGKILL");
|
||||
await new Promise((resolveSleep) => setTimeout(resolveSleep, 500));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
401
packages/core/tests/integration/smoke/template-smoke.test.ts
Normal file
401
packages/core/tests/integration/smoke/template-smoke.test.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Smoke tests for template/demo seed fixtures.
|
||||
*
|
||||
* Validates that all seed files are well-formed, can be applied
|
||||
* to a fresh database, and that the resulting database passes
|
||||
* doctor checks. Does NOT start a dev server — these are fast,
|
||||
* programmatic tests that exercise the seed/validate/apply/doctor
|
||||
* pipeline directly.
|
||||
*
|
||||
* Also shells out to the CLI binary for seed --validate and doctor
|
||||
* commands to ensure the CLI interface works correctly.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, readFileSync, readdirSync, mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import { applySeed } from "../../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../../src/seed/types.js";
|
||||
import { validateSeed } from "../../../src/seed/validate.js";
|
||||
import { LocalStorage } from "../../../src/storage/local.js";
|
||||
import { ensureBuilt } from "../server.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const WORKSPACE_ROOT = resolve(import.meta.dirname, "../../../../..");
|
||||
const CLI_BIN = resolve(import.meta.dirname, "../../../dist/cli/index.mjs");
|
||||
const VALIDATION_FAILED_RE = /validation failed/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discover all templates and demos with seed files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SiteFixture {
|
||||
/** Human-readable name for test output */
|
||||
name: string;
|
||||
/** Absolute path to the template/theme directory */
|
||||
dir: string;
|
||||
/** Absolute path to the seed file */
|
||||
seedPath: string;
|
||||
/** Parsed seed file contents */
|
||||
seed: SeedFile;
|
||||
}
|
||||
|
||||
function discoverFixtures(): SiteFixture[] {
|
||||
const fixtures: SiteFixture[] = [];
|
||||
|
||||
const dirs = [
|
||||
{ prefix: "templates", path: resolve(WORKSPACE_ROOT, "templates") },
|
||||
{ prefix: "demos", path: resolve(WORKSPACE_ROOT, "demos") },
|
||||
];
|
||||
|
||||
for (const { prefix, path: parentDir } of dirs) {
|
||||
if (!existsSync(parentDir)) continue;
|
||||
|
||||
for (const entry of readdirSync(parentDir)) {
|
||||
const dir = join(parentDir, entry);
|
||||
|
||||
// Check for seed path in package.json first (emdash.seed config)
|
||||
let seedPath = join(dir, ".emdash", "seed.json");
|
||||
const pkgPath = join(dir, "package.json");
|
||||
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
if (pkg.emdash?.seed) {
|
||||
seedPath = join(dir, pkg.emdash.seed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(seedPath)) continue;
|
||||
|
||||
const raw = readFileSync(seedPath, "utf-8");
|
||||
const seed = JSON.parse(raw) as SeedFile;
|
||||
|
||||
fixtures.push({
|
||||
name: `${prefix}/${entry}`,
|
||||
dir,
|
||||
seedPath,
|
||||
seed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
const fixtures = discoverFixtures();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Seed Fixture Smoke Tests", () => {
|
||||
let tempDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure CLI binary is built for CLI-based tests
|
||||
await ensureBuilt();
|
||||
}, 120_000);
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any temp directories created during tests
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "emdash-smoke-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Sanity check: we actually found fixtures to test
|
||||
it("discovers at least one template/demo with a seed file", () => {
|
||||
expect(fixtures.length).toBeGreaterThanOrEqual(1);
|
||||
const names = fixtures.map((f) => f.name);
|
||||
// At minimum the blog template should always be present.
|
||||
expect(names).toContain("templates/blog");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-fixture tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
describe(fixture.name, () => {
|
||||
// --- Seed file is valid JSON with correct structure ---
|
||||
|
||||
it("has a valid seed.json that parses as JSON", () => {
|
||||
expect(fixture.seed).toBeDefined();
|
||||
expect(fixture.seed.version).toBe("1");
|
||||
});
|
||||
|
||||
// --- Programmatic validation ---
|
||||
|
||||
it("passes programmatic seed validation", () => {
|
||||
const result = validateSeed(fixture.seed);
|
||||
if (!result.valid) {
|
||||
// Include errors in failure message for debuggability
|
||||
expect.fail(`Seed validation failed:\n${result.errors.join("\n")}`);
|
||||
}
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
// --- CLI --validate ---
|
||||
|
||||
it("passes CLI seed --validate", async () => {
|
||||
const { stdout, stderr } = await exec(
|
||||
"node",
|
||||
[CLI_BIN, "seed", fixture.seedPath, "--validate"],
|
||||
{
|
||||
cwd: fixture.dir,
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
// The validate command should succeed (exit 0) — if it throws,
|
||||
// the test will fail with the error message
|
||||
expect(stdout + stderr).not.toMatch(VALIDATION_FAILED_RE);
|
||||
});
|
||||
|
||||
// --- Seed applies to fresh database ---
|
||||
|
||||
it("applies seed to a fresh database without errors", { timeout: 30_000 }, async () => {
|
||||
const tempDir = createTempDir();
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const uploadsDir = join(tempDir, "uploads");
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
// Create database and run migrations
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
const { applied } = await runMigrations(db);
|
||||
expect(applied.length).toBeGreaterThan(0);
|
||||
|
||||
// Set up local storage for media resolution
|
||||
const storage = new LocalStorage({
|
||||
directory: uploadsDir,
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
});
|
||||
|
||||
// Apply seed
|
||||
const result = await applySeed(db, fixture.seed, {
|
||||
includeContent: true,
|
||||
onConflict: "skip",
|
||||
storage,
|
||||
mediaBasePath: join(fixture.dir, ".emdash"),
|
||||
});
|
||||
|
||||
// Verify collections were created
|
||||
if (fixture.seed.collections && fixture.seed.collections.length > 0) {
|
||||
expect(result.collections.created).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Verify fields were created
|
||||
const totalFields =
|
||||
fixture.seed.collections?.reduce((sum, c) => sum + (c.fields?.length ?? 0), 0) ?? 0;
|
||||
if (totalFields > 0) {
|
||||
expect(result.fields.created).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Verify content was created if seed has content
|
||||
if (fixture.seed.content) {
|
||||
const totalEntries = Object.values(fixture.seed.content).reduce(
|
||||
(sum, entries) => sum + (Array.isArray(entries) ? entries.length : 0),
|
||||
0,
|
||||
);
|
||||
if (totalEntries > 0) {
|
||||
expect(result.content.created).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify taxonomy processing completed (some may be pre-seeded by migrations)
|
||||
if (fixture.seed.taxonomies && fixture.seed.taxonomies.length > 0) {
|
||||
// Taxonomies either created or already existed — just verify no crash
|
||||
expect(result.taxonomies.created + result.taxonomies.terms).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
// Verify menus if present
|
||||
if (fixture.seed.menus && fixture.seed.menus.length > 0) {
|
||||
expect(result.menus.created).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// --- CLI seed apply + doctor ---
|
||||
|
||||
it("passes CLI doctor after seed apply", { timeout: 30_000 }, async () => {
|
||||
const tempDir = createTempDir();
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
|
||||
// Apply seed via CLI (this also runs migrations)
|
||||
await exec("node", [CLI_BIN, "seed", fixture.seedPath, "--database", dbPath], {
|
||||
cwd: fixture.dir,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Run doctor and verify all checks pass
|
||||
const { stdout } = await exec("node", [CLI_BIN, "doctor", "--database", dbPath, "--json"], {
|
||||
cwd: fixture.dir,
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const checks = JSON.parse(stdout) as Array<{
|
||||
name: string;
|
||||
status: "pass" | "warn" | "fail";
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
// No failures allowed
|
||||
const failures = checks.filter((c) => c.status === "fail");
|
||||
if (failures.length > 0) {
|
||||
expect.fail(
|
||||
`Doctor failures:\n${failures.map((f) => ` ${f.name}: ${f.message}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Database, migrations, and collections should all pass
|
||||
const dbCheck = checks.find((c) => c.name === "database");
|
||||
expect(dbCheck?.status).toBe("pass");
|
||||
|
||||
const migrationsCheck = checks.find((c) => c.name === "migrations");
|
||||
expect(migrationsCheck?.status).toBe("pass");
|
||||
|
||||
const collectionsCheck = checks.find((c) => c.name === "collections");
|
||||
expect(collectionsCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
// --- Idempotent re-apply ---
|
||||
|
||||
it(
|
||||
"can re-apply seed with on-conflict=skip without errors",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const tempDir = createTempDir();
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const uploadsDir = join(tempDir, "uploads");
|
||||
mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
await runMigrations(db);
|
||||
|
||||
const storage = new LocalStorage({
|
||||
directory: uploadsDir,
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
});
|
||||
|
||||
const seedOpts = {
|
||||
includeContent: true,
|
||||
onConflict: "skip" as const,
|
||||
storage,
|
||||
seedDir: join(fixture.dir, ".emdash"),
|
||||
};
|
||||
|
||||
// First apply
|
||||
await applySeed(db, fixture.seed, seedOpts);
|
||||
|
||||
// Second apply — should not throw
|
||||
const result2 = await applySeed(db, fixture.seed, seedOpts);
|
||||
|
||||
// Everything should be skipped on second apply
|
||||
expect(result2.collections.created).toBe(0);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- package.json has emdash.seed pointing to seed file ---
|
||||
|
||||
it("has package.json with emdash.seed pointing to the seed file", () => {
|
||||
const pkgPath = join(fixture.dir, "package.json");
|
||||
if (!existsSync(pkgPath)) return; // blank template has no seed, already filtered
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
|
||||
// Either emdash.seed is set, or we rely on the .emdash/seed.json convention
|
||||
const seedRef = pkg.emdash?.seed;
|
||||
if (seedRef) {
|
||||
const resolvedSeedPath = resolve(fixture.dir, seedRef);
|
||||
expect(existsSync(resolvedSeedPath)).toBe(true);
|
||||
} else {
|
||||
// Convention: .emdash/seed.json exists (which it does since we're iterating fixtures)
|
||||
expect(existsSync(fixture.seedPath)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cross-cutting: all templates/demos have required files
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("Required files", () => {
|
||||
const roots = [
|
||||
{ prefix: "templates", dir: resolve(WORKSPACE_ROOT, "templates") },
|
||||
{ prefix: "demos", dir: resolve(WORKSPACE_ROOT, "demos") },
|
||||
].filter((root) => existsSync(root.dir));
|
||||
|
||||
const allDirs = roots
|
||||
.flatMap((root) =>
|
||||
readdirSync(root.dir).map((entry) => ({
|
||||
name: `${root.prefix}/${entry}`,
|
||||
dir: join(root.dir, entry),
|
||||
})),
|
||||
)
|
||||
.filter((d) => existsSync(join(d.dir, "package.json")));
|
||||
|
||||
for (const { name, dir } of allDirs) {
|
||||
describe(name, () => {
|
||||
it("has astro.config.mjs", () => {
|
||||
expect(existsSync(join(dir, "astro.config.mjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has tsconfig.json", () => {
|
||||
expect(existsSync(join(dir, "tsconfig.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has live.config.ts with emdashLoader", () => {
|
||||
const liveConfig = join(dir, "src", "live.config.ts");
|
||||
expect(existsSync(liveConfig)).toBe(true);
|
||||
|
||||
const content = readFileSync(liveConfig, "utf-8");
|
||||
expect(content).toContain("emdashLoader");
|
||||
expect(content).toContain("defineLiveCollection");
|
||||
});
|
||||
|
||||
it("has typecheck script in package.json", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
||||
expect(pkg.scripts?.typecheck || pkg.scripts?.check).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses workspace:* for emdash dependency", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
||||
expect(pkg.dependencies?.emdash).toBe("workspace:*");
|
||||
});
|
||||
|
||||
it("uses catalog: for astro dependency", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
||||
const astroVersion = pkg.dependencies?.astro;
|
||||
expect(astroVersion).toBe("catalog:");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
169
packages/core/tests/integration/snapshot/snapshot-auth.test.ts
Normal file
169
packages/core/tests/integration/snapshot/snapshot-auth.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Integration test for the full preview snapshot auth flow.
|
||||
*
|
||||
* Tests the complete chain that would have caught bug #3:
|
||||
* signPreviewUrl → middleware builds header → snapshot endpoint parses and verifies
|
||||
*
|
||||
* The signing side (signPreviewUrl) lives in @emdashcms/cloudflare, but we
|
||||
* inline the same HMAC logic here to test the format contract without
|
||||
* cross-package imports.
|
||||
*/
|
||||
|
||||
import { sql } from "kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
generateSnapshot,
|
||||
parsePreviewSignatureHeader,
|
||||
verifyPreviewSignature,
|
||||
} from "../../../src/api/handlers/snapshot.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections } from "../../utils/test-db.js";
|
||||
|
||||
const SECRET = "test-preview-secret";
|
||||
|
||||
/**
|
||||
* Sign a preview URL using the same HMAC-SHA256 logic as
|
||||
* @emdashcms/cloudflare signPreviewUrl(). Inlined here so we test
|
||||
* the format contract without cross-package deps.
|
||||
*/
|
||||
async function signPreview(
|
||||
source: string,
|
||||
ttl = 3600,
|
||||
): Promise<{ source: string; exp: number; sig: string }> {
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(SECRET),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(`${source}:${exp}`));
|
||||
const sig = Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return { source, exp, sig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the X-Preview-Signature header value the same way the
|
||||
* preview middleware does: "source:exp:sig"
|
||||
*/
|
||||
function buildSignatureHeader(parts: { source: string; exp: number; sig: string }): string {
|
||||
return `${parts.source}:${parts.exp}:${parts.sig}`;
|
||||
}
|
||||
|
||||
describe("preview snapshot auth flow", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("end-to-end: signed preview URL → header → snapshot access", async () => {
|
||||
// 1. Insert some content so snapshot has data
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('p1', 'test-post', 'published', 'Test', 'Body', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
// 2. Sign a preview URL (same logic as @emdashcms/cloudflare signPreviewUrl)
|
||||
const signed = await signPreview("https://mysite.com");
|
||||
|
||||
// 3. Build the header the way the preview middleware does
|
||||
const headerValue = buildSignatureHeader(signed);
|
||||
|
||||
// 4. Parse the header the way the snapshot endpoint does
|
||||
const parsed = parsePreviewSignatureHeader(headerValue);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.source).toBe("https://mysite.com");
|
||||
expect(parsed!.exp).toBe(signed.exp);
|
||||
expect(parsed!.sig).toBe(signed.sig);
|
||||
|
||||
// 5. Verify the signature the way the snapshot endpoint does
|
||||
const valid = await verifyPreviewSignature(parsed!.source, parsed!.exp, parsed!.sig, SECRET);
|
||||
expect(valid).toBe(true);
|
||||
|
||||
// 6. Actually generate the snapshot (proves auth would grant access)
|
||||
const snapshot = await generateSnapshot(db);
|
||||
expect(snapshot.tables.ec_post).toHaveLength(1);
|
||||
expect(snapshot.tables.ec_post[0]!.slug).toBe("test-post");
|
||||
});
|
||||
|
||||
it("rejects tampered signature", async () => {
|
||||
const signed = await signPreview("https://mysite.com");
|
||||
const headerValue = buildSignatureHeader(signed);
|
||||
|
||||
const parsed = parsePreviewSignatureHeader(headerValue);
|
||||
expect(parsed).not.toBeNull();
|
||||
|
||||
// Tamper with the signature
|
||||
const valid = await verifyPreviewSignature(parsed!.source, parsed!.exp, "a".repeat(64), SECRET);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects wrong secret", async () => {
|
||||
const signed = await signPreview("https://mysite.com");
|
||||
const headerValue = buildSignatureHeader(signed);
|
||||
|
||||
const parsed = parsePreviewSignatureHeader(headerValue);
|
||||
expect(parsed).not.toBeNull();
|
||||
|
||||
const valid = await verifyPreviewSignature(
|
||||
parsed!.source,
|
||||
parsed!.exp,
|
||||
parsed!.sig,
|
||||
"wrong-secret",
|
||||
);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects expired signature", async () => {
|
||||
// Sign with TTL of -1 (already expired)
|
||||
const signed = await signPreview("https://mysite.com", -1);
|
||||
const headerValue = buildSignatureHeader(signed);
|
||||
|
||||
const parsed = parsePreviewSignatureHeader(headerValue);
|
||||
expect(parsed).not.toBeNull();
|
||||
|
||||
const valid = await verifyPreviewSignature(parsed!.source, parsed!.exp, parsed!.sig, SECRET);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePreviewSignatureHeader", () => {
|
||||
it("parses source URLs with colons correctly", async () => {
|
||||
const signed = await signPreview("https://mysite.com:8080");
|
||||
const header = buildSignatureHeader(signed);
|
||||
|
||||
const parsed = parsePreviewSignatureHeader(header);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.source).toBe("https://mysite.com:8080");
|
||||
expect(parsed!.exp).toBe(signed.exp);
|
||||
expect(parsed!.sig).toBe(signed.sig);
|
||||
});
|
||||
|
||||
it("rejects empty string", () => {
|
||||
expect(parsePreviewSignatureHeader("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects header with no colons", () => {
|
||||
expect(parsePreviewSignatureHeader("noseparators")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects header with sig wrong length", () => {
|
||||
expect(parsePreviewSignatureHeader("https://x.com:12345:tooshort")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects header with non-numeric exp", () => {
|
||||
expect(parsePreviewSignatureHeader(`https://x.com:notanumber:${"a".repeat(64)}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
217
packages/core/tests/integration/snapshot/snapshot.test.ts
Normal file
217
packages/core/tests/integration/snapshot/snapshot.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { sql } from "kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Snapshot } from "../../../src/api/handlers/snapshot.js";
|
||||
import { generateSnapshot } from "../../../src/api/handlers/snapshot.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections } from "../../utils/test-db.js";
|
||||
|
||||
describe("generateSnapshot", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("returns empty tables when no content exists", async () => {
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
expect(snapshot.generatedAt).toBeTruthy();
|
||||
expect(typeof snapshot.generatedAt).toBe("string");
|
||||
|
||||
// Schema should include ec_post and ec_page (even with no rows)
|
||||
expect(snapshot.schema).toHaveProperty("ec_post");
|
||||
expect(snapshot.schema).toHaveProperty("ec_page");
|
||||
expect(snapshot.schema.ec_post.columns).toContain("id");
|
||||
expect(snapshot.schema.ec_post.columns).toContain("title");
|
||||
expect(snapshot.schema.ec_post.columns).toContain("slug");
|
||||
expect(snapshot.schema.ec_post.columns).toContain("status");
|
||||
|
||||
// System tables with data should appear
|
||||
expect(snapshot.schema).toHaveProperty("_emdash_collections");
|
||||
expect(snapshot.schema).toHaveProperty("_emdash_fields");
|
||||
|
||||
// _emdash_collections should have 2 rows (post + page)
|
||||
expect(snapshot.tables._emdash_collections).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("includes published content and excludes drafts by default", async () => {
|
||||
// Insert a published post
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('pub1', 'hello-world', 'published', 'Hello World', 'Content here', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
// Insert a draft post
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('draft1', 'draft-post', 'draft', 'Draft Post', 'Draft content', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
// Only published content should appear
|
||||
expect(snapshot.tables.ec_post).toHaveLength(1);
|
||||
expect(snapshot.tables.ec_post[0].slug).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("includes drafts when includeDrafts is true", async () => {
|
||||
// Insert a published post
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('pub1', 'hello-world', 'published', 'Hello World', 'Content', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
// Insert a draft post
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('draft1', 'draft-post', 'draft', 'Draft Post', 'Draft', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
const snapshot = await generateSnapshot(db, { includeDrafts: true });
|
||||
|
||||
// Both should appear
|
||||
expect(snapshot.tables.ec_post).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("excludes soft-deleted content", async () => {
|
||||
// Insert a published post
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('pub1', 'live-post', 'published', 'Live', 'Content', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
// Insert a soft-deleted post
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, deleted_at, version)
|
||||
VALUES ('del1', 'deleted-post', 'published', 'Deleted', 'Gone', datetime('now'), datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
expect(snapshot.tables.ec_post).toHaveLength(1);
|
||||
expect(snapshot.tables.ec_post[0].slug).toBe("live-post");
|
||||
});
|
||||
|
||||
it("excludes auth and security tables", async () => {
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
// These should not appear in schema or tables
|
||||
expect(snapshot.schema).not.toHaveProperty("users");
|
||||
expect(snapshot.schema).not.toHaveProperty("sessions");
|
||||
expect(snapshot.schema).not.toHaveProperty("credentials");
|
||||
expect(snapshot.schema).not.toHaveProperty("challenges");
|
||||
expect(snapshot.schema).not.toHaveProperty("_emdash_api_tokens");
|
||||
expect(snapshot.schema).not.toHaveProperty("_emdash_oauth_tokens");
|
||||
});
|
||||
|
||||
it("includes system tables needed for rendering", async () => {
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
// These system tables should have schema entries
|
||||
expect(snapshot.schema).toHaveProperty("_emdash_collections");
|
||||
expect(snapshot.schema).toHaveProperty("_emdash_fields");
|
||||
expect(snapshot.schema).toHaveProperty("_emdash_migrations");
|
||||
expect(snapshot.schema).toHaveProperty("options");
|
||||
});
|
||||
|
||||
it("includes column type info in schema", async () => {
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
const postSchema = snapshot.schema.ec_post;
|
||||
expect(postSchema).toBeDefined();
|
||||
expect(postSchema.types).toBeDefined();
|
||||
// PRAGMA table_info returns types as declared (case-sensitive)
|
||||
// Kysely creates tables with lowercase types
|
||||
expect(postSchema.types!.id.toLowerCase()).toBe("text");
|
||||
expect(postSchema.types!.version.toLowerCase()).toBe("integer");
|
||||
});
|
||||
|
||||
it("snapshot shape matches DO expectation", async () => {
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, title, content, created_at, updated_at, version)
|
||||
VALUES ('p1', 'test', 'published', 'Test', 'Body', datetime('now'), datetime('now'), 1)
|
||||
`.execute(db);
|
||||
|
||||
const snapshot: Snapshot = await generateSnapshot(db);
|
||||
|
||||
// Verify shape matches what EmDashPreviewDB.applySnapshot expects
|
||||
expect(snapshot).toHaveProperty("tables");
|
||||
expect(snapshot).toHaveProperty("schema");
|
||||
expect(snapshot).toHaveProperty("generatedAt");
|
||||
expect(typeof snapshot.generatedAt).toBe("string");
|
||||
|
||||
// Tables are Record<string, Record<string, unknown>[]>
|
||||
for (const [tableName, rows] of Object.entries(snapshot.tables)) {
|
||||
expect(typeof tableName).toBe("string");
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
for (const row of rows) {
|
||||
expect(typeof row).toBe("object");
|
||||
}
|
||||
}
|
||||
|
||||
// Schema has columns and types
|
||||
for (const [tableName, info] of Object.entries(snapshot.schema)) {
|
||||
expect(typeof tableName).toBe("string");
|
||||
expect(Array.isArray(info.columns)).toBe(true);
|
||||
if (info.types) {
|
||||
expect(typeof info.types).toBe("object");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("filters options table to safe rendering prefixes only", async () => {
|
||||
// Insert site settings (safe — should be included)
|
||||
await sql`INSERT INTO options (name, value) VALUES ('site:title', '"My Site"')`.execute(db);
|
||||
await sql`INSERT INTO options (name, value) VALUES ('site:tagline', '"Welcome"')`.execute(db);
|
||||
|
||||
// Insert plugin secrets (unsafe — should be excluded)
|
||||
await sql`INSERT INTO options (name, value) VALUES ('plugin:smtp:api_key', '"sk-secret-123"')`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`INSERT INTO options (name, value) VALUES ('plugin:seo:license', '"lic-456"')`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
// Insert setup/auth data (unsafe — should be excluded)
|
||||
await sql`INSERT INTO options (name, value) VALUES ('emdash:setup_complete', 'true')`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`INSERT INTO options (name, value) VALUES ('emdash:passkey_pending:user1', '{"challenge":"abc"}')`.execute(
|
||||
db,
|
||||
);
|
||||
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
const optionsRows = snapshot.tables.options;
|
||||
expect(optionsRows).toBeDefined();
|
||||
expect(optionsRows).toHaveLength(2);
|
||||
|
||||
const names = optionsRows.map((r) => r.name);
|
||||
expect(names).toContain("site:title");
|
||||
expect(names).toContain("site:tagline");
|
||||
expect(names).not.toContain("plugin:smtp:api_key");
|
||||
expect(names).not.toContain("plugin:seo:license");
|
||||
expect(names).not.toContain("emdash:setup_complete");
|
||||
expect(names).not.toContain("emdash:passkey_pending:user1");
|
||||
});
|
||||
|
||||
it("discovers content tables dynamically", async () => {
|
||||
// The test setup creates ec_post and ec_page
|
||||
const snapshot = await generateSnapshot(db);
|
||||
|
||||
expect(snapshot.schema).toHaveProperty("ec_post");
|
||||
expect(snapshot.schema).toHaveProperty("ec_page");
|
||||
|
||||
// Verify column discovery matches what we created
|
||||
expect(snapshot.schema.ec_post.columns).toContain("title");
|
||||
expect(snapshot.schema.ec_post.columns).toContain("content");
|
||||
expect(snapshot.schema.ec_page.columns).toContain("title");
|
||||
expect(snapshot.schema.ec_page.columns).toContain("content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!-- WordPress WXR fixture for e2e tests -->
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:wfw="http://wellformedweb.org/CommentAPI/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/"
|
||||
>
|
||||
<channel>
|
||||
<title>Test Blog</title>
|
||||
<link>https://example.com</link>
|
||||
<description>A test WordPress site</description>
|
||||
<pubDate>Sun, 19 Jan 2025 12:00:00 +0000</pubDate>
|
||||
<language>en-US</language>
|
||||
<wp:wxr_version>1.2</wp:wxr_version>
|
||||
<wp:base_site_url>https://example.com</wp:base_site_url>
|
||||
<wp:base_blog_url>https://example.com</wp:base_blog_url>
|
||||
|
||||
<wp:author>
|
||||
<wp:author_id>1</wp:author_id>
|
||||
<wp:author_login><![CDATA[admin]]></wp:author_login>
|
||||
<wp:author_email><![CDATA[admin@example.com]]></wp:author_email>
|
||||
<wp:author_display_name><![CDATA[Site Admin]]></wp:author_display_name>
|
||||
</wp:author>
|
||||
|
||||
<wp:category>
|
||||
<wp:term_id>2</wp:term_id>
|
||||
<wp:category_nicename><![CDATA[tutorials]]></wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
<wp:cat_name><![CDATA[Tutorials]]></wp:cat_name>
|
||||
</wp:category>
|
||||
|
||||
<wp:category>
|
||||
<wp:term_id>3</wp:term_id>
|
||||
<wp:category_nicename><![CDATA[news]]></wp:category_nicename>
|
||||
<wp:category_parent></wp:category_parent>
|
||||
<wp:cat_name><![CDATA[News]]></wp:cat_name>
|
||||
</wp:category>
|
||||
|
||||
<wp:tag>
|
||||
<wp:term_id>4</wp:term_id>
|
||||
<wp:tag_slug><![CDATA[featured]]></wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[Featured]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
|
||||
<!-- Post 1: Simple Gutenberg content -->
|
||||
<item>
|
||||
<title>Hello World</title>
|
||||
<link>https://example.com/2025/01/hello-world/</link>
|
||||
<pubDate>Mon, 15 Jan 2025 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?p=1</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[<!-- wp:paragraph -->
|
||||
<p>Welcome to our new blog! This is a <strong>test post</strong> with some <em>formatting</em>.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading -->
|
||||
<h2>Getting Started</h2>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Here's how to get started with our platform.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:list -->
|
||||
<ul>
|
||||
<li>Step one</li>
|
||||
<li>Step two</li>
|
||||
<li>Step three</li>
|
||||
</ul>
|
||||
<!-- /wp:list -->]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Welcome to our new blog!]]></excerpt:encoded>
|
||||
<wp:post_id>1</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-15 10:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-15 10:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-15 12:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-15 12:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[open]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[open]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[hello-world]]></wp:post_name>
|
||||
<wp:status><![CDATA[publish]]></wp:status>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[post]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
<category domain="category" nicename="tutorials"><![CDATA[Tutorials]]></category>
|
||||
<category domain="post_tag" nicename="featured"><![CDATA[Featured]]></category>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[_edit_last]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[1]]></wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[_yoast_wpseo_title]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[Hello World - Welcome Post]]></wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[_yoast_wpseo_metadesc]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[Our first blog post welcoming visitors.]]></wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[custom_field]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[custom value]]></wp:meta_value>
|
||||
</wp:postmeta>
|
||||
</item>
|
||||
|
||||
<!-- Post 2: With image and quote -->
|
||||
<item>
|
||||
<title>Advanced Features</title>
|
||||
<link>https://example.com/2025/01/advanced-features/</link>
|
||||
<pubDate>Wed, 17 Jan 2025 14:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?p=2</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[<!-- wp:paragraph -->
|
||||
<p>Let's explore some advanced features.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:image {"id":100,"sizeSlug":"large"} -->
|
||||
<figure class="wp-block-image size-large"><img src="https://example.com/wp-content/uploads/2025/01/hero.jpg" alt="Hero image" class="wp-image-100"/><figcaption>Our hero image</figcaption></figure>
|
||||
<!-- /wp:image -->
|
||||
|
||||
<!-- wp:quote -->
|
||||
<blockquote class="wp-block-quote"><p>This is an inspiring quote about technology.</p><cite>Famous Person</cite></blockquote>
|
||||
<!-- /wp:quote -->
|
||||
|
||||
<!-- wp:code -->
|
||||
<pre class="wp-block-code"><code>const hello = "world";
|
||||
console.log(hello);</code></pre>
|
||||
<!-- /wp:code -->]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>2</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-17 14:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-17 14:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-17 14:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-17 14:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[open]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[open]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[advanced-features]]></wp:post_name>
|
||||
<wp:status><![CDATA[publish]]></wp:status>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[post]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
<category domain="category" nicename="tutorials"><![CDATA[Tutorials]]></category>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[_thumbnail_id]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[100]]></wp:meta_value>
|
||||
</wp:postmeta>
|
||||
</item>
|
||||
|
||||
<!-- Post 3: Draft post -->
|
||||
<item>
|
||||
<title>Work in Progress</title>
|
||||
<link>https://example.com/?p=3</link>
|
||||
<pubDate>Thu, 18 Jan 2025 09:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?p=3</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[<!-- wp:paragraph -->
|
||||
<p>This post is still being written.</p>
|
||||
<!-- /wp:paragraph -->]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>3</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-18 09:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-18 09:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-18 10:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-18 10:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[open]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[open]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[work-in-progress]]></wp:post_name>
|
||||
<wp:status><![CDATA[draft]]></wp:status>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[post]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
</item>
|
||||
|
||||
<!-- Page 1: About page -->
|
||||
<item>
|
||||
<title>About Us</title>
|
||||
<link>https://example.com/about/</link>
|
||||
<pubDate>Sat, 01 Jan 2025 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?page_id=10</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[<!-- wp:paragraph -->
|
||||
<p>Welcome to our About page. We are a team of passionate developers.</p>
|
||||
<!-- /wp:paragraph -->
|
||||
|
||||
<!-- wp:heading {"level":3} -->
|
||||
<h3>Our Mission</h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>To build great software that helps people.</p>
|
||||
<!-- /wp:paragraph -->]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>10</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-01 12:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-01 12:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-10 12:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-10 12:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[closed]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[closed]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[about]]></wp:post_name>
|
||||
<wp:status><![CDATA[publish]]></wp:status>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[page]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
</item>
|
||||
|
||||
<!-- Page 2: Contact page (child of About) -->
|
||||
<item>
|
||||
<title>Contact</title>
|
||||
<link>https://example.com/about/contact/</link>
|
||||
<pubDate>Sat, 01 Jan 2025 12:30:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?page_id=11</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[<!-- wp:paragraph -->
|
||||
<p>Get in touch with us at <a href="mailto:hello@example.com">hello@example.com</a>.</p>
|
||||
<!-- /wp:paragraph -->]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>11</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-01 12:30:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-01 12:30:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-01 12:30:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-01 12:30:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[closed]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[closed]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[contact]]></wp:post_name>
|
||||
<wp:status><![CDATA[publish]]></wp:status>
|
||||
<wp:post_parent>10</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[page]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
</item>
|
||||
|
||||
<!-- Attachment -->
|
||||
<item>
|
||||
<title>hero</title>
|
||||
<link>https://example.com/hero/</link>
|
||||
<pubDate>Wed, 17 Jan 2025 13:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/wp-content/uploads/2025/01/hero.jpg</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[Hero image for the site]]></excerpt:encoded>
|
||||
<wp:post_id>100</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-17 13:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-17 13:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-17 13:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-17 13:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[open]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[closed]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[hero]]></wp:post_name>
|
||||
<wp:status><![CDATA[inherit]]></wp:status>
|
||||
<wp:post_parent>2</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[attachment]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
<wp:attachment_url><![CDATA[https://example.com/wp-content/uploads/2025/01/hero.jpg]]></wp:attachment_url>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[_wp_attached_file]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[2025/01/hero.jpg]]></wp:meta_value>
|
||||
</wp:postmeta>
|
||||
</item>
|
||||
|
||||
<!-- Nav menu item (should be skipped) -->
|
||||
<item>
|
||||
<title>Home</title>
|
||||
<link>https://example.com/?p=50</link>
|
||||
<pubDate>Sat, 01 Jan 2025 12:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?p=50</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>50</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-01 12:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-01 12:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-01 12:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-01 12:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[closed]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[closed]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[home]]></wp:post_name>
|
||||
<wp:status><![CDATA[publish]]></wp:status>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>1</wp:menu_order>
|
||||
<wp:post_type><![CDATA[nav_menu_item]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
</item>
|
||||
|
||||
<!-- Reusable Block (wp_block) - should be imported as section -->
|
||||
<item>
|
||||
<title>Newsletter CTA</title>
|
||||
<link>https://example.com/?p=100</link>
|
||||
<pubDate>Mon, 20 Jan 2025 10:00:00 +0000</pubDate>
|
||||
<dc:creator><![CDATA[admin]]></dc:creator>
|
||||
<guid isPermaLink="false">https://example.com/?p=100</guid>
|
||||
<description></description>
|
||||
<content:encoded><![CDATA[<!-- wp:heading {"level":3} -->
|
||||
<h3>Subscribe to Our Newsletter</h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Get the latest updates delivered to your inbox.</p>
|
||||
<!-- /wp:paragraph -->]]></content:encoded>
|
||||
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
|
||||
<wp:post_id>100</wp:post_id>
|
||||
<wp:post_date><![CDATA[2025-01-20 10:00:00]]></wp:post_date>
|
||||
<wp:post_date_gmt><![CDATA[2025-01-20 10:00:00]]></wp:post_date_gmt>
|
||||
<wp:post_modified><![CDATA[2025-01-20 10:00:00]]></wp:post_modified>
|
||||
<wp:post_modified_gmt><![CDATA[2025-01-20 10:00:00]]></wp:post_modified_gmt>
|
||||
<wp:comment_status><![CDATA[closed]]></wp:comment_status>
|
||||
<wp:ping_status><![CDATA[closed]]></wp:ping_status>
|
||||
<wp:post_name><![CDATA[newsletter-cta]]></wp:post_name>
|
||||
<wp:status><![CDATA[publish]]></wp:status>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>0</wp:menu_order>
|
||||
<wp:post_type><![CDATA[wp_block]]></wp:post_type>
|
||||
<wp:post_password><![CDATA[]]></wp:post_password>
|
||||
<wp:is_sticky>0</wp:is_sticky>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* E2E tests for WordPress import CLI
|
||||
*
|
||||
* Tests the full two-phase import flow:
|
||||
* - Phase 1: Prepare (analyze WXR, generate config)
|
||||
* - Phase 2: Execute (import content using config)
|
||||
*
|
||||
* Also tests: --dry-run, --resume, --json flags
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
prepareWordPressImport,
|
||||
executeWordPressImport,
|
||||
type MigrationConfig,
|
||||
type ImportProgress,
|
||||
} from "../../../src/cli/commands/import/wordpress.js";
|
||||
|
||||
const FIXTURE_PATH = join(import.meta.dirname, "fixtures", "sample-export.xml");
|
||||
|
||||
describe("WordPress Import Integration", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), "emdash-wp-import-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Phase 1: Prepare", () => {
|
||||
it("analyzes WXR and generates migration config", async () => {
|
||||
const configPath = join(testDir, ".wp-migration.json");
|
||||
|
||||
await prepareWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
});
|
||||
|
||||
// Check config was created
|
||||
const configContent = await readFile(configPath, "utf-8");
|
||||
const config: MigrationConfig = JSON.parse(configContent);
|
||||
|
||||
// Verify site info
|
||||
expect(config.site.title).toBe("Test Blog");
|
||||
expect(config.site.url).toBe("https://example.com");
|
||||
|
||||
// Verify collections discovered
|
||||
expect(config.collections.post).toEqual({
|
||||
collection: "posts",
|
||||
enabled: true,
|
||||
count: 3,
|
||||
});
|
||||
expect(config.collections.page).toEqual({
|
||||
collection: "pages",
|
||||
enabled: true,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
// nav_menu_item should be disabled (if it exists in the export)
|
||||
if (config.collections.nav_menu_item) {
|
||||
expect(config.collections.nav_menu_item.enabled).toBe(false);
|
||||
}
|
||||
|
||||
// Verify custom fields discovered
|
||||
expect(config.fields._yoast_wpseo_title).toEqual({
|
||||
field: "seo.title",
|
||||
type: "string",
|
||||
enabled: true,
|
||||
count: 1,
|
||||
samples: expect.any(Array),
|
||||
});
|
||||
expect(config.fields._yoast_wpseo_metadesc?.field).toBe("seo.description");
|
||||
expect(config.fields._thumbnail_id?.field).toBe("featuredImage");
|
||||
expect(config.fields.custom_field?.enabled).toBe(true);
|
||||
|
||||
// Internal fields should be disabled
|
||||
expect(config.fields._edit_last?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("generates suggested live.config.ts", async () => {
|
||||
const configPath = join(testDir, ".wp-migration.json");
|
||||
|
||||
await prepareWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
});
|
||||
|
||||
const liveConfigPath = join(testDir, "suggested-live.config.ts");
|
||||
const liveConfig = await readFile(liveConfigPath, "utf-8");
|
||||
|
||||
// Collections are now created via Admin UI, so this generates helpful comments
|
||||
expect(liveConfig).toContain("Suggested EmDash collections");
|
||||
expect(liveConfig).toContain("/_emdash/admin/content-types");
|
||||
expect(liveConfig).toContain('post → "posts"');
|
||||
expect(liveConfig).toContain('page → "pages"');
|
||||
expect(liveConfig).toContain("portableText");
|
||||
});
|
||||
|
||||
it("dry-run does not create files", async () => {
|
||||
const configPath = join(testDir, ".wp-migration.json");
|
||||
|
||||
const result = await prepareWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
verbose: false,
|
||||
dryRun: true,
|
||||
json: false,
|
||||
});
|
||||
|
||||
// Result should indicate dry run
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.files).toContainEqual({
|
||||
path: configPath,
|
||||
action: "would_create",
|
||||
});
|
||||
|
||||
// Files should NOT exist
|
||||
await expect(readFile(configPath)).rejects.toThrow();
|
||||
await expect(readFile(join(testDir, "suggested-live.config.ts"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns structured JSON result", async () => {
|
||||
const configPath = join(testDir, ".wp-migration.json");
|
||||
|
||||
const result = await prepareWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.phase).toBe("prepare");
|
||||
expect(result.summary.postsAnalyzed).toBe(7); // 3 posts + 2 pages + 1 attachment + 1 wp_block (excludes nav_menu_item)
|
||||
expect(result.files.length).toBe(2);
|
||||
expect(result.nextSteps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Phase 2: Execute", () => {
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Run prepare first to create config
|
||||
configPath = join(testDir, ".wp-migration.json");
|
||||
await prepareWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("imports posts and pages to correct directories", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
// Check posts directory
|
||||
const posts = await readdir(join(testDir, "posts"));
|
||||
expect(posts).toContain("hello-world.json");
|
||||
expect(posts).toContain("advanced-features.json");
|
||||
expect(posts).toContain("work-in-progress.json");
|
||||
expect(posts.length).toBe(3);
|
||||
|
||||
// Check pages directory
|
||||
const pages = await readdir(join(testDir, "pages"));
|
||||
expect(pages).toContain("about.json");
|
||||
expect(pages).toContain("contact.json");
|
||||
expect(pages.length).toBe(2);
|
||||
});
|
||||
|
||||
it("converts Gutenberg blocks to Portable Text", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
const postContent = await readFile(join(testDir, "posts", "hello-world.json"), "utf-8");
|
||||
const post = JSON.parse(postContent);
|
||||
|
||||
// Check content is Portable Text array
|
||||
expect(Array.isArray(post.content)).toBe(true);
|
||||
expect(post.content.length).toBeGreaterThan(0);
|
||||
|
||||
// Check for expected block types
|
||||
const blockTypes = post.content.map((b: { _type: string }) => b._type);
|
||||
expect(blockTypes).toContain("block"); // paragraphs and headings
|
||||
|
||||
// Check paragraph content
|
||||
const firstBlock = post.content[0];
|
||||
expect(firstBlock._type).toBe("block");
|
||||
expect(firstBlock.children[0].text).toContain("Welcome to our new blog");
|
||||
});
|
||||
|
||||
it("maps custom fields correctly", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
const postContent = await readFile(join(testDir, "posts", "hello-world.json"), "utf-8");
|
||||
const post = JSON.parse(postContent);
|
||||
|
||||
// Check SEO fields (nested)
|
||||
expect(post.seo?.title).toBe("Hello World - Welcome Post");
|
||||
expect(post.seo?.description).toBe("Our first blog post welcoming visitors.");
|
||||
|
||||
// Check custom field
|
||||
expect(post.custom_field).toBe("custom value");
|
||||
});
|
||||
|
||||
it("preserves post metadata", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
const postContent = await readFile(join(testDir, "posts", "hello-world.json"), "utf-8");
|
||||
const post = JSON.parse(postContent);
|
||||
|
||||
expect(post.title).toBe("Hello World");
|
||||
expect(post.status).toBe("published");
|
||||
expect(post.author).toBe("admin");
|
||||
expect(post.excerpt).toBe("Welcome to our new blog!");
|
||||
expect(post.categories).toContain("tutorials");
|
||||
expect(post.tags).toContain("featured");
|
||||
|
||||
// Check WordPress metadata preserved
|
||||
expect(post._wp.id).toBe(1);
|
||||
expect(post._wp.link).toBe("https://example.com/2025/01/hello-world/");
|
||||
});
|
||||
|
||||
it("handles draft posts correctly", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
const postContent = await readFile(join(testDir, "posts", "work-in-progress.json"), "utf-8");
|
||||
const post = JSON.parse(postContent);
|
||||
|
||||
expect(post.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("creates redirects map", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
const redirectsContent = await readFile(join(testDir, "_redirects.json"), "utf-8");
|
||||
const redirects = JSON.parse(redirectsContent);
|
||||
|
||||
expect(redirects["https://example.com/2025/01/hello-world/"]).toBe("/posts/hello-world");
|
||||
expect(redirects["https://example.com/about/"]).toBe("/pages/about");
|
||||
});
|
||||
|
||||
it("dry-run shows what would be created", async () => {
|
||||
const result = await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: true,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.summary.postsImported).toBe(5);
|
||||
|
||||
// Check files would be created
|
||||
const wouldCreate = result.files.filter((f) => f.action === "would_create");
|
||||
expect(wouldCreate.length).toBeGreaterThan(0);
|
||||
|
||||
// Actual files should NOT exist
|
||||
await expect(readdir(join(testDir, "posts"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("creates progress file for resumability", async () => {
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
const progressContent = await readFile(join(testDir, ".wp-migration-progress.json"), "utf-8");
|
||||
const progress: ImportProgress = JSON.parse(progressContent);
|
||||
|
||||
expect(progress.importedPosts.length).toBe(5);
|
||||
expect(progress.stats.importedPosts).toBe(5);
|
||||
expect(progress.stats.totalPosts).toBe(7); // 3 posts + 2 pages + 1 attachment + 1 wp_block (nav_menu_item excluded)
|
||||
expect(progress.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it("resume skips already-imported posts", async () => {
|
||||
// First import
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
// Second import with resume
|
||||
const result = await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
resume: true,
|
||||
});
|
||||
|
||||
// All should be skipped (resumed)
|
||||
expect(result.summary.postsImported).toBe(0);
|
||||
expect(result.summary.postsSkipped).toBe(7); // 5 content items + 1 attachment + 1 wp_block
|
||||
});
|
||||
|
||||
it("resume imports only new posts", async () => {
|
||||
// First import
|
||||
await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
// Modify progress to simulate partial import
|
||||
const progressPath = join(testDir, ".wp-migration-progress.json");
|
||||
const progressContent = await readFile(progressPath, "utf-8");
|
||||
const progress: ImportProgress = JSON.parse(progressContent);
|
||||
|
||||
// Remove last 2 posts from imported list
|
||||
progress.importedPosts = progress.importedPosts.slice(0, 3);
|
||||
progress.stats.importedPosts = 3;
|
||||
await writeFile(progressPath, JSON.stringify(progress, null, 2));
|
||||
|
||||
// Delete those files too
|
||||
await rm(join(testDir, "pages", "about.json"));
|
||||
await rm(join(testDir, "pages", "contact.json"));
|
||||
|
||||
// Resume import
|
||||
const result = await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
resume: true,
|
||||
});
|
||||
|
||||
// Should import only the 2 missing pages
|
||||
expect(result.summary.postsImported).toBe(2);
|
||||
expect(result.summary.postsSkipped).toBe(5); // 3 + 1 attachment + 1 wp_block
|
||||
|
||||
// Files should exist again
|
||||
const pages = await readdir(join(testDir, "pages"));
|
||||
expect(pages).toContain("about.json");
|
||||
expect(pages).toContain("contact.json");
|
||||
});
|
||||
|
||||
it("returns structured JSON result", async () => {
|
||||
const result = await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.phase).toBe("execute");
|
||||
expect(result.summary.postsImported).toBe(5);
|
||||
expect(result.summary.errors).toBe(0);
|
||||
expect(result.files.length).toBeGreaterThan(0);
|
||||
expect(result.files.every((f) => f.action === "created")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips disabled post types", async () => {
|
||||
// Modify config to disable pages
|
||||
const config: MigrationConfig = JSON.parse(await readFile(configPath, "utf-8"));
|
||||
config.collections.page.enabled = false;
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const result = await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
resume: false,
|
||||
});
|
||||
|
||||
// Only posts should be imported
|
||||
expect(result.summary.postsImported).toBe(3);
|
||||
expect(result.summary.postsSkipped).toBe(4); // 2 pages + 1 attachment + 1 wp_block
|
||||
|
||||
// Pages directory should not exist
|
||||
await expect(readdir(join(testDir, "pages"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles missing config file gracefully", async () => {
|
||||
const badConfigPath = join(testDir, "nonexistent.json");
|
||||
|
||||
await expect(
|
||||
executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath: badConfigPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
resume: false,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("handles empty progress file on resume", async () => {
|
||||
// Create config first
|
||||
const configPath = join(testDir, ".wp-migration.json");
|
||||
await prepareWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
});
|
||||
|
||||
// Resume without prior import should work (fresh start)
|
||||
const result = await executeWordPressImport(FIXTURE_PATH, {
|
||||
outputDir: testDir,
|
||||
configPath,
|
||||
skipMedia: true,
|
||||
verbose: false,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
resume: true,
|
||||
});
|
||||
|
||||
expect(result.summary.postsImported).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Integration tests using WordPress Theme Unit Test data
|
||||
*
|
||||
* Tests the full WordPress migration pipeline against the official
|
||||
* WordPress Theme Unit Test dataset. The test data is downloaded from
|
||||
* GitHub on first run and cached locally.
|
||||
*
|
||||
* @see https://github.com/WordPress/theme-test-data
|
||||
*/
|
||||
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { gutenbergToPortableText } from "@emdashcms/gutenberg-to-portable-text";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import { parseWxr } from "../../../src/cli/wxr/parser.js";
|
||||
|
||||
// Test regex patterns
|
||||
const PARAGRAPH_WITH_TEXT_REGEX = /<p[^>]*>[^<]+<\/p>/;
|
||||
|
||||
const TEST_DATA_PATH = join(
|
||||
process.cwd(),
|
||||
"../../examples/wp-theme-unit-test/themeunittestdata.wordpress.xml",
|
||||
);
|
||||
|
||||
const TEST_DATA_URL =
|
||||
"https://raw.githubusercontent.com/WordPress/theme-test-data/master/themeunittestdata.wordpress.xml";
|
||||
|
||||
/**
|
||||
* Download the WordPress theme unit test data if it doesn't exist locally.
|
||||
*/
|
||||
async function ensureTestData(): Promise<void> {
|
||||
if (existsSync(TEST_DATA_PATH)) return;
|
||||
|
||||
console.log(`Downloading WordPress theme unit test data from ${TEST_DATA_URL}...`);
|
||||
const response = await fetch(TEST_DATA_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download test data: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const data = await response.text();
|
||||
await mkdir(dirname(TEST_DATA_PATH), { recursive: true });
|
||||
await writeFile(TEST_DATA_PATH, data, "utf-8");
|
||||
console.log(`Downloaded to ${TEST_DATA_PATH}`);
|
||||
}
|
||||
|
||||
describe("WordPress Theme Unit Test Migration", () => {
|
||||
let wxrData: Awaited<ReturnType<typeof parseWxr>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureTestData();
|
||||
const stream = createReadStream(TEST_DATA_PATH, { encoding: "utf-8" });
|
||||
wxrData = await parseWxr(stream);
|
||||
});
|
||||
|
||||
describe("WXR Parsing", () => {
|
||||
it("parses site metadata", () => {
|
||||
expect(wxrData.site.title).toBe("Theme Unit Test Data");
|
||||
expect(wxrData.site.link).toBe("https://wpthemetestdata.wordpress.com");
|
||||
expect(wxrData.site.language).toBe("en");
|
||||
});
|
||||
|
||||
it("parses all posts", () => {
|
||||
// Theme Unit Test has many posts covering different scenarios
|
||||
expect(wxrData.posts.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("parses all pages", () => {
|
||||
const pages = wxrData.posts.filter((p) => p.postType === "page");
|
||||
expect(pages.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("parses categories with hierarchy", () => {
|
||||
expect(wxrData.categories.length).toBeGreaterThan(20);
|
||||
|
||||
// Check for parent-child relationships
|
||||
const parentCategory = wxrData.categories.find((c) => c.nicename === "parent-category");
|
||||
expect(parentCategory).toBeDefined();
|
||||
|
||||
const childCategory = wxrData.categories.find((c) => c.nicename === "child-category-01");
|
||||
expect(childCategory).toBeDefined();
|
||||
expect(childCategory?.parent).toBe("parent-category");
|
||||
});
|
||||
|
||||
it("parses tags", () => {
|
||||
expect(wxrData.tags.length).toBeGreaterThan(50);
|
||||
|
||||
// Check for specific tags
|
||||
const wpTag = wxrData.tags.find((t) => t.slug === "wordpress");
|
||||
expect(wpTag).toBeDefined();
|
||||
expect(wpTag?.name).toBe("WordPress");
|
||||
});
|
||||
|
||||
it("parses authors", () => {
|
||||
expect(wxrData.authors.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const author = wxrData.authors.find((a) => a.login === "themereviewteam");
|
||||
expect(author).toBeDefined();
|
||||
expect(author?.displayName).toBe("Theme Reviewer");
|
||||
});
|
||||
|
||||
it("parses attachments", () => {
|
||||
expect(wxrData.attachments.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("parses post categories and tags", () => {
|
||||
// Find a post with both categories and tags
|
||||
const postsWithTaxonomies = wxrData.posts.filter(
|
||||
(p) => p.categories.length > 0 || p.tags.length > 0,
|
||||
);
|
||||
expect(postsWithTaxonomies.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gutenberg Block Conversion", () => {
|
||||
it("converts paragraph blocks", () => {
|
||||
const post = wxrData.posts.find((p) => p.content?.includes("wp:paragraph"));
|
||||
expect(post).toBeDefined();
|
||||
|
||||
const result = gutenbergToPortableText(post!.content || "");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const block = result.find((b) => b._type === "block");
|
||||
expect(block).toBeDefined();
|
||||
});
|
||||
|
||||
it("converts heading blocks with different levels", () => {
|
||||
const post = wxrData.posts.find((p) => p.title === "WP 6.1 Font size scale");
|
||||
expect(post).toBeDefined();
|
||||
|
||||
const result = gutenbergToPortableText(post!.content || "");
|
||||
|
||||
// Should have h2 headings
|
||||
const headings = result.filter(
|
||||
(b) => b._type === "block" && (b as any).style?.startsWith("h"),
|
||||
);
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("converts list blocks", () => {
|
||||
// Find a post with list content
|
||||
const post = wxrData.posts.find((p) => p.content?.includes("wp:list"));
|
||||
|
||||
if (post) {
|
||||
const result = gutenbergToPortableText(post.content || "");
|
||||
const listItems = result.filter((b) => b._type === "block" && (b as any).listItem);
|
||||
expect(listItems.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts image blocks", () => {
|
||||
const post = wxrData.posts.find((p) => p.content?.includes("wp:image"));
|
||||
|
||||
if (post) {
|
||||
const result = gutenbergToPortableText(post.content || "");
|
||||
const images = result.filter((b) => b._type === "image");
|
||||
expect(images.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts quote blocks", () => {
|
||||
const post = wxrData.posts.find((p) => p.content?.includes("wp:quote"));
|
||||
|
||||
if (post) {
|
||||
const result = gutenbergToPortableText(post.content || "");
|
||||
const quotes = result.filter(
|
||||
(b) => b._type === "block" && (b as any).style === "blockquote",
|
||||
);
|
||||
expect(quotes.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts code blocks", () => {
|
||||
const post = wxrData.posts.find((p) => p.content?.includes("wp:code"));
|
||||
|
||||
if (post) {
|
||||
const result = gutenbergToPortableText(post.content || "");
|
||||
const codeBlocks = result.filter((b) => b._type === "code");
|
||||
expect(codeBlocks.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts group blocks by flattening", () => {
|
||||
const post = wxrData.posts.find((p) => p.content?.includes("wp:group"));
|
||||
expect(post).toBeDefined();
|
||||
|
||||
const result = gutenbergToPortableText(post!.content || "");
|
||||
// Groups should be flattened - no group type in output
|
||||
const groups = result.filter((b) => b._type === "group");
|
||||
expect(groups.length).toBe(0);
|
||||
|
||||
// But their content should still be present
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles classic editor content", () => {
|
||||
// Find a post in the "Classic" category
|
||||
const classicPost = wxrData.posts.find((p) => p.categories.includes("classic"));
|
||||
|
||||
if (classicPost && classicPost.content) {
|
||||
// Classic content doesn't have wp: comments
|
||||
const hasGutenbergBlocks = classicPost.content.includes("<!-- wp:");
|
||||
|
||||
if (!hasGutenbergBlocks && classicPost.content.trim()) {
|
||||
const result = gutenbergToPortableText(classicPost.content);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves inline formatting", () => {
|
||||
const post = wxrData.posts.find(
|
||||
(p) => p.content?.includes("<strong>") || p.content?.includes("<em>"),
|
||||
);
|
||||
|
||||
if (post) {
|
||||
const result = gutenbergToPortableText(post.content || "");
|
||||
const blocksWithMarks = result.filter(
|
||||
(b) => b._type === "block" && (b as any).children?.some((c: any) => c.marks?.length > 0),
|
||||
);
|
||||
// Should have some formatted text
|
||||
expect(blocksWithMarks.length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles empty content gracefully", () => {
|
||||
const result = gutenbergToPortableText("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles malformed blocks gracefully", () => {
|
||||
// Test with incomplete block markers
|
||||
const malformed = "<!-- wp:paragraph --><p>Test<!-- /wp:paragraph";
|
||||
const result = gutenbergToPortableText(malformed);
|
||||
// Should not throw, may produce partial output or fallback
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles posts with special characters in title", () => {
|
||||
// Find posts with special characters
|
||||
const specialPosts = wxrData.posts.filter(
|
||||
(p) => p.title?.includes("&") || p.title?.includes("<") || p.title?.includes('"'),
|
||||
);
|
||||
// Should parse without errors
|
||||
expect(specialPosts).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles posts with very long content", () => {
|
||||
// Find the longest post
|
||||
const longestPost = wxrData.posts.reduce((longest, current) => {
|
||||
const currentLength = current.content?.length || 0;
|
||||
const longestLength = longest?.content?.length || 0;
|
||||
return currentLength > longestLength ? current : longest;
|
||||
}, wxrData.posts[0]);
|
||||
|
||||
if (longestPost?.content) {
|
||||
const result = gutenbergToPortableText(longestPost.content);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles deeply nested blocks", () => {
|
||||
// Find posts with nested structures (columns, groups)
|
||||
const nestedPost = wxrData.posts.find(
|
||||
(p) => p.content?.includes("wp:columns") || p.content?.includes("wp:group"),
|
||||
);
|
||||
|
||||
if (nestedPost) {
|
||||
const result = gutenbergToPortableText(nestedPost.content || "");
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles posts with embeds", () => {
|
||||
const embedPost = wxrData.posts.find((p) => p.content?.includes("wp:embed"));
|
||||
|
||||
if (embedPost) {
|
||||
const result = gutenbergToPortableText(embedPost.content || "");
|
||||
const embeds = result.filter((b) => b._type === "embed");
|
||||
expect(embeds.length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content Integrity", () => {
|
||||
it("preserves all text content through conversion", () => {
|
||||
// Take a sample of posts and verify text isn't lost
|
||||
const samplePosts = wxrData.posts.slice(0, 10);
|
||||
|
||||
for (const post of samplePosts) {
|
||||
if (!post.content) continue;
|
||||
|
||||
const result = gutenbergToPortableText(post.content);
|
||||
|
||||
// Extract all text from result
|
||||
const extractedText = result
|
||||
.map((block) => {
|
||||
if (block._type === "block" && (block as any).children) {
|
||||
return (block as any).children.map((c: any) => c.text || "").join("");
|
||||
}
|
||||
if (block._type === "code") {
|
||||
return (block as any).code || "";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
// If there was content, we should have extracted some text
|
||||
// (unless it was all images/embeds)
|
||||
if (post.content.includes("<p>") || post.content.includes("wp:paragraph")) {
|
||||
// Only check if there was actual text content
|
||||
const hasTextContent = PARAGRAPH_WITH_TEXT_REGEX.test(post.content);
|
||||
if (hasTextContent) {
|
||||
expect(extractedText.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Statistics", () => {
|
||||
it("reports conversion statistics", () => {
|
||||
let totalPosts = 0;
|
||||
let successfulConversions = 0;
|
||||
let failedConversions = 0;
|
||||
let totalBlocks = 0;
|
||||
const blockTypes = new Map<string, number>();
|
||||
|
||||
for (const post of wxrData.posts) {
|
||||
totalPosts++;
|
||||
try {
|
||||
const result = gutenbergToPortableText(post.content || "");
|
||||
successfulConversions++;
|
||||
totalBlocks += result.length;
|
||||
|
||||
for (const block of result) {
|
||||
const type = block._type;
|
||||
blockTypes.set(type, (blockTypes.get(type) || 0) + 1);
|
||||
}
|
||||
} catch {
|
||||
failedConversions++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log statistics (visible in test output with --reporter=verbose)
|
||||
console.log("\n=== WordPress Migration Statistics ===");
|
||||
console.log(`Total posts: ${totalPosts}`);
|
||||
console.log(`Successful: ${successfulConversions}`);
|
||||
console.log(`Failed: ${failedConversions}`);
|
||||
console.log(`Total blocks generated: ${totalBlocks}`);
|
||||
console.log("\nBlock types:");
|
||||
for (const [type, count] of blockTypes.entries()) {
|
||||
console.log(` ${type}: ${count}`);
|
||||
}
|
||||
console.log("=====================================\n");
|
||||
|
||||
// All conversions should succeed
|
||||
expect(failedConversions).toBe(0);
|
||||
expect(successfulConversions).toBe(totalPosts);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
packages/core/tests/unit/api/cache-headers.test.ts
Normal file
70
packages/core/tests/unit/api/cache-headers.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { apiError, apiSuccess, handleError, unwrapResult } from "../../../src/api/error.js";
|
||||
|
||||
describe("API cache headers", () => {
|
||||
const EXPECTED_CACHE_CONTROL = "private, no-store";
|
||||
|
||||
describe("apiSuccess", () => {
|
||||
it("should include Cache-Control: private, no-store", () => {
|
||||
const response = apiSuccess({ ok: true });
|
||||
expect(response.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
|
||||
});
|
||||
|
||||
it("should not include Vary header", () => {
|
||||
const response = apiSuccess({ ok: true });
|
||||
expect(response.headers.has("Vary")).toBe(false);
|
||||
});
|
||||
|
||||
it("should still include correct status and body", async () => {
|
||||
const response = apiSuccess({ id: "123" }, 201);
|
||||
expect(response.status).toBe(201);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ data: { id: "123" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiError", () => {
|
||||
it("should include Cache-Control: private, no-store", () => {
|
||||
const response = apiError("NOT_FOUND", "Not found", 404);
|
||||
expect(response.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
|
||||
});
|
||||
|
||||
it("should not include Vary header", () => {
|
||||
const response = apiError("NOT_FOUND", "Not found", 404);
|
||||
expect(response.headers.has("Vary")).toBe(false);
|
||||
});
|
||||
|
||||
it("should still include correct status and body", async () => {
|
||||
const response = apiError("FORBIDDEN", "Access denied", 403);
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({ error: { code: "FORBIDDEN", message: "Access denied" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleError", () => {
|
||||
it("should include cache headers on 500 responses", () => {
|
||||
const response = handleError(new Error("db crash"), "Something went wrong", "INTERNAL");
|
||||
expect(response.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
|
||||
expect(response.headers.has("Vary")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwrapResult", () => {
|
||||
it("should include cache headers on success", () => {
|
||||
const response = unwrapResult({ success: true, data: { id: "1" } });
|
||||
expect(response.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
|
||||
expect(response.headers.has("Vary")).toBe(false);
|
||||
});
|
||||
|
||||
it("should include cache headers on error", () => {
|
||||
const response = unwrapResult({
|
||||
success: false,
|
||||
error: { code: "NOT_FOUND", message: "Not found" },
|
||||
});
|
||||
expect(response.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL);
|
||||
expect(response.headers.has("Vary")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/core/tests/unit/api/content-handlers.test.ts
Normal file
267
packages/core/tests/unit/api/content-handlers.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
handleContentCreate,
|
||||
handleContentDuplicate,
|
||||
handleContentGet,
|
||||
handleContentList,
|
||||
handleContentUpdate,
|
||||
} from "../../../src/api/index.js";
|
||||
import { BylineRepository } from "../../../src/database/repositories/byline.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("Content Handlers — auto-slug generation", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
// Add a "name" field to the page collection so we can test name-based slug generation
|
||||
const registry = new SchemaRegistry(db);
|
||||
await registry.createField("page", { slug: "name", label: "Name", type: "string" });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("handleContentCreate", () => {
|
||||
it("should auto-generate slug from title when slug is omitted", async () => {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("should auto-generate slug from name when title is absent", async () => {
|
||||
const result = await handleContentCreate(db, "page", {
|
||||
data: { name: "My Widget" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("my-widget");
|
||||
});
|
||||
|
||||
it("should prefer title over name for slug generation", async () => {
|
||||
const result = await handleContentCreate(db, "page", {
|
||||
data: { title: "From Title", name: "From Name" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("from-title");
|
||||
});
|
||||
|
||||
it("should respect explicit slug and not auto-generate", async () => {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
slug: "custom-slug",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("custom-slug");
|
||||
});
|
||||
|
||||
it("should handle slug collisions by appending numeric suffix", async () => {
|
||||
// Create first item with the slug
|
||||
await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
});
|
||||
|
||||
// Create second item with same title — should get unique slug
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("hello-world-1");
|
||||
});
|
||||
|
||||
it("should increment suffix on repeated collisions", async () => {
|
||||
await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
});
|
||||
await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
});
|
||||
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: "Hello World" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("hello-world-2");
|
||||
});
|
||||
|
||||
it("should leave slug null when no title or name is present", async () => {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { content: [{ _type: "block", children: [{ _type: "span", text: "hi" }] }] },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBeNull();
|
||||
});
|
||||
|
||||
it("should leave slug null when title is not a string", async () => {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: 42 },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBeNull();
|
||||
});
|
||||
|
||||
it("should leave slug null when title is empty string", async () => {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: "" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle unicode titles", async () => {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title: "Café Naïve" },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.slug).toBe("cafe-naive");
|
||||
});
|
||||
|
||||
it("should allow same auto-slug in different collections", async () => {
|
||||
const postResult = await handleContentCreate(db, "post", {
|
||||
data: { title: "About" },
|
||||
});
|
||||
const pageResult = await handleContentCreate(db, "page", {
|
||||
data: { title: "About" },
|
||||
});
|
||||
|
||||
expect(postResult.success).toBe(true);
|
||||
expect(pageResult.success).toBe(true);
|
||||
expect(postResult.data?.item.slug).toBe("about");
|
||||
expect(pageResult.data?.item.slug).toBe("about");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleContentDuplicate", () => {
|
||||
it("should generate slug from duplicated title", async () => {
|
||||
const original = await handleContentCreate(db, "post", {
|
||||
data: { title: "My Post" },
|
||||
slug: "my-post",
|
||||
});
|
||||
|
||||
const result = await handleContentDuplicate(db, "post", original.data!.item.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Title becomes "My Post (Copy)", slug should be generated from it
|
||||
expect(result.data?.item.slug).toBe("my-post-copy");
|
||||
});
|
||||
|
||||
it("should handle duplicate slug collision from copy", async () => {
|
||||
const original = await handleContentCreate(db, "post", {
|
||||
data: { title: "My Post" },
|
||||
slug: "my-post",
|
||||
});
|
||||
|
||||
// First duplicate
|
||||
const dup1 = await handleContentDuplicate(db, "post", original.data!.item.id);
|
||||
expect(dup1.data?.item.slug).toBe("my-post-copy");
|
||||
|
||||
// Second duplicate — "My Post (Copy)" title slugifies to "my-post-copy"
|
||||
// which now collides with the first duplicate
|
||||
const dup2 = await handleContentDuplicate(db, "post", original.data!.item.id);
|
||||
expect(dup2.success).toBe(true);
|
||||
expect(dup2.data?.item.slug).toBe("my-post-copy-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byline hydration and assignment", () => {
|
||||
it("should assign and return bylines on create", async () => {
|
||||
const bylineRepo = new BylineRepository(db);
|
||||
const byline = await bylineRepo.create({
|
||||
slug: "author-one",
|
||||
displayName: "Author One",
|
||||
});
|
||||
|
||||
const created = await handleContentCreate(db, "post", {
|
||||
data: { title: "Bylined" },
|
||||
bylines: [{ bylineId: byline.id, roleLabel: "Writer" }],
|
||||
});
|
||||
|
||||
expect(created.success).toBe(true);
|
||||
expect(created.data?.item.primaryBylineId).toBe(byline.id);
|
||||
expect(created.data?.item.byline?.id).toBe(byline.id);
|
||||
expect(created.data?.item.bylines).toHaveLength(1);
|
||||
expect(created.data?.item.bylines?.[0]?.roleLabel).toBe("Writer");
|
||||
});
|
||||
|
||||
it("should return bylines on get and list", async () => {
|
||||
const bylineRepo = new BylineRepository(db);
|
||||
const first = await bylineRepo.create({ slug: "first", displayName: "First" });
|
||||
const second = await bylineRepo.create({ slug: "second", displayName: "Second" });
|
||||
|
||||
const created = await handleContentCreate(db, "post", {
|
||||
data: { title: "Order Test" },
|
||||
bylines: [{ bylineId: second.id }, { bylineId: first.id }],
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
const contentId = created.data!.item.id;
|
||||
|
||||
const fetched = await handleContentGet(db, "post", contentId);
|
||||
expect(fetched.success).toBe(true);
|
||||
expect(fetched.data?.item.bylines?.[0]?.byline.id).toBe(second.id);
|
||||
expect(fetched.data?.item.bylines?.[1]?.byline.id).toBe(first.id);
|
||||
expect(fetched.data?.item.byline?.id).toBe(second.id);
|
||||
|
||||
const listed = await handleContentList(db, "post", {});
|
||||
expect(listed.success).toBe(true);
|
||||
const listedItem = listed.data?.items.find((item) => item.id === contentId);
|
||||
expect(listedItem?.byline?.id).toBe(second.id);
|
||||
expect(listedItem?.bylines?.[0]?.byline.id).toBe(second.id);
|
||||
});
|
||||
|
||||
it("should update byline ordering on update", async () => {
|
||||
const bylineRepo = new BylineRepository(db);
|
||||
const first = await bylineRepo.create({ slug: "first-upd", displayName: "First" });
|
||||
const second = await bylineRepo.create({ slug: "second-upd", displayName: "Second" });
|
||||
|
||||
const created = await handleContentCreate(db, "post", {
|
||||
data: { title: "Update Bylines" },
|
||||
bylines: [{ bylineId: first.id }, { bylineId: second.id }],
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
const updated = await handleContentUpdate(db, "post", created.data!.item.id, {
|
||||
bylines: [{ bylineId: second.id }, { bylineId: first.id }],
|
||||
});
|
||||
|
||||
expect(updated.success).toBe(true);
|
||||
expect(updated.data?.item.primaryBylineId).toBe(second.id);
|
||||
expect(updated.data?.item.bylines?.[0]?.byline.id).toBe(second.id);
|
||||
expect(updated.data?.item.bylines?.[1]?.byline.id).toBe(first.id);
|
||||
});
|
||||
|
||||
it("should copy bylines when duplicating", async () => {
|
||||
const bylineRepo = new BylineRepository(db);
|
||||
const byline = await bylineRepo.create({
|
||||
slug: "dup-author",
|
||||
displayName: "Dup Author",
|
||||
});
|
||||
|
||||
const original = await handleContentCreate(db, "post", {
|
||||
data: { title: "Duplicate With Bylines" },
|
||||
bylines: [{ bylineId: byline.id }],
|
||||
});
|
||||
expect(original.success).toBe(true);
|
||||
|
||||
const duplicated = await handleContentDuplicate(db, "post", original.data!.item.id);
|
||||
expect(duplicated.success).toBe(true);
|
||||
expect(duplicated.data?.item.byline?.id).toBe(byline.id);
|
||||
expect(duplicated.data?.item.bylines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
packages/core/tests/unit/api/csrf.test.ts
Normal file
129
packages/core/tests/unit/api/csrf.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { checkPublicCsrf } from "../../../src/api/csrf.js";
|
||||
|
||||
function makeRequest(method: string, headers: Record<string, string> = {}): Request {
|
||||
return new Request("http://example.com/_emdash/api/comments/posts/abc", {
|
||||
method,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function makeUrl(host = "example.com"): URL {
|
||||
return new URL(`http://${host}/_emdash/api/comments/posts/abc`);
|
||||
}
|
||||
|
||||
describe("checkPublicCsrf", () => {
|
||||
describe("allows requests with X-EmDash-Request header", () => {
|
||||
it("allows POST with custom header", () => {
|
||||
const request = makeRequest("POST", { "X-EmDash-Request": "1" });
|
||||
expect(checkPublicCsrf(request, makeUrl())).toBeNull();
|
||||
});
|
||||
|
||||
it("allows POST with custom header even if Origin is cross-origin", () => {
|
||||
const request = makeRequest("POST", {
|
||||
"X-EmDash-Request": "1",
|
||||
Origin: "http://evil.com",
|
||||
});
|
||||
expect(checkPublicCsrf(request, makeUrl())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("allows same-origin requests", () => {
|
||||
it("allows POST with matching Origin", () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "http://example.com",
|
||||
});
|
||||
expect(checkPublicCsrf(request, makeUrl())).toBeNull();
|
||||
});
|
||||
|
||||
it("allows POST with matching Origin on different path", () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "http://example.com",
|
||||
});
|
||||
const url = new URL("http://example.com/_emdash/api/auth/invite/complete");
|
||||
expect(checkPublicCsrf(request, url)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches host including port", () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "http://localhost:4321",
|
||||
});
|
||||
const url = new URL("http://localhost:4321/_emdash/api/comments/posts/abc");
|
||||
expect(checkPublicCsrf(request, url)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocks cross-origin requests", () => {
|
||||
it("returns 403 with CSRF_REJECTED code", async () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "http://evil.com",
|
||||
});
|
||||
const response = checkPublicCsrf(request, makeUrl());
|
||||
expect(response).not.toBeNull();
|
||||
expect(response!.status).toBe(403);
|
||||
const body = await response!.json();
|
||||
expect(body).toEqual({
|
||||
error: { code: "CSRF_REJECTED", message: "Cross-origin request blocked" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Origin with different port", async () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "http://example.com:9999",
|
||||
});
|
||||
const response = checkPublicCsrf(request, makeUrl());
|
||||
expect(response).not.toBeNull();
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects Origin with different host", async () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "http://attacker.example.com",
|
||||
});
|
||||
const response = checkPublicCsrf(request, makeUrl());
|
||||
expect(response).not.toBeNull();
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects cross-scheme Origin (http vs https)", async () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "https://example.com",
|
||||
});
|
||||
// Request URL is http://example.com — same host but different scheme
|
||||
const response = checkPublicCsrf(request, makeUrl());
|
||||
expect(response).not.toBeNull();
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects malformed Origin header", async () => {
|
||||
const request = makeRequest("POST", {
|
||||
Origin: "not-a-valid-url",
|
||||
});
|
||||
const response = checkPublicCsrf(request, makeUrl());
|
||||
expect(response).not.toBeNull();
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects Origin: null (sandboxed iframe)", async () => {
|
||||
const request = makeRequest("POST", { Origin: "null" });
|
||||
const response = checkPublicCsrf(request, makeUrl());
|
||||
expect(response).not.toBeNull();
|
||||
expect(response!.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allows requests without Origin header", () => {
|
||||
it("allows POST without any Origin (non-browser client)", () => {
|
||||
const request = makeRequest("POST");
|
||||
expect(checkPublicCsrf(request, makeUrl())).toBeNull();
|
||||
});
|
||||
|
||||
it("allows POST without Origin or custom header (curl/server)", () => {
|
||||
const request = makeRequest("POST", {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(checkPublicCsrf(request, makeUrl())).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
241
packages/core/tests/unit/api/dashboard-handlers.test.ts
Normal file
241
packages/core/tests/unit/api/dashboard-handlers.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
import { handleDashboardStats } from "../../../src/api/handlers/dashboard.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
||||
import { createPostFixture, createPageFixture } from "../../utils/fixtures.js";
|
||||
import {
|
||||
setupTestDatabase,
|
||||
setupTestDatabaseWithCollections,
|
||||
teardownTestDatabase,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describe("Dashboard Handlers", () => {
|
||||
describe("handleDashboardStats", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("returns empty stats when no collections exist", async () => {
|
||||
db = await setupTestDatabase();
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data!.collections).toEqual([]);
|
||||
expect(result.data!.mediaCount).toBe(0);
|
||||
expect(result.data!.userCount).toBe(0);
|
||||
expect(result.data!.recentItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns collection stats with correct counts", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
// Create some posts with different statuses
|
||||
await contentRepo.create(createPostFixture({ slug: "post-1" }));
|
||||
await contentRepo.create(createPostFixture({ slug: "post-2", status: "published" }));
|
||||
await contentRepo.create(createPostFixture({ slug: "post-3", status: "published" }));
|
||||
|
||||
// Create a draft page
|
||||
await contentRepo.create(createPageFixture({ slug: "page-1" }));
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const { collections } = result.data!;
|
||||
|
||||
// Both collections should be present
|
||||
expect(collections).toHaveLength(2);
|
||||
|
||||
const postStats = collections.find((c) => c.slug === "post");
|
||||
expect(postStats).toBeDefined();
|
||||
expect(postStats!.label).toBe("Posts");
|
||||
expect(postStats!.total).toBe(3);
|
||||
expect(postStats!.published).toBe(2);
|
||||
expect(postStats!.draft).toBe(1);
|
||||
|
||||
const pageStats = collections.find((c) => c.slug === "page");
|
||||
expect(pageStats).toBeDefined();
|
||||
expect(pageStats!.label).toBe("Pages");
|
||||
expect(pageStats!.total).toBe(1);
|
||||
expect(pageStats!.published).toBe(0);
|
||||
expect(pageStats!.draft).toBe(1);
|
||||
});
|
||||
|
||||
it("returns recent items across collections", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
await contentRepo.create(createPostFixture({ slug: "post-1" }));
|
||||
// Small delay for distinct updated_at
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await contentRepo.create(createPageFixture({ slug: "page-1" }));
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const { recentItems } = result.data!;
|
||||
|
||||
expect(recentItems.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Most recently updated should be first
|
||||
expect(recentItems[0]!.collection).toBe("page");
|
||||
expect(recentItems[0]!.collectionLabel).toBe("Pages");
|
||||
expect(recentItems[0]!.slug).toBe("page-1");
|
||||
expect(recentItems[0]!.status).toBe("draft");
|
||||
|
||||
expect(recentItems[1]!.collection).toBe("post");
|
||||
expect(recentItems[1]!.collectionLabel).toBe("Posts");
|
||||
expect(recentItems[1]!.slug).toBe("post-1");
|
||||
});
|
||||
|
||||
it("recent items use title field when available", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
// setupTestDatabaseWithCollections creates post/page with title fields
|
||||
await contentRepo.create(
|
||||
createPostFixture({
|
||||
slug: "my-post",
|
||||
data: { title: "My Great Post", content: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const postItem = result.data!.recentItems.find((i) => i.slug === "my-post");
|
||||
expect(postItem).toBeDefined();
|
||||
expect(postItem!.title).toBe("My Great Post");
|
||||
});
|
||||
|
||||
it("recent items fall back to slug when collection has no title field", async () => {
|
||||
db = await setupTestDatabase();
|
||||
const registry = new SchemaRegistry(db);
|
||||
|
||||
// Create a collection without a title field
|
||||
await registry.createCollection({
|
||||
slug: "events",
|
||||
label: "Events",
|
||||
labelSingular: "Event",
|
||||
});
|
||||
await registry.createField("events", {
|
||||
slug: "date",
|
||||
label: "Date",
|
||||
type: "datetime",
|
||||
});
|
||||
|
||||
const contentRepo = new ContentRepository(db);
|
||||
await contentRepo.create({
|
||||
type: "events",
|
||||
slug: "launch-party",
|
||||
data: { date: "2026-03-01" },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const eventItem = result.data!.recentItems.find((i) => i.collection === "events");
|
||||
expect(eventItem).toBeDefined();
|
||||
// No title field, should fall back to slug
|
||||
expect(eventItem!.title).toBe("launch-party");
|
||||
});
|
||||
|
||||
it("excludes soft-deleted items from recent items", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
const post = await contentRepo.create(createPostFixture({ slug: "will-delete" }));
|
||||
await contentRepo.create(createPostFixture({ slug: "will-keep" }));
|
||||
|
||||
// Soft-delete the first post
|
||||
await contentRepo.delete("post", post.id);
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const slugs = result.data!.recentItems.map((i) => i.slug);
|
||||
expect(slugs).toContain("will-keep");
|
||||
expect(slugs).not.toContain("will-delete");
|
||||
});
|
||||
|
||||
it("limits recent items to 10", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
// Create 15 posts
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await contentRepo.create(createPostFixture({ slug: `post-${String(i).padStart(2, "0")}` }));
|
||||
}
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data!.recentItems).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("recent items are ordered by updated_at descending", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
await contentRepo.create(createPostFixture({ slug: "oldest" }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await contentRepo.create(createPostFixture({ slug: "middle" }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await contentRepo.create(createPostFixture({ slug: "newest" }));
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const slugs = result.data!.recentItems.map((i) => i.slug);
|
||||
expect(slugs).toEqual(["newest", "middle", "oldest"]);
|
||||
});
|
||||
|
||||
it("counts exclude soft-deleted items", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
const post = await contentRepo.create(createPostFixture({ slug: "to-delete" }));
|
||||
await contentRepo.create(createPostFixture({ slug: "to-keep" }));
|
||||
await contentRepo.delete("post", post.id);
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const postStats = result.data!.collections.find((c) => c.slug === "post");
|
||||
// count() in ContentRepository filters deleted_at IS NULL
|
||||
expect(postStats!.total).toBe(1);
|
||||
});
|
||||
|
||||
it("returns camelCase keys in recent items", async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
const contentRepo = new ContentRepository(db);
|
||||
await contentRepo.create(createPostFixture());
|
||||
|
||||
const result = await handleDashboardStats(db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const item = result.data!.recentItems[0]!;
|
||||
// Verify camelCase API shape
|
||||
expect(item).toHaveProperty("id");
|
||||
expect(item).toHaveProperty("collection");
|
||||
expect(item).toHaveProperty("collectionLabel");
|
||||
expect(item).toHaveProperty("title");
|
||||
expect(item).toHaveProperty("slug");
|
||||
expect(item).toHaveProperty("status");
|
||||
expect(item).toHaveProperty("updatedAt");
|
||||
expect(item).toHaveProperty("authorId");
|
||||
// Should NOT have snake_case keys
|
||||
expect(item).not.toHaveProperty("collection_label");
|
||||
expect(item).not.toHaveProperty("updated_at");
|
||||
expect(item).not.toHaveProperty("author_id");
|
||||
});
|
||||
});
|
||||
});
|
||||
862
packages/core/tests/unit/api/marketplace-handlers.test.ts
Normal file
862
packages/core/tests/unit/api/marketplace-handlers.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
/**
|
||||
* Marketplace handler tests
|
||||
*
|
||||
* Tests the business logic for:
|
||||
* - Install (handleMarketplaceInstall)
|
||||
* - Update (handleMarketplaceUpdate)
|
||||
* - Uninstall (handleMarketplaceUninstall)
|
||||
* - Update check (handleMarketplaceUpdateCheck)
|
||||
* - Search/GetPlugin proxies (handleMarketplaceSearch, handleMarketplaceGetPlugin)
|
||||
*
|
||||
* Uses a real in-memory SQLite database and mock Storage/SandboxRunner/fetch.
|
||||
*/
|
||||
|
||||
import BetterSqlite3 from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
handleMarketplaceInstall,
|
||||
handleMarketplaceUpdate,
|
||||
handleMarketplaceUninstall,
|
||||
handleMarketplaceUpdateCheck,
|
||||
handleMarketplaceSearch,
|
||||
handleMarketplaceGetPlugin,
|
||||
} from "../../../src/api/handlers/marketplace.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import type { MarketplacePluginDetail } from "../../../src/plugins/marketplace.js";
|
||||
import type { SandboxRunner, SandboxedPlugin } from "../../../src/plugins/sandbox/types.js";
|
||||
import { PluginStateRepository } from "../../../src/plugins/state.js";
|
||||
import type { PluginManifest } from "../../../src/plugins/types.js";
|
||||
import type {
|
||||
Storage,
|
||||
UploadResult,
|
||||
DownloadResult,
|
||||
ListResult,
|
||||
SignedUploadUrl,
|
||||
} from "../../../src/storage/types.js";
|
||||
|
||||
// ── Mock factories ────────────────────────────────────────────────
|
||||
|
||||
function createMockStorage(): Storage {
|
||||
const store = new Map<string, { body: Uint8Array; contentType: string }>();
|
||||
|
||||
return {
|
||||
async upload(opts: {
|
||||
key: string;
|
||||
body: Buffer | Uint8Array | ReadableStream<Uint8Array>;
|
||||
contentType: string;
|
||||
}): Promise<UploadResult> {
|
||||
let body: Uint8Array;
|
||||
if (opts.body instanceof Uint8Array) {
|
||||
body = opts.body;
|
||||
} else if (Buffer.isBuffer(opts.body)) {
|
||||
body = new Uint8Array(opts.body);
|
||||
} else {
|
||||
// ReadableStream
|
||||
const response = new Response(opts.body);
|
||||
body = new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
store.set(opts.key, { body, contentType: opts.contentType });
|
||||
return { key: opts.key, url: `https://storage.test/${opts.key}`, size: body.length };
|
||||
},
|
||||
async download(key: string): Promise<DownloadResult> {
|
||||
const item = store.get(key);
|
||||
if (!item) throw new Error(`Not found: ${key}`);
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(item.body);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return { body: stream, contentType: item.contentType, size: item.body.length };
|
||||
},
|
||||
async delete(key: string): Promise<void> {
|
||||
store.delete(key);
|
||||
},
|
||||
async exists(key: string): Promise<boolean> {
|
||||
return store.has(key);
|
||||
},
|
||||
async list(): Promise<ListResult> {
|
||||
return { files: [] };
|
||||
},
|
||||
async getSignedUploadUrl(): Promise<SignedUploadUrl> {
|
||||
return {
|
||||
url: "https://test.com/upload",
|
||||
method: "PUT",
|
||||
headers: {},
|
||||
expiresAt: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
getPublicUrl(key: string): string {
|
||||
return `https://storage.test/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSandboxRunner(): SandboxRunner & {
|
||||
loadedPlugins: Array<{ manifest: PluginManifest; code: string }>;
|
||||
} {
|
||||
const loadedPlugins: Array<{ manifest: PluginManifest; code: string }> = [];
|
||||
|
||||
return {
|
||||
loadedPlugins,
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
},
|
||||
async load(manifest: PluginManifest, code: string): Promise<SandboxedPlugin> {
|
||||
loadedPlugins.push({ manifest, code });
|
||||
return {
|
||||
id: manifest.id,
|
||||
manifest,
|
||||
async invokeHook() {
|
||||
return undefined;
|
||||
},
|
||||
async invokeRoute() {
|
||||
return undefined;
|
||||
},
|
||||
async terminate() {},
|
||||
};
|
||||
},
|
||||
async terminateAll() {},
|
||||
};
|
||||
}
|
||||
|
||||
const MARKETPLACE_URL = "https://marketplace.example.com";
|
||||
|
||||
function mockManifest(id = "test-seo", version = "1.0.0"): PluginManifest {
|
||||
return {
|
||||
id,
|
||||
version,
|
||||
capabilities: ["read:content"],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a gzipped tar bundle for use with mocked fetch.
|
||||
* Uses CompressionStream + minimal tar format.
|
||||
*/
|
||||
async function createMockBundle(manifest: PluginManifest): Promise<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const manifestJson = JSON.stringify(manifest);
|
||||
const backendCode = 'export default function() { return "hello"; }';
|
||||
|
||||
// Create simple tar
|
||||
const files = [
|
||||
{ name: "manifest.json", content: manifestJson },
|
||||
{ name: "backend.js", content: backendCode },
|
||||
];
|
||||
|
||||
const blocks: Uint8Array[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const contentBytes = encoder.encode(file.content);
|
||||
const header = new Uint8Array(512);
|
||||
|
||||
// Name
|
||||
header.set(encoder.encode(file.name), 0);
|
||||
// Mode
|
||||
header.set(encoder.encode("0000644\0"), 100);
|
||||
// UID/GID
|
||||
header.set(encoder.encode("0000000\0"), 108);
|
||||
header.set(encoder.encode("0000000\0"), 116);
|
||||
// Size in octal
|
||||
const sizeOctal = contentBytes.length.toString(8).padStart(11, "0") + "\0";
|
||||
header.set(encoder.encode(sizeOctal), 124);
|
||||
// Mtime
|
||||
header.set(encoder.encode("00000000000\0"), 136);
|
||||
// Type = regular file
|
||||
header[156] = 0x30;
|
||||
// Checksum spaces
|
||||
header.set(encoder.encode(" "), 148);
|
||||
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < 512; i++) checksum += header[i]!;
|
||||
header.set(encoder.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148);
|
||||
|
||||
blocks.push(header);
|
||||
|
||||
const paddedSize = Math.ceil(contentBytes.length / 512) * 512;
|
||||
const dataBlock = new Uint8Array(paddedSize);
|
||||
dataBlock.set(contentBytes, 0);
|
||||
blocks.push(dataBlock);
|
||||
}
|
||||
|
||||
blocks.push(new Uint8Array(1024)); // end-of-archive
|
||||
|
||||
const totalSize = blocks.reduce((sum, b) => sum + b.length, 0);
|
||||
const tar = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const block of blocks) {
|
||||
tar.set(block, offset);
|
||||
offset += block.length;
|
||||
}
|
||||
|
||||
// Gzip
|
||||
const cs = new CompressionStream("gzip");
|
||||
const writer = cs.writable.getWriter();
|
||||
const reader = cs.readable.getReader();
|
||||
|
||||
const writePromise = writer.write(tar).then(() => writer.close());
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalLen = 0;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLen += value.length;
|
||||
}
|
||||
await writePromise;
|
||||
|
||||
const result = new Uint8Array(totalLen);
|
||||
offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mockPluginDetail(
|
||||
id = "test-seo",
|
||||
latestVersion = "1.0.0",
|
||||
checksum?: string,
|
||||
): MarketplacePluginDetail {
|
||||
return {
|
||||
id,
|
||||
name: "Test SEO",
|
||||
description: "SEO plugin",
|
||||
author: { name: "Test", verified: true, avatarUrl: null },
|
||||
capabilities: ["hooks"],
|
||||
keywords: [],
|
||||
installCount: 10,
|
||||
hasIcon: false,
|
||||
iconUrl: "",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
repositoryUrl: null,
|
||||
homepageUrl: null,
|
||||
license: "MIT",
|
||||
latestVersion: {
|
||||
version: latestVersion,
|
||||
minEmDashVersion: null,
|
||||
bundleSize: 1234,
|
||||
checksum: checksum ?? "will-be-computed",
|
||||
changelog: null,
|
||||
readme: null,
|
||||
hasIcon: false,
|
||||
screenshotCount: 0,
|
||||
screenshotUrls: [],
|
||||
capabilities: ["hooks"],
|
||||
auditVerdict: "pass",
|
||||
imageAuditVerdict: "pass",
|
||||
publishedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Marketplace handlers", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: BetterSqlite3.Database;
|
||||
let storage: Storage;
|
||||
let sandboxRunner: ReturnType<typeof createMockSandboxRunner>;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqliteDb = new BetterSqlite3(":memory:");
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqliteDb }),
|
||||
});
|
||||
await runMigrations(db);
|
||||
|
||||
storage = createMockStorage();
|
||||
sandboxRunner = createMockSandboxRunner();
|
||||
fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Install ────────────────────────────────────────────────────
|
||||
|
||||
describe("handleMarketplaceInstall", () => {
|
||||
it("returns error when marketplace not configured", async () => {
|
||||
const result = await handleMarketplaceInstall(db, storage, sandboxRunner, undefined, "test");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("MARKETPLACE_NOT_CONFIGURED");
|
||||
});
|
||||
|
||||
it("returns error when storage not available", async () => {
|
||||
const result = await handleMarketplaceInstall(
|
||||
db,
|
||||
null,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test",
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("STORAGE_NOT_CONFIGURED");
|
||||
});
|
||||
|
||||
it("returns error when sandbox runner not available", async () => {
|
||||
const result = await handleMarketplaceInstall(db, storage, null, MARKETPLACE_URL, "test");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("SANDBOX_NOT_AVAILABLE");
|
||||
});
|
||||
|
||||
it("successfully installs a marketplace plugin", async () => {
|
||||
const manifest = mockManifest("test-seo", "1.0.0");
|
||||
const bundleBytes = await createMockBundle(manifest);
|
||||
|
||||
// Mock: getPlugin detail — set checksum to undefined so the check is skipped
|
||||
const detail = mockPluginDetail("test-seo", "1.0.0");
|
||||
detail.latestVersion!.checksum = "";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
// Mock: downloadBundle
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
// Mock: reportInstall
|
||||
fetchSpy.mockResolvedValueOnce(new Response("OK", { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceInstall(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.pluginId).toBe("test-seo");
|
||||
expect(result.data?.version).toBe("1.0.0");
|
||||
expect(result.data?.capabilities).toEqual(["read:content"]);
|
||||
|
||||
// Verify state was written
|
||||
const repo = new PluginStateRepository(db);
|
||||
const state = await repo.get("test-seo");
|
||||
expect(state?.source).toBe("marketplace");
|
||||
expect(state?.marketplaceVersion).toBe("1.0.0");
|
||||
expect(state?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("rejects install if plugin already installed", async () => {
|
||||
// Pre-install the plugin
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Mock: getPlugin detail (still needed — called before install check... actually, the existing check comes first)
|
||||
const result = await handleMarketplaceInstall(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("ALREADY_INSTALLED");
|
||||
});
|
||||
|
||||
it("rejects when manifest ID doesn't match requested plugin", async () => {
|
||||
const manifest = mockManifest("wrong-id", "1.0.0");
|
||||
const bundleBytes = await createMockBundle(manifest);
|
||||
|
||||
// Clear checksum so we reach the manifest check
|
||||
const detail = mockPluginDetail("test-seo", "1.0.0");
|
||||
detail.latestVersion!.checksum = "";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceInstall(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("MANIFEST_MISMATCH");
|
||||
});
|
||||
|
||||
it("validates checksum against requested pinned version metadata", async () => {
|
||||
const manifest = mockManifest("test-seo", "1.0.0");
|
||||
const bundleBytes = await createMockBundle(manifest);
|
||||
|
||||
const detail = mockPluginDetail("test-seo", "2.0.0");
|
||||
detail.latestVersion!.checksum = "different-checksum";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
minEmDashVersion: null,
|
||||
bundleSize: 1234,
|
||||
checksum: "",
|
||||
changelog: null,
|
||||
capabilities: ["hooks"],
|
||||
auditVerdict: "pass",
|
||||
imageAuditVerdict: "pass",
|
||||
publishedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response("OK", { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceInstall(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
{ version: "1.0.0" },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update ─────────────────────────────────────────────────────
|
||||
|
||||
describe("handleMarketplaceUpdate", () => {
|
||||
it("returns error when plugin not found", async () => {
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"nonexistent",
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("returns error when plugin is not from marketplace", async () => {
|
||||
// Insert a config-sourced plugin
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("config-plugin", "1.0.0", "active");
|
||||
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"config-plugin",
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("returns error when already up to date", async () => {
|
||||
// Install v1.0.0
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Mock getPlugin returning same version
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(mockPluginDetail("test-seo", "1.0.0")), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("ALREADY_UP_TO_DATE");
|
||||
});
|
||||
|
||||
it("rejects update on checksum mismatch", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
const detail = mockPluginDetail("test-seo", "2.0.0");
|
||||
detail.latestVersion!.checksum = "expected-checksum";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
|
||||
const bundleBytes = await createMockBundle(mockManifest("test-seo", "2.0.0"));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
{ confirmCapabilityChanges: true },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("CHECKSUM_MISMATCH");
|
||||
});
|
||||
|
||||
it("rejects update when bundle manifest version mismatches target", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
const detail = mockPluginDetail("test-seo", "2.0.0");
|
||||
detail.latestVersion!.checksum = "";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
|
||||
const wrongVersionManifest = mockManifest("test-seo", "9.9.9");
|
||||
const bundleBytes = await createMockBundle(wrongVersionManifest);
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
{ confirmCapabilityChanges: true },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("MANIFEST_VERSION_MISMATCH");
|
||||
});
|
||||
|
||||
it("requires confirmation for capability escalation", async () => {
|
||||
// Install v1.0.0 with only "hooks" capability
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Store old bundle in R2 (needed for capability diff)
|
||||
const oldManifest = mockManifest("test-seo", "1.0.0");
|
||||
const encoder = new TextEncoder();
|
||||
await storage.upload({
|
||||
key: "marketplace/test-seo/1.0.0/manifest.json",
|
||||
body: encoder.encode(JSON.stringify(oldManifest)),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await storage.upload({
|
||||
key: "marketplace/test-seo/1.0.0/backend.js",
|
||||
body: encoder.encode("export default {};"),
|
||||
contentType: "application/javascript",
|
||||
});
|
||||
|
||||
// New version has additional capability
|
||||
const newManifest = {
|
||||
...mockManifest("test-seo", "2.0.0"),
|
||||
capabilities: ["read:content", "network:fetch"],
|
||||
};
|
||||
const bundleBytes = await createMockBundle(newManifest as PluginManifest);
|
||||
|
||||
// Mock getPlugin
|
||||
const detail = mockPluginDetail("test-seo", "2.0.0");
|
||||
detail.latestVersion!.checksum = "";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
// Mock downloadBundle
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("CAPABILITY_ESCALATION");
|
||||
expect(result.error?.details?.capabilityChanges).toBeDefined();
|
||||
});
|
||||
|
||||
it("succeeds with confirmCapabilityChanges flag", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Store old bundle
|
||||
const encoder = new TextEncoder();
|
||||
const oldManifest = mockManifest("test-seo", "1.0.0");
|
||||
await storage.upload({
|
||||
key: "marketplace/test-seo/1.0.0/manifest.json",
|
||||
body: encoder.encode(JSON.stringify(oldManifest)),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await storage.upload({
|
||||
key: "marketplace/test-seo/1.0.0/backend.js",
|
||||
body: encoder.encode("export default {};"),
|
||||
contentType: "application/javascript",
|
||||
});
|
||||
|
||||
const newManifest = {
|
||||
...mockManifest("test-seo", "2.0.0"),
|
||||
capabilities: ["read:content", "network:fetch"],
|
||||
};
|
||||
const bundleBytes = await createMockBundle(newManifest as PluginManifest);
|
||||
|
||||
const detail = mockPluginDetail("test-seo", "2.0.0");
|
||||
detail.latestVersion!.checksum = "";
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 }));
|
||||
fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceUpdate(
|
||||
db,
|
||||
storage,
|
||||
sandboxRunner,
|
||||
MARKETPLACE_URL,
|
||||
"test-seo",
|
||||
{ confirmCapabilityChanges: true },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.oldVersion).toBe("1.0.0");
|
||||
expect(result.data?.newVersion).toBe("2.0.0");
|
||||
expect(result.data?.capabilityChanges.added).toContain("network:fetch");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Uninstall ──────────────────────────────────────────────────
|
||||
|
||||
describe("handleMarketplaceUninstall", () => {
|
||||
it("returns error when plugin not found", async () => {
|
||||
const result = await handleMarketplaceUninstall(db, storage, "nonexistent");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("returns error when plugin is not from marketplace", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("config-plugin", "1.0.0", "active");
|
||||
|
||||
const result = await handleMarketplaceUninstall(db, storage, "config-plugin");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("successfully uninstalls a marketplace plugin", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Store bundle files that should be cleaned up
|
||||
const encoder = new TextEncoder();
|
||||
await storage.upload({
|
||||
key: "marketplace/test-seo/1.0.0/manifest.json",
|
||||
body: encoder.encode("{}"),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await storage.upload({
|
||||
key: "marketplace/test-seo/1.0.0/backend.js",
|
||||
body: encoder.encode(""),
|
||||
contentType: "application/javascript",
|
||||
});
|
||||
|
||||
const result = await handleMarketplaceUninstall(db, storage, "test-seo");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.pluginId).toBe("test-seo");
|
||||
expect(result.data?.dataDeleted).toBe(false);
|
||||
|
||||
// Verify state was deleted
|
||||
const state = await repo.get("test-seo");
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes plugin storage data when deleteData=true", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Insert some plugin storage data
|
||||
await db
|
||||
.insertInto("_plugin_storage")
|
||||
.values({
|
||||
plugin_id: "test-seo",
|
||||
collection: "default",
|
||||
id: "test-key",
|
||||
data: JSON.stringify({ foo: "bar" }),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const result = await handleMarketplaceUninstall(db, storage, "test-seo", {
|
||||
deleteData: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.dataDeleted).toBe(true);
|
||||
|
||||
// Verify plugin storage data was deleted
|
||||
const storageRows = await db
|
||||
.selectFrom("_plugin_storage")
|
||||
.selectAll()
|
||||
.where("plugin_id", "=", "test-seo")
|
||||
.execute();
|
||||
expect(storageRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update check ───────────────────────────────────────────────
|
||||
|
||||
describe("handleMarketplaceUpdateCheck", () => {
|
||||
it("returns error when marketplace not configured", async () => {
|
||||
const result = await handleMarketplaceUpdateCheck(db, undefined);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("MARKETPLACE_NOT_CONFIGURED");
|
||||
});
|
||||
|
||||
it("returns empty items when no marketplace plugins installed", async () => {
|
||||
const result = await handleMarketplaceUpdateCheck(db, MARKETPLACE_URL);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects available updates", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// Mock getPlugin returning newer version
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(mockPluginDetail("test-seo", "2.0.0")), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await handleMarketplaceUpdateCheck(db, MARKETPLACE_URL);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items).toHaveLength(1);
|
||||
expect(result.data?.items[0]?.hasUpdate).toBe(true);
|
||||
expect(result.data?.items[0]?.installed).toBe("1.0.0");
|
||||
expect(result.data?.items[0]?.latest).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("reports no update when versions match", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(mockPluginDetail("test-seo", "1.0.0")), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await handleMarketplaceUpdateCheck(db, MARKETPLACE_URL);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items[0]?.hasUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it("skips plugins that fail to check", async () => {
|
||||
const repo = new PluginStateRepository(db);
|
||||
await repo.upsert("test-seo", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
await repo.upsert("test-analytics", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
// First plugin check fails (404 — delisted)
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
||||
);
|
||||
// Second plugin check succeeds
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(mockPluginDetail("test-analytics", "2.0.0")), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await handleMarketplaceUpdateCheck(db, MARKETPLACE_URL);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Only the successful check should appear
|
||||
expect(result.data?.items).toHaveLength(1);
|
||||
expect(result.data?.items[0]?.pluginId).toBe("test-analytics");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Search proxy ───────────────────────────────────────────────
|
||||
|
||||
describe("handleMarketplaceSearch", () => {
|
||||
it("returns error when marketplace not configured", async () => {
|
||||
const result = await handleMarketplaceSearch(undefined);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("MARKETPLACE_NOT_CONFIGURED");
|
||||
});
|
||||
|
||||
it("proxies search request to marketplace", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
|
||||
const result = await handleMarketplaceSearch(MARKETPLACE_URL, "seo");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toContain("/api/v1/plugins?q=seo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── GetPlugin proxy ────────────────────────────────────────────
|
||||
|
||||
describe("handleMarketplaceGetPlugin", () => {
|
||||
it("returns error when marketplace not configured", async () => {
|
||||
const result = await handleMarketplaceGetPlugin(undefined, "test-seo");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("MARKETPLACE_NOT_CONFIGURED");
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for missing plugin", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
||||
);
|
||||
|
||||
const result = await handleMarketplaceGetPlugin(MARKETPLACE_URL, "nonexistent");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("proxies plugin detail from marketplace", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(mockPluginDetail()), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await handleMarketplaceGetPlugin(MARKETPLACE_URL, "test-seo");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
338
packages/core/tests/unit/api/openapi.test.ts
Normal file
338
packages/core/tests/unit/api/openapi.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { generateOpenApiDocument } from "../../../src/api/openapi/document.js";
|
||||
|
||||
describe("OpenAPI document generation", () => {
|
||||
it("generates a valid OpenAPI 3.1 document", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
|
||||
expect(doc.openapi).toBe("3.1.0");
|
||||
expect(doc.info.title).toBe("EmDash CMS API");
|
||||
expect(doc.info.version).toBe("0.1.0");
|
||||
});
|
||||
|
||||
it("includes content paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/{id}");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/{id}/publish");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/{id}/schedule");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/{id}/duplicate");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/{id}/compare");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/{id}/translations");
|
||||
expect(paths).toContain("/_emdash/api/content/{collection}/trash");
|
||||
});
|
||||
|
||||
it("includes media paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/media");
|
||||
expect(paths).toContain("/_emdash/api/media/{id}");
|
||||
expect(paths).toContain("/_emdash/api/media/upload-url");
|
||||
expect(paths).toContain("/_emdash/api/media/{id}/confirm");
|
||||
});
|
||||
|
||||
it("includes schema paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/schema/collections");
|
||||
expect(paths).toContain("/_emdash/api/schema/collections/{slug}");
|
||||
expect(paths).toContain("/_emdash/api/schema/collections/{slug}/fields");
|
||||
expect(paths).toContain("/_emdash/api/schema/collections/{slug}/fields/{fieldSlug}");
|
||||
expect(paths).toContain("/_emdash/api/schema/orphans");
|
||||
});
|
||||
|
||||
it("includes comments paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/comments/{collection}/{contentId}");
|
||||
expect(paths).toContain("/_emdash/api/admin/comments");
|
||||
expect(paths).toContain("/_emdash/api/admin/comments/counts");
|
||||
expect(paths).toContain("/_emdash/api/admin/comments/bulk");
|
||||
expect(paths).toContain("/_emdash/api/admin/comments/{id}");
|
||||
});
|
||||
|
||||
it("includes taxonomy paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/taxonomies");
|
||||
expect(paths).toContain("/_emdash/api/taxonomies/{name}/terms");
|
||||
expect(paths).toContain("/_emdash/api/taxonomies/{name}/terms/{slug}");
|
||||
});
|
||||
|
||||
it("includes menu paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/menus");
|
||||
expect(paths).toContain("/_emdash/api/menus/{name}");
|
||||
expect(paths).toContain("/_emdash/api/menus/{name}/items");
|
||||
expect(paths).toContain("/_emdash/api/menus/{name}/reorder");
|
||||
});
|
||||
|
||||
it("includes section paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/sections");
|
||||
expect(paths).toContain("/_emdash/api/sections/{slug}");
|
||||
});
|
||||
|
||||
it("includes widget paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/widget-areas");
|
||||
expect(paths).toContain("/_emdash/api/widget-areas/{name}");
|
||||
expect(paths).toContain("/_emdash/api/widget-areas/{name}/widgets");
|
||||
expect(paths).toContain("/_emdash/api/widget-areas/{name}/widgets/{id}");
|
||||
expect(paths).toContain("/_emdash/api/widget-areas/{name}/reorder");
|
||||
});
|
||||
|
||||
it("includes settings paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/settings");
|
||||
});
|
||||
|
||||
it("includes search paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/search");
|
||||
expect(paths).toContain("/_emdash/api/search/suggest");
|
||||
expect(paths).toContain("/_emdash/api/search/rebuild");
|
||||
expect(paths).toContain("/_emdash/api/search/enable");
|
||||
expect(paths).toContain("/_emdash/api/search/stats");
|
||||
});
|
||||
|
||||
it("includes redirect paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/redirects");
|
||||
expect(paths).toContain("/_emdash/api/redirects/{id}");
|
||||
expect(paths).toContain("/_emdash/api/redirects/404s");
|
||||
expect(paths).toContain("/_emdash/api/redirects/404s/summary");
|
||||
});
|
||||
|
||||
it("includes user paths", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const paths = Object.keys(doc.paths ?? {});
|
||||
|
||||
expect(paths).toContain("/_emdash/api/admin/users");
|
||||
expect(paths).toContain("/_emdash/api/admin/users/{id}");
|
||||
expect(paths).toContain("/_emdash/api/admin/users/{id}/disable");
|
||||
expect(paths).toContain("/_emdash/api/admin/users/{id}/enable");
|
||||
expect(paths).toContain("/_emdash/api/admin/allowed-domains");
|
||||
expect(paths).toContain("/_emdash/api/admin/allowed-domains/{domain}");
|
||||
});
|
||||
|
||||
it("has correct HTTP methods on content collection endpoint", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const collectionPath = doc.paths?.["/_emdash/api/content/{collection}"];
|
||||
|
||||
expect(collectionPath).toBeDefined();
|
||||
expect(collectionPath).toHaveProperty("get");
|
||||
expect(collectionPath).toHaveProperty("post");
|
||||
});
|
||||
|
||||
it("has correct HTTP methods on content item endpoint", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const itemPath = doc.paths?.["/_emdash/api/content/{collection}/{id}"];
|
||||
|
||||
expect(itemPath).toBeDefined();
|
||||
expect(itemPath).toHaveProperty("get");
|
||||
expect(itemPath).toHaveProperty("put");
|
||||
expect(itemPath).toHaveProperty("delete");
|
||||
});
|
||||
|
||||
it("generates unique operation IDs for all operations", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const operationIds: string[] = [];
|
||||
|
||||
for (const pathItem of Object.values(doc.paths ?? {})) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const op = (pathItem as Record<string, unknown>)?.[method] as
|
||||
| { operationId?: string }
|
||||
| undefined;
|
||||
if (op?.operationId) {
|
||||
operationIds.push(op.operationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content operations
|
||||
expect(operationIds).toContain("listContent");
|
||||
expect(operationIds).toContain("createContent");
|
||||
expect(operationIds).toContain("getContent");
|
||||
expect(operationIds).toContain("updateContent");
|
||||
expect(operationIds).toContain("deleteContent");
|
||||
expect(operationIds).toContain("publishContent");
|
||||
expect(operationIds).toContain("duplicateContent");
|
||||
|
||||
// Media operations
|
||||
expect(operationIds).toContain("listMedia");
|
||||
expect(operationIds).toContain("getMedia");
|
||||
expect(operationIds).toContain("deleteMedia");
|
||||
expect(operationIds).toContain("getMediaUploadUrl");
|
||||
|
||||
// Schema operations
|
||||
expect(operationIds).toContain("listCollections");
|
||||
expect(operationIds).toContain("createCollection");
|
||||
expect(operationIds).toContain("listFields");
|
||||
expect(operationIds).toContain("createField");
|
||||
|
||||
// Comments operations
|
||||
expect(operationIds).toContain("listPublicComments");
|
||||
expect(operationIds).toContain("createComment");
|
||||
expect(operationIds).toContain("listAdminComments");
|
||||
expect(operationIds).toContain("bulkCommentAction");
|
||||
|
||||
// Taxonomy operations
|
||||
expect(operationIds).toContain("listTaxonomies");
|
||||
expect(operationIds).toContain("listTerms");
|
||||
expect(operationIds).toContain("createTerm");
|
||||
|
||||
// Menu operations
|
||||
expect(operationIds).toContain("listMenus");
|
||||
expect(operationIds).toContain("createMenu");
|
||||
expect(operationIds).toContain("createMenuItem");
|
||||
|
||||
// Section operations
|
||||
expect(operationIds).toContain("listSections");
|
||||
expect(operationIds).toContain("createSection");
|
||||
|
||||
// Widget operations
|
||||
expect(operationIds).toContain("listWidgetAreas");
|
||||
expect(operationIds).toContain("createWidget");
|
||||
|
||||
// Settings operations
|
||||
expect(operationIds).toContain("getSettings");
|
||||
expect(operationIds).toContain("updateSettings");
|
||||
|
||||
// Search operations
|
||||
expect(operationIds).toContain("search");
|
||||
expect(operationIds).toContain("rebuildSearchIndex");
|
||||
|
||||
// Redirect operations
|
||||
expect(operationIds).toContain("listRedirects");
|
||||
expect(operationIds).toContain("createRedirect");
|
||||
expect(operationIds).toContain("listNotFoundEntries");
|
||||
|
||||
// User operations
|
||||
expect(operationIds).toContain("listUsers");
|
||||
expect(operationIds).toContain("getUser");
|
||||
expect(operationIds).toContain("disableUser");
|
||||
|
||||
// No duplicate operation IDs
|
||||
const uniqueIds = new Set(operationIds);
|
||||
expect(uniqueIds.size).toBe(operationIds.length);
|
||||
});
|
||||
|
||||
it("includes reusable component schemas", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const schemas = doc.components?.schemas ?? {};
|
||||
|
||||
// Content schemas
|
||||
expect(schemas).toHaveProperty("ContentCreateBody");
|
||||
expect(schemas).toHaveProperty("ContentUpdateBody");
|
||||
expect(schemas).toHaveProperty("ContentItem");
|
||||
expect(schemas).toHaveProperty("ContentResponse");
|
||||
expect(schemas).toHaveProperty("ContentListResponse");
|
||||
|
||||
// Media schemas
|
||||
expect(schemas).toHaveProperty("MediaItem");
|
||||
expect(schemas).toHaveProperty("MediaListResponse");
|
||||
|
||||
// Schema schemas
|
||||
expect(schemas).toHaveProperty("Collection");
|
||||
expect(schemas).toHaveProperty("CollectionListResponse");
|
||||
|
||||
// Comment schemas
|
||||
expect(schemas).toHaveProperty("PublicComment");
|
||||
expect(schemas).toHaveProperty("Comment");
|
||||
expect(schemas).toHaveProperty("CommentBulkBody");
|
||||
|
||||
// Taxonomy schemas
|
||||
expect(schemas).toHaveProperty("Term");
|
||||
expect(schemas).toHaveProperty("TermListResponse");
|
||||
|
||||
// Menu schemas
|
||||
expect(schemas).toHaveProperty("MenuWithItems");
|
||||
|
||||
// User schemas
|
||||
expect(schemas).toHaveProperty("User");
|
||||
expect(schemas).toHaveProperty("UserListResponse");
|
||||
});
|
||||
|
||||
it("wraps success responses in { data } envelope", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const listPath = doc.paths?.["/_emdash/api/content/{collection}"];
|
||||
const getResponse = (listPath as Record<string, unknown>)?.get as {
|
||||
responses: Record<string, { content: Record<string, { schema: Record<string, unknown> }> }>;
|
||||
};
|
||||
const schema = getResponse?.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||
|
||||
expect(schema).toBeDefined();
|
||||
// The envelope should have a "data" property
|
||||
expect(schema).toHaveProperty("properties");
|
||||
const props = (schema as Record<string, unknown>).properties as Record<string, unknown>;
|
||||
expect(props).toHaveProperty("data");
|
||||
});
|
||||
|
||||
it("includes error response schemas", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const listPath = doc.paths?.["/_emdash/api/content/{collection}"];
|
||||
const getOp = (listPath as Record<string, unknown>)?.get as {
|
||||
responses: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Should have auth error responses
|
||||
expect(getOp?.responses).toHaveProperty("401");
|
||||
expect(getOp?.responses).toHaveProperty("403");
|
||||
});
|
||||
|
||||
it("includes security schemes", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const schemes = doc.components?.securitySchemes;
|
||||
|
||||
expect(schemes).toHaveProperty("session");
|
||||
expect(schemes).toHaveProperty("bearer");
|
||||
});
|
||||
|
||||
it("tags all 12 domains", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const tagNames = (doc.tags ?? []).map((t: { name: string }) => t.name);
|
||||
|
||||
expect(tagNames).toContain("Content");
|
||||
expect(tagNames).toContain("Media");
|
||||
expect(tagNames).toContain("Schema");
|
||||
expect(tagNames).toContain("Comments");
|
||||
expect(tagNames).toContain("Taxonomies");
|
||||
expect(tagNames).toContain("Menus");
|
||||
expect(tagNames).toContain("Sections");
|
||||
expect(tagNames).toContain("Widgets");
|
||||
expect(tagNames).toContain("Settings");
|
||||
expect(tagNames).toContain("Search");
|
||||
expect(tagNames).toContain("Redirects");
|
||||
expect(tagNames).toContain("Users");
|
||||
expect(tagNames).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("produces valid JSON output", () => {
|
||||
const doc = generateOpenApiDocument();
|
||||
const json = JSON.stringify(doc);
|
||||
|
||||
// Should not throw
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.openapi).toBe("3.1.0");
|
||||
});
|
||||
});
|
||||
122
packages/core/tests/unit/api/ownership-extraction.test.ts
Normal file
122
packages/core/tests/unit/api/ownership-extraction.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for SEC-07: ownership extraction bugs (#12, #13, #14, #16)
|
||||
*
|
||||
* Verifies that handler response shapes carry authorId correctly
|
||||
* and that ownership-related operations work as expected.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
handleContentCreate,
|
||||
handleContentGet,
|
||||
handleContentGetIncludingTrashed,
|
||||
handleContentDelete,
|
||||
handleContentDuplicate,
|
||||
handleMediaCreate,
|
||||
} from "../../../src/api/index.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("SEC-07: Ownership extraction", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("#12: handleContentGet returns authorId inside data.item", () => {
|
||||
it("should expose authorId at data.item level, not data level", async () => {
|
||||
const created = await handleContentCreate(db, "post", {
|
||||
data: { title: "Owned Post" },
|
||||
authorId: "user_author_123",
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
|
||||
const result = await handleContentGet(db, "post", created.data!.item.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// The route pattern extracts: existing.data.item.authorId
|
||||
// If authorId were only on data (wrong), ownership checks would always fail
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const item = data.item as Record<string, unknown>;
|
||||
|
||||
expect(item.authorId).toBe("user_author_123");
|
||||
// data level should NOT have authorId directly
|
||||
expect(data.authorId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should expose authorId at data.item level for trashed items", async () => {
|
||||
const created = await handleContentCreate(db, "post", {
|
||||
data: { title: "Trashed Post" },
|
||||
authorId: "user_trash_owner",
|
||||
});
|
||||
expect(created.success).toBe(true);
|
||||
await handleContentDelete(db, "post", created.data!.item.id);
|
||||
|
||||
const result = await handleContentGetIncludingTrashed(db, "post", created.data!.item.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const item = data.item as Record<string, unknown>;
|
||||
|
||||
expect(item.authorId).toBe("user_trash_owner");
|
||||
expect(data.authorId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#14: handleContentDuplicate uses caller's authorId", () => {
|
||||
it("should set the duplicate's authorId to the provided caller ID", async () => {
|
||||
const original = await handleContentCreate(db, "post", {
|
||||
data: { title: "Original Post" },
|
||||
authorId: "original_author",
|
||||
});
|
||||
expect(original.success).toBe(true);
|
||||
|
||||
// Duplicate as a different user
|
||||
const dup = await handleContentDuplicate(db, "post", original.data!.item.id, "caller_user");
|
||||
expect(dup.success).toBe(true);
|
||||
expect(dup.data?.item.authorId).toBe("caller_user");
|
||||
});
|
||||
|
||||
it("should fall back to original authorId when caller ID not provided", async () => {
|
||||
const original = await handleContentCreate(db, "post", {
|
||||
data: { title: "Fallback Post" },
|
||||
authorId: "original_author",
|
||||
});
|
||||
expect(original.success).toBe(true);
|
||||
|
||||
const dup = await handleContentDuplicate(db, "post", original.data!.item.id);
|
||||
expect(dup.success).toBe(true);
|
||||
expect(dup.data?.item.authorId).toBe("original_author");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#16: handleMediaCreate persists authorId", () => {
|
||||
it("should store authorId on created media item", async () => {
|
||||
const result = await handleMediaCreate(db, {
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: "test_key_123.jpg",
|
||||
authorId: "media_uploader",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.authorId).toBe("media_uploader");
|
||||
});
|
||||
|
||||
it("should set authorId to null when not provided", async () => {
|
||||
const result = await handleMediaCreate(db, {
|
||||
filename: "orphan.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: "test_key_orphan.jpg",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.authorId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
35
packages/core/tests/unit/api/redirect.test.ts
Normal file
35
packages/core/tests/unit/api/redirect.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSafeRedirect } from "#api/redirect.js";
|
||||
|
||||
describe("isSafeRedirect", () => {
|
||||
it("accepts simple relative paths", () => {
|
||||
expect(isSafeRedirect("/")).toBe(true);
|
||||
expect(isSafeRedirect("/admin")).toBe(true);
|
||||
expect(isSafeRedirect("/_emdash/admin")).toBe(true);
|
||||
expect(isSafeRedirect("/foo/bar?baz=1")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects protocol-relative URLs (double slash)", () => {
|
||||
expect(isSafeRedirect("//evil.com")).toBe(false);
|
||||
expect(isSafeRedirect("//evil.com/path")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects backslash bypass (/\\evil.com normalizes to //evil.com)", () => {
|
||||
expect(isSafeRedirect("/\\evil.com")).toBe(false);
|
||||
expect(isSafeRedirect("/foo\\bar")).toBe(false);
|
||||
expect(isSafeRedirect("\\evil.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects URLs that do not start with /", () => {
|
||||
expect(isSafeRedirect("https://evil.com")).toBe(false);
|
||||
expect(isSafeRedirect("http://evil.com")).toBe(false);
|
||||
expect(isSafeRedirect("evil.com")).toBe(false);
|
||||
expect(isSafeRedirect("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects null and undefined", () => {
|
||||
expect(isSafeRedirect(null)).toBe(false);
|
||||
expect(isSafeRedirect(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
133
packages/core/tests/unit/api/rev.test.ts
Normal file
133
packages/core/tests/unit/api/rev.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Unit tests for _rev token generation and validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { encodeRev, decodeRev, validateRev } from "../../../src/api/rev.js";
|
||||
import type { ContentItem } from "../../../src/database/repositories/types.js";
|
||||
|
||||
function makeItem(overrides: Partial<ContentItem> = {}): ContentItem {
|
||||
return {
|
||||
id: "item_1",
|
||||
type: "posts",
|
||||
slug: "test",
|
||||
status: "draft",
|
||||
data: {},
|
||||
authorId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-15T12:30:00.000Z",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
version: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("encodeRev", () => {
|
||||
it("produces a base64-encoded string", () => {
|
||||
const item = makeItem();
|
||||
const rev = encodeRev(item);
|
||||
|
||||
expect(rev).toBeTruthy();
|
||||
// Should be valid base64
|
||||
expect(() => atob(rev)).not.toThrow();
|
||||
});
|
||||
|
||||
it("encodes version and updatedAt", () => {
|
||||
const item = makeItem({ version: 5, updatedAt: "2026-02-14T10:00:00.000Z" });
|
||||
const rev = encodeRev(item);
|
||||
const decoded = atob(rev);
|
||||
|
||||
expect(decoded).toBe("5:2026-02-14T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("produces different revs for different versions", () => {
|
||||
const rev1 = encodeRev(makeItem({ version: 1 }));
|
||||
const rev2 = encodeRev(makeItem({ version: 2 }));
|
||||
expect(rev1).not.toBe(rev2);
|
||||
});
|
||||
|
||||
it("produces different revs for different updatedAt", () => {
|
||||
const rev1 = encodeRev(makeItem({ updatedAt: "2026-01-01T00:00:00.000Z" }));
|
||||
const rev2 = encodeRev(makeItem({ updatedAt: "2026-01-02T00:00:00.000Z" }));
|
||||
expect(rev1).not.toBe(rev2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeRev", () => {
|
||||
it("decodes a valid rev", () => {
|
||||
const rev = btoa("5:2026-02-14T10:00:00.000Z");
|
||||
const result = decodeRev(rev);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.version).toBe(5);
|
||||
expect(result!.updatedAt).toBe("2026-02-14T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(decodeRev("not-valid-base64!!!")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing colon", () => {
|
||||
expect(decodeRev(btoa("nocolon"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-numeric version", () => {
|
||||
expect(decodeRev(btoa("abc:2026-01-01"))).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips with encodeRev", () => {
|
||||
const item = makeItem({ version: 7, updatedAt: "2026-03-01T08:15:30.000Z" });
|
||||
const rev = encodeRev(item);
|
||||
const decoded = decodeRev(rev);
|
||||
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded!.version).toBe(7);
|
||||
expect(decoded!.updatedAt).toBe("2026-03-01T08:15:30.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRev", () => {
|
||||
it("returns valid when no rev is provided", () => {
|
||||
const result = validateRev(undefined, makeItem());
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns valid when rev matches", () => {
|
||||
const item = makeItem({ version: 3, updatedAt: "2026-01-15T12:30:00.000Z" });
|
||||
const rev = encodeRev(item);
|
||||
|
||||
const result = validateRev(rev, item);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("returns invalid when version mismatches", () => {
|
||||
const item = makeItem({ version: 3, updatedAt: "2026-01-15T12:30:00.000Z" });
|
||||
const staleRev = btoa("2:2026-01-15T12:30:00.000Z"); // Version 2, but item is at 3
|
||||
|
||||
const result = validateRev(staleRev, item);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.message).toContain("modified");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns invalid when updatedAt mismatches", () => {
|
||||
const item = makeItem({ version: 3, updatedAt: "2026-01-15T12:30:00.000Z" });
|
||||
const staleRev = btoa("3:2026-01-14T00:00:00.000Z"); // Right version, wrong timestamp
|
||||
|
||||
const result = validateRev(staleRev, item);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("returns invalid for malformed rev", () => {
|
||||
const result = validateRev("garbage", makeItem());
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.message).toContain("Malformed");
|
||||
}
|
||||
});
|
||||
});
|
||||
230
packages/core/tests/unit/api/revision-handlers.test.ts
Normal file
230
packages/core/tests/unit/api/revision-handlers.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
handleRevisionList,
|
||||
handleRevisionGet,
|
||||
handleRevisionRestore,
|
||||
} from "../../../src/api/index.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import { RevisionRepository } from "../../../src/database/repositories/revision.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { createPostFixture } from "../../utils/fixtures.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("Revision Handlers", () => {
|
||||
let db: Kysely<Database>;
|
||||
let contentRepo: ContentRepository;
|
||||
let revisionRepo: RevisionRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
contentRepo = new ContentRepository(db);
|
||||
revisionRepo = new RevisionRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("handleRevisionList", () => {
|
||||
it("should return empty list when no revisions exist", async () => {
|
||||
const content = await contentRepo.create(createPostFixture());
|
||||
|
||||
const result = await handleRevisionList(db, "post", content.id, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items).toEqual([]);
|
||||
expect(result.data?.total).toBe(0);
|
||||
});
|
||||
|
||||
it("should return revisions for a content entry", async () => {
|
||||
const content = await contentRepo.create(createPostFixture());
|
||||
|
||||
// Create some revisions with small delay to ensure distinct ULIDs
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "Version 1", content: "First version" },
|
||||
});
|
||||
// Small delay to ensure ULID timestamp differs
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "Version 2", content: "Second version" },
|
||||
});
|
||||
|
||||
const result = await handleRevisionList(db, "post", content.id, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items).toHaveLength(2);
|
||||
expect(result.data?.total).toBe(2);
|
||||
// Should be newest first
|
||||
expect(result.data?.items[0].data.title).toBe("Version 2");
|
||||
expect(result.data?.items[1].data.title).toBe("Version 1");
|
||||
});
|
||||
|
||||
it("should respect limit parameter", async () => {
|
||||
const content = await contentRepo.create(createPostFixture());
|
||||
|
||||
// Create 5 revisions
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: `Version ${i}` },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await handleRevisionList(db, "post", content.id, {
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items).toHaveLength(3);
|
||||
expect(result.data?.total).toBe(5); // Total still reflects all revisions
|
||||
});
|
||||
|
||||
it("should not return revisions from other entries", async () => {
|
||||
const content1 = await contentRepo.create(createPostFixture());
|
||||
const content2 = await contentRepo.create({
|
||||
...createPostFixture(),
|
||||
slug: "another-post",
|
||||
});
|
||||
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content1.id,
|
||||
data: { title: "Content 1 revision" },
|
||||
});
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content2.id,
|
||||
data: { title: "Content 2 revision" },
|
||||
});
|
||||
|
||||
const result = await handleRevisionList(db, "post", content1.id, {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.items).toHaveLength(1);
|
||||
expect(result.data?.items[0].data.title).toBe("Content 1 revision");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRevisionGet", () => {
|
||||
it("should return a revision by ID", async () => {
|
||||
const content = await contentRepo.create(createPostFixture());
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "Test Revision" },
|
||||
});
|
||||
|
||||
const result = await handleRevisionGet(db, revision.id);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.id).toBe(revision.id);
|
||||
expect(result.data?.item.data.title).toBe("Test Revision");
|
||||
});
|
||||
|
||||
it("should return NOT_FOUND for non-existent revision", async () => {
|
||||
const result = await handleRevisionGet(db, "nonexistent-id");
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleRevisionRestore", () => {
|
||||
const callerUserId = "user_caller_123";
|
||||
|
||||
it("should restore content to a previous revision", async () => {
|
||||
const content = await contentRepo.create({
|
||||
...createPostFixture(),
|
||||
data: { title: "Original", content: "Original content" },
|
||||
});
|
||||
|
||||
// Create a revision with the original state
|
||||
const originalRevision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "Original", content: "Original content" },
|
||||
});
|
||||
|
||||
// Update the content
|
||||
await contentRepo.update("post", content.id, {
|
||||
data: { title: "Updated", content: "Updated content" },
|
||||
});
|
||||
|
||||
// Restore to original revision
|
||||
const result = await handleRevisionRestore(db, originalRevision.id, callerUserId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.data.title).toBe("Original");
|
||||
expect(result.data?.item.data.content).toBe("Original content");
|
||||
});
|
||||
|
||||
it("should create a new revision when restoring", async () => {
|
||||
const content = await contentRepo.create(createPostFixture());
|
||||
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "To restore" },
|
||||
});
|
||||
|
||||
const beforeCount = await revisionRepo.countByEntry("post", content.id);
|
||||
|
||||
await handleRevisionRestore(db, revision.id, callerUserId);
|
||||
|
||||
const afterCount = await revisionRepo.countByEntry("post", content.id);
|
||||
expect(afterCount).toBe(beforeCount + 1);
|
||||
});
|
||||
|
||||
it("should attribute the new revision to the caller", async () => {
|
||||
const content = await contentRepo.create(createPostFixture());
|
||||
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "To restore" },
|
||||
authorId: "original_author",
|
||||
});
|
||||
|
||||
await handleRevisionRestore(db, revision.id, callerUserId);
|
||||
|
||||
// The newest revision (restore record) should be attributed to the caller
|
||||
const latestRevision = await revisionRepo.findLatest("post", content.id);
|
||||
expect(latestRevision).not.toBeNull();
|
||||
expect(latestRevision!.authorId).toBe(callerUserId);
|
||||
});
|
||||
|
||||
it("should handle revision data containing _slug", async () => {
|
||||
const content = await contentRepo.create({
|
||||
...createPostFixture(),
|
||||
data: { title: "Original" },
|
||||
});
|
||||
|
||||
// Revision data includes _slug (added by runtime when slug changes)
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: content.id,
|
||||
data: { title: "With slug change", _slug: "new-slug" },
|
||||
});
|
||||
|
||||
const result = await handleRevisionRestore(db, revision.id, callerUserId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.item.data.title).toBe("With slug change");
|
||||
expect(result.data?.item.slug).toBe("new-slug");
|
||||
});
|
||||
|
||||
it("should return NOT_FOUND for non-existent revision", async () => {
|
||||
const result = await handleRevisionRestore(db, "nonexistent-id", callerUserId);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.code).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/core/tests/unit/api/schemas.test.ts
Normal file
56
packages/core/tests/unit/api/schemas.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { contentUpdateBody, httpUrl } from "../../../src/api/schemas/index.js";
|
||||
|
||||
describe("contentUpdateBody schema", () => {
|
||||
it("should pass through skipRevision when present", () => {
|
||||
const input = {
|
||||
data: { title: "Hello" },
|
||||
skipRevision: true,
|
||||
};
|
||||
const result = contentUpdateBody.parse(input);
|
||||
expect(result.skipRevision).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept updates without skipRevision", () => {
|
||||
const input = {
|
||||
data: { title: "Hello" },
|
||||
};
|
||||
const result = contentUpdateBody.parse(input);
|
||||
expect(result.skipRevision).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpUrl validator", () => {
|
||||
it("accepts http URLs", () => {
|
||||
expect(httpUrl.parse("http://example.com")).toBe("http://example.com");
|
||||
});
|
||||
|
||||
it("accepts https URLs", () => {
|
||||
expect(httpUrl.parse("https://example.com/path?q=1")).toBe("https://example.com/path?q=1");
|
||||
});
|
||||
|
||||
it("rejects javascript: URIs", () => {
|
||||
expect(() => httpUrl.parse("javascript:alert(1)")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects data: URIs", () => {
|
||||
expect(() => httpUrl.parse("data:text/html,<script>alert(1)</script>")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects ftp: URIs", () => {
|
||||
expect(() => httpUrl.parse("ftp://example.com")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty string", () => {
|
||||
expect(() => httpUrl.parse("")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-URL strings", () => {
|
||||
expect(() => httpUrl.parse("not a url")).toThrow();
|
||||
});
|
||||
|
||||
it("is case-insensitive for scheme", () => {
|
||||
expect(httpUrl.parse("HTTPS://EXAMPLE.COM")).toBe("HTTPS://EXAMPLE.COM");
|
||||
});
|
||||
});
|
||||
308
packages/core/tests/unit/auth/allowed-domains.test.ts
Normal file
308
packages/core/tests/unit/auth/allowed-domains.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import type { AuthAdapter } from "@emdashcms/auth";
|
||||
import { Role } from "@emdashcms/auth";
|
||||
import { createKyselyAdapter } from "@emdashcms/auth/adapters/kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("Allowed Domains Management", () => {
|
||||
let db: Kysely<Database>;
|
||||
let adapter: AuthAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
adapter = createKyselyAdapter(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("getAllowedDomains", () => {
|
||||
it("should return empty array when no domains exist", async () => {
|
||||
const domains = await adapter.getAllowedDomains();
|
||||
expect(domains).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all allowed domains", async () => {
|
||||
await adapter.createAllowedDomain("acme.com", Role.AUTHOR);
|
||||
await adapter.createAllowedDomain("partner.org", Role.CONTRIBUTOR);
|
||||
await adapter.createAllowedDomain("editors.net", Role.EDITOR);
|
||||
|
||||
const domains = await adapter.getAllowedDomains();
|
||||
|
||||
expect(domains).toHaveLength(3);
|
||||
const domainNames = domains.map((d) => d.domain);
|
||||
expect(domainNames).toContain("acme.com");
|
||||
expect(domainNames).toContain("partner.org");
|
||||
expect(domainNames).toContain("editors.net");
|
||||
});
|
||||
|
||||
it("should include both enabled and disabled domains", async () => {
|
||||
await adapter.createAllowedDomain("enabled.com", Role.AUTHOR);
|
||||
await adapter.createAllowedDomain("disabled.com", Role.AUTHOR);
|
||||
await adapter.updateAllowedDomain("disabled.com", false);
|
||||
|
||||
const domains = await adapter.getAllowedDomains();
|
||||
|
||||
expect(domains).toHaveLength(2);
|
||||
const enabled = domains.find((d) => d.domain === "enabled.com");
|
||||
const disabled = domains.find((d) => d.domain === "disabled.com");
|
||||
|
||||
expect(enabled?.enabled).toBe(true);
|
||||
expect(disabled?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllowedDomain", () => {
|
||||
it("should return null for non-existent domain", async () => {
|
||||
const domain = await adapter.getAllowedDomain("nonexistent.com");
|
||||
expect(domain).toBeNull();
|
||||
});
|
||||
|
||||
it("should return domain with all properties", async () => {
|
||||
await adapter.createAllowedDomain("example.com", Role.EDITOR);
|
||||
|
||||
const domain = await adapter.getAllowedDomain("example.com");
|
||||
|
||||
expect(domain).not.toBeNull();
|
||||
expect(domain?.domain).toBe("example.com");
|
||||
expect(domain?.defaultRole).toBe(Role.EDITOR);
|
||||
expect(domain?.enabled).toBe(true);
|
||||
expect(domain?.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should be case-insensitive for domain lookup (normalizes to lowercase)", async () => {
|
||||
await adapter.createAllowedDomain("example.com", Role.AUTHOR);
|
||||
|
||||
// Lowercase should work
|
||||
const lower = await adapter.getAllowedDomain("example.com");
|
||||
expect(lower).not.toBeNull();
|
||||
|
||||
// Uppercase should also work (domains are normalized to lowercase)
|
||||
const upper = await adapter.getAllowedDomain("EXAMPLE.COM");
|
||||
expect(upper).not.toBeNull();
|
||||
expect(upper?.domain).toBe("example.com"); // stored as lowercase
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAllowedDomain", () => {
|
||||
it("should create a new allowed domain", async () => {
|
||||
const domain = await adapter.createAllowedDomain("newdomain.com", Role.AUTHOR);
|
||||
|
||||
expect(domain.domain).toBe("newdomain.com");
|
||||
expect(domain.defaultRole).toBe(Role.AUTHOR);
|
||||
expect(domain.enabled).toBe(true);
|
||||
expect(domain.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should create domain with specified role", async () => {
|
||||
await adapter.createAllowedDomain("subscribers.com", Role.SUBSCRIBER);
|
||||
await adapter.createAllowedDomain("contributors.com", Role.CONTRIBUTOR);
|
||||
await adapter.createAllowedDomain("authors.com", Role.AUTHOR);
|
||||
await adapter.createAllowedDomain("editors.com", Role.EDITOR);
|
||||
await adapter.createAllowedDomain("admins.com", Role.ADMIN);
|
||||
|
||||
expect((await adapter.getAllowedDomain("subscribers.com"))?.defaultRole).toBe(
|
||||
Role.SUBSCRIBER,
|
||||
);
|
||||
expect((await adapter.getAllowedDomain("contributors.com"))?.defaultRole).toBe(
|
||||
Role.CONTRIBUTOR,
|
||||
);
|
||||
expect((await adapter.getAllowedDomain("authors.com"))?.defaultRole).toBe(Role.AUTHOR);
|
||||
expect((await adapter.getAllowedDomain("editors.com"))?.defaultRole).toBe(Role.EDITOR);
|
||||
expect((await adapter.getAllowedDomain("admins.com"))?.defaultRole).toBe(Role.ADMIN);
|
||||
});
|
||||
|
||||
it("should throw error for duplicate domain", async () => {
|
||||
await adapter.createAllowedDomain("duplicate.com", Role.AUTHOR);
|
||||
|
||||
await expect(adapter.createAllowedDomain("duplicate.com", Role.EDITOR)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should set enabled to true by default", async () => {
|
||||
const domain = await adapter.createAllowedDomain("enabled-default.com", Role.AUTHOR);
|
||||
expect(domain.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAllowedDomain", () => {
|
||||
it("should toggle domain enabled status", async () => {
|
||||
await adapter.createAllowedDomain("toggle.com", Role.AUTHOR);
|
||||
|
||||
// Disable
|
||||
await adapter.updateAllowedDomain("toggle.com", false);
|
||||
let domain = await adapter.getAllowedDomain("toggle.com");
|
||||
expect(domain?.enabled).toBe(false);
|
||||
|
||||
// Re-enable
|
||||
await adapter.updateAllowedDomain("toggle.com", true);
|
||||
domain = await adapter.getAllowedDomain("toggle.com");
|
||||
expect(domain?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should update default role", async () => {
|
||||
await adapter.createAllowedDomain("role-change.com", Role.AUTHOR);
|
||||
|
||||
await adapter.updateAllowedDomain("role-change.com", true, Role.EDITOR);
|
||||
|
||||
const domain = await adapter.getAllowedDomain("role-change.com");
|
||||
expect(domain?.defaultRole).toBe(Role.EDITOR);
|
||||
});
|
||||
|
||||
it("should update both enabled and role at once", async () => {
|
||||
await adapter.createAllowedDomain("both.com", Role.AUTHOR);
|
||||
|
||||
await adapter.updateAllowedDomain("both.com", false, Role.CONTRIBUTOR);
|
||||
|
||||
const domain = await adapter.getAllowedDomain("both.com");
|
||||
expect(domain?.enabled).toBe(false);
|
||||
expect(domain?.defaultRole).toBe(Role.CONTRIBUTOR);
|
||||
});
|
||||
|
||||
it("should preserve role when only updating enabled", async () => {
|
||||
await adapter.createAllowedDomain("preserve.com", Role.EDITOR);
|
||||
|
||||
await adapter.updateAllowedDomain("preserve.com", false);
|
||||
|
||||
const domain = await adapter.getAllowedDomain("preserve.com");
|
||||
expect(domain?.enabled).toBe(false);
|
||||
expect(domain?.defaultRole).toBe(Role.EDITOR);
|
||||
});
|
||||
|
||||
it("should preserve createdAt when updating", async () => {
|
||||
const created = await adapter.createAllowedDomain("timestamp.com", Role.AUTHOR);
|
||||
const originalCreatedAt = created.createdAt;
|
||||
|
||||
// Small delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await adapter.updateAllowedDomain("timestamp.com", false, Role.EDITOR);
|
||||
|
||||
const updated = await adapter.getAllowedDomain("timestamp.com");
|
||||
expect(updated?.createdAt.getTime()).toBe(originalCreatedAt.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAllowedDomain", () => {
|
||||
it("should delete an existing domain", async () => {
|
||||
await adapter.createAllowedDomain("todelete.com", Role.AUTHOR);
|
||||
|
||||
await adapter.deleteAllowedDomain("todelete.com");
|
||||
|
||||
const domain = await adapter.getAllowedDomain("todelete.com");
|
||||
expect(domain).toBeNull();
|
||||
});
|
||||
|
||||
it("should not affect other domains", async () => {
|
||||
await adapter.createAllowedDomain("keep.com", Role.AUTHOR);
|
||||
await adapter.createAllowedDomain("delete.com", Role.AUTHOR);
|
||||
|
||||
await adapter.deleteAllowedDomain("delete.com");
|
||||
|
||||
const kept = await adapter.getAllowedDomain("keep.com");
|
||||
const deleted = await adapter.getAllowedDomain("delete.com");
|
||||
|
||||
expect(kept).not.toBeNull();
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it("should be idempotent (no error on non-existent)", async () => {
|
||||
// Deleting non-existent domain should not throw
|
||||
await expect(adapter.deleteAllowedDomain("nonexistent.com")).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Domain Management Flow", () => {
|
||||
it("should support full CRUD flow", async () => {
|
||||
// Create
|
||||
const created = await adapter.createAllowedDomain("company.com", Role.AUTHOR);
|
||||
expect(created.domain).toBe("company.com");
|
||||
expect(created.enabled).toBe(true);
|
||||
|
||||
// Read
|
||||
let domain = await adapter.getAllowedDomain("company.com");
|
||||
expect(domain?.domain).toBe("company.com");
|
||||
|
||||
// Update - change role
|
||||
await adapter.updateAllowedDomain("company.com", true, Role.EDITOR);
|
||||
domain = await adapter.getAllowedDomain("company.com");
|
||||
expect(domain?.defaultRole).toBe(Role.EDITOR);
|
||||
|
||||
// Update - disable
|
||||
await adapter.updateAllowedDomain("company.com", false);
|
||||
domain = await adapter.getAllowedDomain("company.com");
|
||||
expect(domain?.enabled).toBe(false);
|
||||
|
||||
// List
|
||||
const all = await adapter.getAllowedDomains();
|
||||
expect(all).toHaveLength(1);
|
||||
|
||||
// Delete
|
||||
await adapter.deleteAllowedDomain("company.com");
|
||||
domain = await adapter.getAllowedDomain("company.com");
|
||||
expect(domain).toBeNull();
|
||||
|
||||
// List after delete
|
||||
const afterDelete = await adapter.getAllowedDomains();
|
||||
expect(afterDelete).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should handle multiple domains correctly", async () => {
|
||||
// Create multiple domains
|
||||
await adapter.createAllowedDomain("first.com", Role.SUBSCRIBER);
|
||||
await adapter.createAllowedDomain("second.com", Role.CONTRIBUTOR);
|
||||
await adapter.createAllowedDomain("third.com", Role.AUTHOR);
|
||||
|
||||
// Verify all exist
|
||||
let domains = await adapter.getAllowedDomains();
|
||||
expect(domains).toHaveLength(3);
|
||||
|
||||
// Disable one
|
||||
await adapter.updateAllowedDomain("second.com", false);
|
||||
|
||||
// Delete another
|
||||
await adapter.deleteAllowedDomain("first.com");
|
||||
|
||||
// Verify state
|
||||
domains = await adapter.getAllowedDomains();
|
||||
expect(domains).toHaveLength(2);
|
||||
|
||||
const second = domains.find((d) => d.domain === "second.com");
|
||||
const third = domains.find((d) => d.domain === "third.com");
|
||||
|
||||
expect(second?.enabled).toBe(false);
|
||||
expect(third?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle subdomains correctly", async () => {
|
||||
await adapter.createAllowedDomain("sub.domain.com", Role.AUTHOR);
|
||||
|
||||
const domain = await adapter.getAllowedDomain("sub.domain.com");
|
||||
expect(domain).not.toBeNull();
|
||||
|
||||
// Parent domain should not match
|
||||
const parent = await adapter.getAllowedDomain("domain.com");
|
||||
expect(parent).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle domains with hyphens", async () => {
|
||||
await adapter.createAllowedDomain("my-company.com", Role.AUTHOR);
|
||||
|
||||
const domain = await adapter.getAllowedDomain("my-company.com");
|
||||
expect(domain?.domain).toBe("my-company.com");
|
||||
});
|
||||
|
||||
it("should handle long domain names", async () => {
|
||||
const longDomain = "very-long-subdomain.another-part.yet-another.example.com";
|
||||
await adapter.createAllowedDomain(longDomain, Role.AUTHOR);
|
||||
|
||||
const domain = await adapter.getAllowedDomain(longDomain);
|
||||
expect(domain?.domain).toBe(longDomain);
|
||||
});
|
||||
});
|
||||
});
|
||||
224
packages/core/tests/unit/auth/api-tokens.test.ts
Normal file
224
packages/core/tests/unit/auth/api-tokens.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Unit tests for API token generation, hashing, and scope utilities.
|
||||
*/
|
||||
|
||||
import { Role, scopesForRole, clampScopes } from "@emdashcms/auth";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
generatePrefixedToken,
|
||||
hashApiToken,
|
||||
validateScopes,
|
||||
hasScope,
|
||||
TOKEN_PREFIXES,
|
||||
VALID_SCOPES,
|
||||
} from "../../../src/auth/api-tokens.js";
|
||||
|
||||
// Regex patterns for token validation
|
||||
const PAT_PREFIX_REGEX = /^ec_pat_/;
|
||||
const OAUTH_ACCESS_PREFIX_REGEX = /^ec_oat_/;
|
||||
const OAUTH_REFRESH_PREFIX_REGEX = /^ec_ort_/;
|
||||
const BASE64URL_INVALID_CHARS_REGEX = /[+/=]/;
|
||||
const BASE64URL_VALID_REGEX = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
describe("generatePrefixedToken", () => {
|
||||
it("generates a PAT with ec_pat_ prefix", () => {
|
||||
const { raw, hash, prefix } = generatePrefixedToken(TOKEN_PREFIXES.PAT);
|
||||
|
||||
expect(raw).toMatch(PAT_PREFIX_REGEX);
|
||||
expect(raw.length).toBeGreaterThan(20);
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash).not.toBe(raw);
|
||||
expect(prefix).toMatch(PAT_PREFIX_REGEX);
|
||||
expect(prefix.length).toBe(TOKEN_PREFIXES.PAT.length + 4);
|
||||
});
|
||||
|
||||
it("generates an OAuth access token with ec_oat_ prefix", () => {
|
||||
const { raw } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
|
||||
expect(raw).toMatch(OAUTH_ACCESS_PREFIX_REGEX);
|
||||
});
|
||||
|
||||
it("generates an OAuth refresh token with ec_ort_ prefix", () => {
|
||||
const { raw } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_REFRESH);
|
||||
expect(raw).toMatch(OAUTH_REFRESH_PREFIX_REGEX);
|
||||
});
|
||||
|
||||
it("generates unique tokens each time", () => {
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { raw } = generatePrefixedToken("ec_pat_");
|
||||
tokens.add(raw);
|
||||
}
|
||||
expect(tokens.size).toBe(50);
|
||||
});
|
||||
|
||||
it("generates unique hashes for different tokens", () => {
|
||||
const { hash: hash1 } = generatePrefixedToken("ec_pat_");
|
||||
const { hash: hash2 } = generatePrefixedToken("ec_pat_");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashApiToken", () => {
|
||||
it("produces a deterministic hash", () => {
|
||||
const hash1 = hashApiToken("ec_pat_abc123");
|
||||
const hash2 = hashApiToken("ec_pat_abc123");
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
it("produces different hashes for different tokens", () => {
|
||||
const hash1 = hashApiToken("ec_pat_abc123");
|
||||
const hash2 = hashApiToken("ec_pat_def456");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("hashes the full prefixed token", () => {
|
||||
// Same suffix but different prefix should produce different hashes
|
||||
const hash1 = hashApiToken("ec_pat_abc123");
|
||||
const hash2 = hashApiToken("ec_oat_abc123");
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("produces URL-safe base64 output", () => {
|
||||
const hash = hashApiToken("ec_pat_test");
|
||||
// Should not contain +, /, or = (standard base64 chars)
|
||||
expect(hash).not.toMatch(BASE64URL_INVALID_CHARS_REGEX);
|
||||
// Should only contain base64url chars
|
||||
expect(hash).toMatch(BASE64URL_VALID_REGEX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateScopes", () => {
|
||||
it("returns empty array for valid scopes", () => {
|
||||
const invalid = validateScopes(["content:read", "media:write"]);
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns invalid scopes", () => {
|
||||
const invalid = validateScopes(["content:read", "invalid:scope", "admin"]);
|
||||
expect(invalid).toEqual(["invalid:scope"]);
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
expect(validateScopes([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts all valid scopes", () => {
|
||||
const invalid = validateScopes([...VALID_SCOPES]);
|
||||
expect(invalid).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasScope", () => {
|
||||
it("returns true when scope is present", () => {
|
||||
expect(hasScope(["content:read", "media:write"], "content:read")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when scope is missing", () => {
|
||||
expect(hasScope(["content:read"], "content:write")).toBe(false);
|
||||
});
|
||||
|
||||
it("admin scope grants access to everything", () => {
|
||||
expect(hasScope(["admin"], "content:read")).toBe(true);
|
||||
expect(hasScope(["admin"], "schema:write")).toBe(true);
|
||||
expect(hasScope(["admin"], "media:write")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles empty scopes", () => {
|
||||
expect(hasScope([], "content:read")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scopesForRole — maps roles to maximum allowed scopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("scopesForRole", () => {
|
||||
it("SUBSCRIBER gets only read scopes for content and media", () => {
|
||||
const scopes = scopesForRole(Role.SUBSCRIBER);
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("media:read");
|
||||
expect(scopes).not.toContain("content:write");
|
||||
expect(scopes).not.toContain("media:write");
|
||||
expect(scopes).not.toContain("schema:read");
|
||||
expect(scopes).not.toContain("schema:write");
|
||||
expect(scopes).not.toContain("admin");
|
||||
});
|
||||
|
||||
it("CONTRIBUTOR gets content and media read/write", () => {
|
||||
const scopes = scopesForRole(Role.CONTRIBUTOR);
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("content:write");
|
||||
expect(scopes).toContain("media:read");
|
||||
expect(scopes).toContain("media:write");
|
||||
expect(scopes).not.toContain("schema:read");
|
||||
expect(scopes).not.toContain("schema:write");
|
||||
expect(scopes).not.toContain("admin");
|
||||
});
|
||||
|
||||
it("EDITOR gets content, media, and schema:read", () => {
|
||||
const scopes = scopesForRole(Role.EDITOR);
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("content:write");
|
||||
expect(scopes).toContain("media:read");
|
||||
expect(scopes).toContain("media:write");
|
||||
expect(scopes).toContain("schema:read");
|
||||
expect(scopes).not.toContain("schema:write");
|
||||
expect(scopes).not.toContain("admin");
|
||||
});
|
||||
|
||||
it("ADMIN gets all scopes including admin and schema:write", () => {
|
||||
const scopes = scopesForRole(Role.ADMIN);
|
||||
expect(scopes).toContain("content:read");
|
||||
expect(scopes).toContain("content:write");
|
||||
expect(scopes).toContain("media:read");
|
||||
expect(scopes).toContain("media:write");
|
||||
expect(scopes).toContain("schema:read");
|
||||
expect(scopes).toContain("schema:write");
|
||||
expect(scopes).toContain("admin");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clampScopes — intersects requested scopes with role-allowed scopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("clampScopes", () => {
|
||||
it("strips admin scope from non-admin role", () => {
|
||||
const result = clampScopes(["content:read", "admin"], Role.CONTRIBUTOR);
|
||||
expect(result).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("strips schema:write from editor role", () => {
|
||||
const result = clampScopes(["schema:read", "schema:write"], Role.EDITOR);
|
||||
expect(result).toEqual(["schema:read"]);
|
||||
});
|
||||
|
||||
it("preserves all scopes for admin role", () => {
|
||||
const all = [
|
||||
"content:read",
|
||||
"content:write",
|
||||
"media:read",
|
||||
"media:write",
|
||||
"schema:read",
|
||||
"schema:write",
|
||||
"admin",
|
||||
];
|
||||
const result = clampScopes(all, Role.ADMIN);
|
||||
expect(result).toEqual(all);
|
||||
});
|
||||
|
||||
it("returns empty array when no scopes survive clamping", () => {
|
||||
const result = clampScopes(["admin", "schema:write"], Role.SUBSCRIBER);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(clampScopes([], Role.ADMIN)).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips schema:read from contributor role", () => {
|
||||
const result = clampScopes(["content:read", "schema:read"], Role.CONTRIBUTOR);
|
||||
expect(result).toEqual(["content:read"]);
|
||||
});
|
||||
});
|
||||
214
packages/core/tests/unit/auth/challenge-store.test.ts
Normal file
214
packages/core/tests/unit/auth/challenge-store.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
createChallengeStore,
|
||||
cleanupExpiredChallenges,
|
||||
} from "../../../src/auth/challenge-store.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("ChallengeStore", () => {
|
||||
let db: Kysely<Database>;
|
||||
let store: ReturnType<typeof createChallengeStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
store = createChallengeStore(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("set()", () => {
|
||||
it("stores challenge with expiry", async () => {
|
||||
const challenge = "test-challenge-123";
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "registration",
|
||||
userId: "user-1",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const result = await store.get(challenge);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe("registration");
|
||||
expect(result?.userId).toBe("user-1");
|
||||
expect(result?.expiresAt).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it("stores challenge without userId", async () => {
|
||||
const challenge = "auth-challenge-456";
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000;
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "authentication",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const result = await store.get(challenge);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe("authentication");
|
||||
expect(result?.userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates existing challenge on conflict", async () => {
|
||||
const challenge = "update-test";
|
||||
const expiresAt1 = Date.now() + 5 * 60 * 1000;
|
||||
const expiresAt2 = Date.now() + 10 * 60 * 1000;
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "registration",
|
||||
userId: "user-1",
|
||||
expiresAt: expiresAt1,
|
||||
});
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "authentication",
|
||||
userId: "user-2",
|
||||
expiresAt: expiresAt2,
|
||||
});
|
||||
|
||||
const result = await store.get(challenge);
|
||||
expect(result?.type).toBe("authentication");
|
||||
expect(result?.userId).toBe("user-2");
|
||||
expect(result?.expiresAt).toBe(expiresAt2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get()", () => {
|
||||
it("returns stored challenge", async () => {
|
||||
const challenge = "get-test";
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000;
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "registration",
|
||||
userId: "user-abc",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const result = await store.get(challenge);
|
||||
expect(result).toEqual({
|
||||
type: "registration",
|
||||
userId: "user-abc",
|
||||
expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-existent challenge", async () => {
|
||||
const result = await store.get("does-not-exist");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for expired challenges and deletes them", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const challenge = "expired-test";
|
||||
const expiresAt = Date.now() + 60 * 1000; // 1 minute
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "registration",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Advance time past expiry
|
||||
vi.advanceTimersByTime(61 * 1000);
|
||||
|
||||
const result = await store.get(challenge);
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Verify it was deleted
|
||||
vi.useRealTimers();
|
||||
const afterDelete = await db
|
||||
.selectFrom("auth_challenges")
|
||||
.selectAll()
|
||||
.where("challenge", "=", challenge)
|
||||
.executeTakeFirst();
|
||||
expect(afterDelete).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete()", () => {
|
||||
it("removes challenge", async () => {
|
||||
const challenge = "delete-test";
|
||||
const expiresAt = Date.now() + 5 * 60 * 1000;
|
||||
|
||||
await store.set(challenge, {
|
||||
type: "authentication",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Verify it exists
|
||||
const before = await store.get(challenge);
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Delete it
|
||||
await store.delete(challenge);
|
||||
|
||||
// Verify it's gone
|
||||
const after = await store.get(challenge);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw when deleting non-existent challenge", async () => {
|
||||
await expect(store.delete("non-existent")).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupExpiredChallenges()", () => {
|
||||
it("removes only expired entries", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Create some challenges with different expiry times
|
||||
await store.set("expired-1", {
|
||||
type: "registration",
|
||||
expiresAt: now + 30 * 1000, // expires in 30s
|
||||
});
|
||||
await store.set("expired-2", {
|
||||
type: "authentication",
|
||||
expiresAt: now + 60 * 1000, // expires in 60s
|
||||
});
|
||||
await store.set("valid-1", {
|
||||
type: "registration",
|
||||
expiresAt: now + 5 * 60 * 1000, // expires in 5 minutes
|
||||
});
|
||||
await store.set("valid-2", {
|
||||
type: "authentication",
|
||||
expiresAt: now + 10 * 60 * 1000, // expires in 10 minutes
|
||||
});
|
||||
|
||||
// Advance time by 90 seconds (past first two, but not last two)
|
||||
vi.advanceTimersByTime(90 * 1000);
|
||||
|
||||
const deleted = await cleanupExpiredChallenges(db);
|
||||
expect(deleted).toBe(2);
|
||||
|
||||
// Verify only valid ones remain
|
||||
vi.useRealTimers();
|
||||
const remaining = await db.selectFrom("auth_challenges").select("challenge").execute();
|
||||
|
||||
expect(remaining.map((r) => r.challenge).toSorted()).toEqual(["valid-1", "valid-2"]);
|
||||
});
|
||||
|
||||
it("returns 0 when no expired challenges", async () => {
|
||||
const expiresAt = Date.now() + 10 * 60 * 1000;
|
||||
|
||||
await store.set("valid", {
|
||||
type: "registration",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const deleted = await cleanupExpiredChallenges(db);
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
|
||||
it("handles empty table", async () => {
|
||||
const deleted = await cleanupExpiredChallenges(db);
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
117
packages/core/tests/unit/auth/discovery-endpoints.test.ts
Normal file
117
packages/core/tests/unit/auth/discovery-endpoints.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Unit tests for OAuth discovery endpoint response shapes.
|
||||
*
|
||||
* These endpoints are public, unauthenticated, and return JSON metadata
|
||||
* that MCP clients use to discover OAuth endpoints. The response shapes
|
||||
* are contractual — changing them breaks MCP client compatibility.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { GET as getAuthorizationServer } from "../../../src/astro/routes/api/well-known/oauth-authorization-server.js";
|
||||
// We import the GET handlers directly — they're plain functions that take
|
||||
// an Astro-like context and return a Response.
|
||||
import { GET as getProtectedResource } from "../../../src/astro/routes/api/well-known/oauth-protected-resource.js";
|
||||
import { VALID_SCOPES } from "../../../src/auth/api-tokens.js";
|
||||
|
||||
/** Minimal mock of what the route handlers actually use from the Astro context. */
|
||||
function mockContext(origin = "https://example.com") {
|
||||
return { url: new URL("/.well-known/test", origin) } as Parameters<
|
||||
typeof getProtectedResource
|
||||
>[0];
|
||||
}
|
||||
|
||||
describe("Protected Resource Metadata (RFC 9728)", () => {
|
||||
it("returns correct resource and authorization_servers", async () => {
|
||||
const response = await getProtectedResource(mockContext());
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
expect(body.resource).toBe("https://example.com/_emdash/api/mcp");
|
||||
expect(body.authorization_servers).toEqual(["https://example.com/_emdash"]);
|
||||
});
|
||||
|
||||
it("includes all valid scopes", async () => {
|
||||
const response = await getProtectedResource(mockContext());
|
||||
const body = (await response.json()) as { scopes_supported: string[] };
|
||||
expect(body.scopes_supported).toEqual([...VALID_SCOPES]);
|
||||
});
|
||||
|
||||
it("advertises header-based bearer method", async () => {
|
||||
const response = await getProtectedResource(mockContext());
|
||||
const body = (await response.json()) as { bearer_methods_supported: string[] };
|
||||
expect(body.bearer_methods_supported).toEqual(["header"]);
|
||||
});
|
||||
|
||||
it("sets CORS and cache headers", async () => {
|
||||
const response = await getProtectedResource(mockContext());
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Cache-Control")).toContain("public");
|
||||
});
|
||||
|
||||
it("uses the request origin for URLs", async () => {
|
||||
const response = await getProtectedResource(mockContext("https://cms.mysite.com"));
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
expect(body.resource).toBe("https://cms.mysite.com/_emdash/api/mcp");
|
||||
expect(body.authorization_servers).toEqual(["https://cms.mysite.com/_emdash"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization Server Metadata (RFC 8414)", () => {
|
||||
it("returns correct issuer and endpoints", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
expect(body.issuer).toBe("https://example.com/_emdash");
|
||||
expect(body.authorization_endpoint).toBe("https://example.com/_emdash/oauth/authorize");
|
||||
expect(body.token_endpoint).toBe("https://example.com/_emdash/api/oauth/token");
|
||||
expect(body.device_authorization_endpoint).toBe(
|
||||
"https://example.com/_emdash/api/oauth/device/code",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports authorization_code, refresh_token, and device_code grants", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
const body = (await response.json()) as { grant_types_supported: string[] };
|
||||
expect(body.grant_types_supported).toContain("authorization_code");
|
||||
expect(body.grant_types_supported).toContain("refresh_token");
|
||||
expect(body.grant_types_supported).toContain("urn:ietf:params:oauth:grant-type:device_code");
|
||||
});
|
||||
|
||||
it("requires S256 code challenge method only", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
const body = (await response.json()) as { code_challenge_methods_supported: string[] };
|
||||
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
||||
});
|
||||
|
||||
it("only supports code response type", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
const body = (await response.json()) as { response_types_supported: string[] };
|
||||
expect(body.response_types_supported).toEqual(["code"]);
|
||||
});
|
||||
|
||||
it("supports public clients (no auth method)", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
const body = (await response.json()) as { token_endpoint_auth_methods_supported: string[] };
|
||||
expect(body.token_endpoint_auth_methods_supported).toEqual(["none"]);
|
||||
});
|
||||
|
||||
it("includes all valid scopes", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
const body = (await response.json()) as { scopes_supported: string[] };
|
||||
expect(body.scopes_supported).toEqual([...VALID_SCOPES]);
|
||||
});
|
||||
|
||||
it("sets CORS and cache headers", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Cache-Control")).toContain("public");
|
||||
});
|
||||
|
||||
it("supports client_id_metadata_document", async () => {
|
||||
const response = await getAuthorizationServer(mockContext());
|
||||
const body = (await response.json()) as { client_id_metadata_document_supported: boolean };
|
||||
expect(body.client_id_metadata_document_supported).toBe(true);
|
||||
});
|
||||
});
|
||||
309
packages/core/tests/unit/auth/invite.test.ts
Normal file
309
packages/core/tests/unit/auth/invite.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { AuthAdapter, EmailSendFn } from "@emdashcms/auth";
|
||||
import type { EmailMessage } from "@emdashcms/auth";
|
||||
import {
|
||||
Role,
|
||||
createInvite,
|
||||
createInviteToken,
|
||||
validateInvite,
|
||||
completeInvite,
|
||||
InviteError,
|
||||
escapeHtml,
|
||||
generateToken,
|
||||
} from "@emdashcms/auth";
|
||||
import { createKyselyAdapter } from "@emdashcms/auth/adapters/kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Regex patterns for token validation
|
||||
const TOKEN_PARAM_REGEX = /token=/;
|
||||
const TOKEN_EXTRACT_REGEX = /token=([a-zA-Z0-9_-]+)/;
|
||||
|
||||
describe("Invite", () => {
|
||||
let db: Kysely<Database>;
|
||||
let adapter: AuthAdapter;
|
||||
let adminId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
adapter = createKyselyAdapter(db);
|
||||
|
||||
// Create an admin user (required for the invitedBy FK)
|
||||
const admin = await adapter.createUser({
|
||||
email: "admin@example.com",
|
||||
name: "Admin",
|
||||
role: Role.ADMIN,
|
||||
emailVerified: true,
|
||||
});
|
||||
adminId = admin.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("createInviteToken", () => {
|
||||
it("should create a token and return url + email", async () => {
|
||||
const result = await createInviteToken(
|
||||
{ baseUrl: "https://example.com" },
|
||||
adapter,
|
||||
"new@example.com",
|
||||
Role.AUTHOR,
|
||||
adminId,
|
||||
);
|
||||
|
||||
expect(result.email).toBe("new@example.com");
|
||||
expect(result.url).toContain("https://example.com");
|
||||
expect(result.url).toMatch(TOKEN_PARAM_REGEX);
|
||||
// Should NOT have a token field on the result
|
||||
expect("token" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw user_exists if email is already registered", async () => {
|
||||
await adapter.createUser({
|
||||
email: "existing@example.com",
|
||||
name: "Existing",
|
||||
role: Role.AUTHOR,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createInviteToken(
|
||||
{ baseUrl: "https://example.com" },
|
||||
adapter,
|
||||
"existing@example.com",
|
||||
Role.AUTHOR,
|
||||
adminId,
|
||||
),
|
||||
).rejects.toThrow(InviteError);
|
||||
|
||||
try {
|
||||
await createInviteToken(
|
||||
{ baseUrl: "https://example.com" },
|
||||
adapter,
|
||||
"existing@example.com",
|
||||
Role.AUTHOR,
|
||||
adminId,
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(InviteError);
|
||||
expect((error as InviteError).code).toBe("user_exists");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInvite", () => {
|
||||
let mockEmailSend: EmailSendFn & ReturnType<typeof vi.fn>;
|
||||
let sentEmails: Array<EmailMessage>;
|
||||
|
||||
beforeEach(() => {
|
||||
sentEmails = [];
|
||||
mockEmailSend = vi.fn(async (email: EmailMessage) => {
|
||||
sentEmails.push(email);
|
||||
});
|
||||
});
|
||||
|
||||
it("should send email when email sender is provided", async () => {
|
||||
const result = await createInvite(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
siteName: "Test Site",
|
||||
email: mockEmailSend,
|
||||
},
|
||||
adapter,
|
||||
"invite@example.com",
|
||||
Role.EDITOR,
|
||||
adminId,
|
||||
);
|
||||
|
||||
expect(mockEmailSend).toHaveBeenCalledOnce();
|
||||
expect(sentEmails).toHaveLength(1);
|
||||
expect(sentEmails[0]!.to).toBe("invite@example.com");
|
||||
expect(sentEmails[0]!.subject).toContain("Test Site");
|
||||
expect(sentEmails[0]!.html).toContain("Accept Invite");
|
||||
expect(sentEmails[0]!.text).toContain(result.url);
|
||||
});
|
||||
|
||||
it("should return url without sending email when no sender", async () => {
|
||||
const result = await createInvite(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
siteName: "Test Site",
|
||||
// No email sender — copy-link fallback
|
||||
},
|
||||
adapter,
|
||||
"noemail@example.com",
|
||||
Role.AUTHOR,
|
||||
adminId,
|
||||
);
|
||||
|
||||
expect(result.url).toContain("https://example.com");
|
||||
expect(result.url).toMatch(TOKEN_PARAM_REGEX);
|
||||
expect(result.email).toBe("noemail@example.com");
|
||||
});
|
||||
|
||||
it("should HTML-escape siteName in email HTML body", async () => {
|
||||
await createInvite(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
siteName: '<script>alert("xss")</script>',
|
||||
email: mockEmailSend,
|
||||
},
|
||||
adapter,
|
||||
"xss@example.com",
|
||||
Role.AUTHOR,
|
||||
adminId,
|
||||
);
|
||||
|
||||
expect(sentEmails).toHaveLength(1);
|
||||
const html = sentEmails[0]!.html!;
|
||||
// HTML body should be escaped
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
// Plain text subject should NOT be escaped (it's not HTML)
|
||||
expect(sentEmails[0]!.subject).toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateInvite", () => {
|
||||
let capturedToken: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedToken = null;
|
||||
});
|
||||
|
||||
async function createTestInvite(email: string, role: number = Role.AUTHOR): Promise<string> {
|
||||
const mockSend = vi.fn(async (msg: EmailMessage) => {
|
||||
const match = msg.text.match(TOKEN_EXTRACT_REGEX);
|
||||
capturedToken = match ? (match[1] ?? null) : null;
|
||||
});
|
||||
|
||||
await createInvite(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
siteName: "Test",
|
||||
email: mockSend,
|
||||
},
|
||||
adapter,
|
||||
email,
|
||||
role,
|
||||
adminId,
|
||||
);
|
||||
|
||||
if (!capturedToken) throw new Error("Token not captured from email");
|
||||
return capturedToken;
|
||||
}
|
||||
|
||||
it("should validate a valid token and return email + role", async () => {
|
||||
const token = await createTestInvite("valid@example.com", Role.EDITOR);
|
||||
|
||||
const result = await validateInvite(adapter, token);
|
||||
|
||||
expect(result.email).toBe("valid@example.com");
|
||||
expect(result.role).toBe(Role.EDITOR);
|
||||
});
|
||||
|
||||
it("should throw invalid_token for a nonexistent token", async () => {
|
||||
// Use a valid base64url token that doesn't exist in the DB
|
||||
const fakeToken = generateToken();
|
||||
|
||||
await expect(validateInvite(adapter, fakeToken)).rejects.toThrow(InviteError);
|
||||
|
||||
try {
|
||||
await validateInvite(adapter, fakeToken);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(InviteError);
|
||||
expect((error as InviteError).code).toBe("invalid_token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw invalid_token for an already-used token", async () => {
|
||||
const token = await createTestInvite("used@example.com");
|
||||
|
||||
// Complete the invite (consumes the token)
|
||||
await completeInvite(adapter, token, { name: "Used User" });
|
||||
|
||||
// Token should now be invalid
|
||||
await expect(validateInvite(adapter, token)).rejects.toThrow(InviteError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeInvite", () => {
|
||||
async function createTestInvite(email: string, role: number = Role.AUTHOR): Promise<string> {
|
||||
let token: string | null = null;
|
||||
const mockSend = vi.fn(async (msg: EmailMessage) => {
|
||||
const match = msg.text.match(TOKEN_EXTRACT_REGEX);
|
||||
token = match ? (match[1] ?? null) : null;
|
||||
});
|
||||
|
||||
await createInvite(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
siteName: "Test",
|
||||
email: mockSend,
|
||||
},
|
||||
adapter,
|
||||
email,
|
||||
role,
|
||||
adminId,
|
||||
);
|
||||
|
||||
if (!token) throw new Error("Token not captured from email");
|
||||
return token;
|
||||
}
|
||||
|
||||
it("should create user with correct email and role", async () => {
|
||||
const token = await createTestInvite("new@example.com", Role.EDITOR);
|
||||
|
||||
const user = await completeInvite(adapter, token, { name: "New User" });
|
||||
|
||||
expect(user.email).toBe("new@example.com");
|
||||
expect(user.role).toBe(Role.EDITOR);
|
||||
expect(user.name).toBe("New User");
|
||||
expect(user.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
it("should delete token after use (single-use)", async () => {
|
||||
const token = await createTestInvite("oneuse@example.com");
|
||||
|
||||
await completeInvite(adapter, token, { name: "One Use" });
|
||||
|
||||
// Second use should fail
|
||||
await expect(completeInvite(adapter, token, { name: "Second Use" })).rejects.toThrow(
|
||||
InviteError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw invalid_token for nonexistent token", async () => {
|
||||
const fakeToken = generateToken();
|
||||
|
||||
await expect(completeInvite(adapter, fakeToken, { name: "Fake" })).rejects.toThrow(
|
||||
InviteError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("should escape angle brackets", () => {
|
||||
expect(escapeHtml("<script>")).toBe("<script>");
|
||||
});
|
||||
|
||||
it("should escape ampersands", () => {
|
||||
expect(escapeHtml("a & b")).toBe("a & b");
|
||||
});
|
||||
|
||||
it("should escape double quotes", () => {
|
||||
expect(escapeHtml('"hello"')).toBe(""hello"");
|
||||
});
|
||||
|
||||
it("should handle strings with no special characters", () => {
|
||||
expect(escapeHtml("My Site")).toBe("My Site");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(escapeHtml("")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
82
packages/core/tests/unit/auth/passkey-config.test.ts
Normal file
82
packages/core/tests/unit/auth/passkey-config.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getPasskeyConfig } from "../../../src/auth/passkey-config.js";
|
||||
|
||||
describe("passkey-config", () => {
|
||||
describe("getPasskeyConfig()", () => {
|
||||
it("extracts rpId from localhost URL", () => {
|
||||
const url = new URL("http://localhost:4321/admin");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.rpId).toBe("localhost");
|
||||
});
|
||||
|
||||
it("extracts rpId from production URL", () => {
|
||||
const url = new URL("https://example.com/admin");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.rpId).toBe("example.com");
|
||||
});
|
||||
|
||||
it("extracts rpId from subdomain URL", () => {
|
||||
const url = new URL("https://admin.example.com/dashboard");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.rpId).toBe("admin.example.com");
|
||||
});
|
||||
|
||||
it("returns correct origin for http", () => {
|
||||
const url = new URL("http://localhost:4321/admin");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.origin).toBe("http://localhost:4321");
|
||||
});
|
||||
|
||||
it("returns correct origin for https", () => {
|
||||
const url = new URL("https://example.com/admin");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.origin).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("handles port numbers correctly", () => {
|
||||
const url = new URL("http://localhost:3000/setup");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.rpId).toBe("localhost");
|
||||
expect(config.origin).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("handles https with non-standard port", () => {
|
||||
const url = new URL("https://staging.example.com:8443/admin");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.rpId).toBe("staging.example.com");
|
||||
expect(config.origin).toBe("https://staging.example.com:8443");
|
||||
});
|
||||
|
||||
it("uses hostname as rpName by default", () => {
|
||||
const url = new URL("https://example.com/admin");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
expect(config.rpName).toBe("example.com");
|
||||
});
|
||||
|
||||
it("uses provided siteName for rpName", () => {
|
||||
const url = new URL("https://example.com/admin");
|
||||
const config = getPasskeyConfig(url, "My Cool Site");
|
||||
|
||||
expect(config.rpName).toBe("My Cool Site");
|
||||
expect(config.rpId).toBe("example.com");
|
||||
});
|
||||
|
||||
it("ignores path and query params for origin", () => {
|
||||
const url = new URL("https://example.com:443/admin/setup?foo=bar#section");
|
||||
const config = getPasskeyConfig(url);
|
||||
|
||||
// Standard https port 443 is omitted from origin
|
||||
expect(config.origin).toBe("https://example.com");
|
||||
expect(config.rpId).toBe("example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
278
packages/core/tests/unit/auth/passkey-management.test.ts
Normal file
278
packages/core/tests/unit/auth/passkey-management.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { AuthAdapter, Credential, User } from "@emdashcms/auth";
|
||||
import { Role } from "@emdashcms/auth";
|
||||
import { createKyselyAdapter } from "@emdashcms/auth/adapters/kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("Passkey Management", () => {
|
||||
let db: Kysely<Database>;
|
||||
let adapter: AuthAdapter;
|
||||
let testUser: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
adapter = createKyselyAdapter(db);
|
||||
|
||||
// Create a test user
|
||||
testUser = await adapter.createUser({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
role: Role.ADMIN,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
// Helper to create a test credential
|
||||
async function createTestCredential(userId: string, name?: string): Promise<Credential> {
|
||||
const credentialId = `cred-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
return adapter.createCredential({
|
||||
id: credentialId,
|
||||
userId,
|
||||
publicKey: new Uint8Array([1, 2, 3, 4]),
|
||||
counter: 0,
|
||||
deviceType: "multiDevice",
|
||||
backedUp: true,
|
||||
transports: ["internal"],
|
||||
name: name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("getCredentialById", () => {
|
||||
it("should return credential by ID", async () => {
|
||||
const created = await createTestCredential(testUser.id, "My MacBook");
|
||||
|
||||
const credential = await adapter.getCredentialById(created.id);
|
||||
|
||||
expect(credential).not.toBeNull();
|
||||
expect(credential?.id).toBe(created.id);
|
||||
expect(credential?.userId).toBe(testUser.id);
|
||||
expect(credential?.name).toBe("My MacBook");
|
||||
expect(credential?.deviceType).toBe("multiDevice");
|
||||
expect(credential?.backedUp).toBe(true);
|
||||
});
|
||||
|
||||
it("should return null for non-existent credential", async () => {
|
||||
const credential = await adapter.getCredentialById("non-existent");
|
||||
expect(credential).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCredentialsByUserId", () => {
|
||||
it("should return empty array for user with no passkeys", async () => {
|
||||
const credentials = await adapter.getCredentialsByUserId(testUser.id);
|
||||
expect(credentials).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all passkeys for a user", async () => {
|
||||
await createTestCredential(testUser.id, "MacBook Pro");
|
||||
await createTestCredential(testUser.id, "iPhone");
|
||||
await createTestCredential(testUser.id, null);
|
||||
|
||||
const credentials = await adapter.getCredentialsByUserId(testUser.id);
|
||||
|
||||
expect(credentials).toHaveLength(3);
|
||||
const names = credentials.map((c) => c.name);
|
||||
expect(names).toContain("MacBook Pro");
|
||||
expect(names).toContain("iPhone");
|
||||
expect(names).toContain(null);
|
||||
});
|
||||
|
||||
it("should not return passkeys from other users", async () => {
|
||||
const otherUser = await adapter.createUser({
|
||||
email: "other@example.com",
|
||||
name: "Other User",
|
||||
});
|
||||
|
||||
await createTestCredential(testUser.id, "Test User Passkey");
|
||||
await createTestCredential(otherUser.id, "Other User Passkey");
|
||||
|
||||
const testUserCreds = await adapter.getCredentialsByUserId(testUser.id);
|
||||
const otherUserCreds = await adapter.getCredentialsByUserId(otherUser.id);
|
||||
|
||||
expect(testUserCreds).toHaveLength(1);
|
||||
expect(testUserCreds[0].name).toBe("Test User Passkey");
|
||||
|
||||
expect(otherUserCreds).toHaveLength(1);
|
||||
expect(otherUserCreds[0].name).toBe("Other User Passkey");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCredentialName", () => {
|
||||
it("should update the credential name", async () => {
|
||||
const credential = await createTestCredential(testUser.id, "Old Name");
|
||||
|
||||
await adapter.updateCredentialName(credential.id, "New Name");
|
||||
|
||||
const updated = await adapter.getCredentialById(credential.id);
|
||||
expect(updated?.name).toBe("New Name");
|
||||
});
|
||||
|
||||
it("should set name to null when provided null", async () => {
|
||||
const credential = await createTestCredential(testUser.id, "Has Name");
|
||||
|
||||
await adapter.updateCredentialName(credential.id, null);
|
||||
|
||||
const updated = await adapter.getCredentialById(credential.id);
|
||||
expect(updated?.name).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty string as name", async () => {
|
||||
const credential = await createTestCredential(testUser.id, "Has Name");
|
||||
|
||||
await adapter.updateCredentialName(credential.id, "");
|
||||
|
||||
const updated = await adapter.getCredentialById(credential.id);
|
||||
expect(updated?.name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCredentialsByUserId", () => {
|
||||
it("should return 0 for user with no passkeys", async () => {
|
||||
const count = await adapter.countCredentialsByUserId(testUser.id);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("should return correct count", async () => {
|
||||
await createTestCredential(testUser.id);
|
||||
await createTestCredential(testUser.id);
|
||||
await createTestCredential(testUser.id);
|
||||
|
||||
const count = await adapter.countCredentialsByUserId(testUser.id);
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("should only count credentials for the specified user", async () => {
|
||||
const otherUser = await adapter.createUser({
|
||||
email: "other@example.com",
|
||||
});
|
||||
|
||||
await createTestCredential(testUser.id);
|
||||
await createTestCredential(testUser.id);
|
||||
await createTestCredential(otherUser.id);
|
||||
|
||||
const testUserCount = await adapter.countCredentialsByUserId(testUser.id);
|
||||
const otherUserCount = await adapter.countCredentialsByUserId(otherUser.id);
|
||||
|
||||
expect(testUserCount).toBe(2);
|
||||
expect(otherUserCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteCredential", () => {
|
||||
it("should delete a credential", async () => {
|
||||
const credential = await createTestCredential(testUser.id);
|
||||
|
||||
await adapter.deleteCredential(credential.id);
|
||||
|
||||
const deleted = await adapter.getCredentialById(credential.id);
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it("should not affect other credentials", async () => {
|
||||
await createTestCredential(testUser.id, "Keep This");
|
||||
const cred2 = await createTestCredential(testUser.id, "Delete This");
|
||||
|
||||
await adapter.deleteCredential(cred2.id);
|
||||
|
||||
const remaining = await adapter.getCredentialsByUserId(testUser.id);
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].name).toBe("Keep This");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Passkey Management Flow", () => {
|
||||
it("should support full CRUD flow", async () => {
|
||||
// Create passkeys
|
||||
const passkey1 = await createTestCredential(testUser.id, "MacBook");
|
||||
const passkey2 = await createTestCredential(testUser.id, "iPhone");
|
||||
|
||||
// List passkeys
|
||||
let passkeys = await adapter.getCredentialsByUserId(testUser.id);
|
||||
expect(passkeys).toHaveLength(2);
|
||||
|
||||
// Rename a passkey
|
||||
await adapter.updateCredentialName(passkey1.id, "MacBook Pro M3");
|
||||
const renamed = await adapter.getCredentialById(passkey1.id);
|
||||
expect(renamed?.name).toBe("MacBook Pro M3");
|
||||
|
||||
// Delete a passkey (not the last one)
|
||||
const countBefore = await adapter.countCredentialsByUserId(testUser.id);
|
||||
expect(countBefore).toBe(2);
|
||||
|
||||
await adapter.deleteCredential(passkey2.id);
|
||||
|
||||
const countAfter = await adapter.countCredentialsByUserId(testUser.id);
|
||||
expect(countAfter).toBe(1);
|
||||
|
||||
// Verify only one remains
|
||||
passkeys = await adapter.getCredentialsByUserId(testUser.id);
|
||||
expect(passkeys).toHaveLength(1);
|
||||
expect(passkeys[0].name).toBe("MacBook Pro M3");
|
||||
});
|
||||
|
||||
it("should enforce 'cannot delete last passkey' in application logic", async () => {
|
||||
// Create a single passkey
|
||||
const passkey = await createTestCredential(testUser.id, "Only Passkey");
|
||||
|
||||
// Check count before deletion attempt
|
||||
const count = await adapter.countCredentialsByUserId(testUser.id);
|
||||
expect(count).toBe(1);
|
||||
|
||||
// Application should check count and prevent deletion
|
||||
// The adapter itself doesn't enforce this - it's the API layer's job
|
||||
if (count <= 1) {
|
||||
// Don't delete - this is what the API should do
|
||||
const stillExists = await adapter.getCredentialById(passkey.id);
|
||||
expect(stillExists).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Credential properties", () => {
|
||||
it("should preserve all credential properties", async () => {
|
||||
await adapter.createCredential({
|
||||
id: "test-cred-123",
|
||||
userId: testUser.id,
|
||||
publicKey: new Uint8Array([10, 20, 30, 40, 50]),
|
||||
counter: 5,
|
||||
deviceType: "singleDevice",
|
||||
backedUp: false,
|
||||
transports: ["usb", "nfc"],
|
||||
name: "YubiKey 5",
|
||||
});
|
||||
|
||||
const retrieved = await adapter.getCredentialById("test-cred-123");
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.id).toBe("test-cred-123");
|
||||
expect(retrieved?.userId).toBe(testUser.id);
|
||||
expect(retrieved?.counter).toBe(5);
|
||||
expect(retrieved?.deviceType).toBe("singleDevice");
|
||||
expect(retrieved?.backedUp).toBe(false);
|
||||
expect(retrieved?.transports).toEqual(["usb", "nfc"]);
|
||||
expect(retrieved?.name).toBe("YubiKey 5");
|
||||
expect(retrieved?.createdAt).toBeInstanceOf(Date);
|
||||
expect(retrieved?.lastUsedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should update lastUsedAt when counter is updated", async () => {
|
||||
const credential = await createTestCredential(testUser.id);
|
||||
const originalLastUsed = credential.lastUsedAt;
|
||||
|
||||
// Small delay to ensure time difference
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await adapter.updateCredentialCounter(credential.id, 1);
|
||||
|
||||
const updated = await adapter.getCredentialById(credential.id);
|
||||
expect(updated?.counter).toBe(1);
|
||||
expect(updated?.lastUsedAt.getTime()).toBeGreaterThan(originalLastUsed.getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
66
packages/core/tests/unit/auth/scopes.test.ts
Normal file
66
packages/core/tests/unit/auth/scopes.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Unit tests for scope enforcement.
|
||||
*
|
||||
* Tests the requireScope() guard that API routes and MCP tools use
|
||||
* to enforce token scope restrictions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { requireScope } from "../../../src/auth/scopes.js";
|
||||
|
||||
describe("requireScope", () => {
|
||||
it("allows session auth (no tokenScopes) unconditionally", () => {
|
||||
const result = requireScope({}, "content:write");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("allows session auth with undefined tokenScopes", () => {
|
||||
const result = requireScope({ tokenScopes: undefined }, "schema:write");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("allows when token has the required scope", () => {
|
||||
const result = requireScope(
|
||||
{ tokenScopes: ["content:read", "content:write"] },
|
||||
"content:write",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects when token lacks the required scope", () => {
|
||||
const result = requireScope({ tokenScopes: ["content:read"] }, "content:write");
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result!.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns INSUFFICIENT_SCOPE error body", async () => {
|
||||
const result = requireScope({ tokenScopes: ["media:read"] }, "schema:write");
|
||||
expect(result).not.toBeNull();
|
||||
const body = (await result!.json()) as { error: { code: string; message: string } };
|
||||
expect(body.error.code).toBe("INSUFFICIENT_SCOPE");
|
||||
expect(body.error.message).toContain("schema:write");
|
||||
});
|
||||
|
||||
it("admin scope grants access to everything", () => {
|
||||
expect(requireScope({ tokenScopes: ["admin"] }, "content:read")).toBeNull();
|
||||
expect(requireScope({ tokenScopes: ["admin"] }, "content:write")).toBeNull();
|
||||
expect(requireScope({ tokenScopes: ["admin"] }, "schema:read")).toBeNull();
|
||||
expect(requireScope({ tokenScopes: ["admin"] }, "schema:write")).toBeNull();
|
||||
expect(requireScope({ tokenScopes: ["admin"] }, "media:read")).toBeNull();
|
||||
expect(requireScope({ tokenScopes: ["admin"] }, "media:write")).toBeNull();
|
||||
});
|
||||
|
||||
it("empty scopes array rejects everything", () => {
|
||||
expect(requireScope({ tokenScopes: [] }, "content:read")).toBeInstanceOf(Response);
|
||||
expect(requireScope({ tokenScopes: [] }, "admin")).toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
it("read scope does not grant write access", () => {
|
||||
expect(requireScope({ tokenScopes: ["content:read"] }, "content:write")).toBeInstanceOf(
|
||||
Response,
|
||||
);
|
||||
expect(requireScope({ tokenScopes: ["media:read"] }, "media:write")).toBeInstanceOf(Response);
|
||||
expect(requireScope({ tokenScopes: ["schema:read"] }, "schema:write")).toBeInstanceOf(Response);
|
||||
});
|
||||
});
|
||||
462
packages/core/tests/unit/auth/signup.test.ts
Normal file
462
packages/core/tests/unit/auth/signup.test.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import type { AuthAdapter, EmailSendFn } from "@emdashcms/auth";
|
||||
import type { EmailMessage } from "@emdashcms/auth";
|
||||
import {
|
||||
Role,
|
||||
canSignup,
|
||||
requestSignup,
|
||||
validateSignupToken,
|
||||
completeSignup,
|
||||
SignupError,
|
||||
} from "@emdashcms/auth";
|
||||
import { createKyselyAdapter } from "@emdashcms/auth/adapters/kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Regex patterns for token validation
|
||||
const TOKEN_PARAM_REGEX = /token=/;
|
||||
const TOKEN_EXTRACT_REGEX = /token=([a-zA-Z0-9_-]+)/;
|
||||
|
||||
describe("Self-Signup", () => {
|
||||
let db: Kysely<Database>;
|
||||
let adapter: AuthAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
adapter = createKyselyAdapter(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("canSignup", () => {
|
||||
it("should return null for email with no allowed domain", async () => {
|
||||
const result = await canSignup(adapter, "user@notallowed.com");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for email with disabled domain", async () => {
|
||||
// Create a disabled domain
|
||||
await adapter.createAllowedDomain("disabled.com", Role.AUTHOR);
|
||||
await adapter.updateAllowedDomain("disabled.com", false);
|
||||
|
||||
const result = await canSignup(adapter, "user@disabled.com");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return allowed:true and role for email with allowed domain", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
const result = await canSignup(adapter, "user@allowed.com");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.allowed).toBe(true);
|
||||
expect(result?.role).toBe(Role.AUTHOR);
|
||||
});
|
||||
|
||||
it("should return correct role for each domain", async () => {
|
||||
await adapter.createAllowedDomain("authors.com", Role.AUTHOR);
|
||||
await adapter.createAllowedDomain("editors.com", Role.EDITOR);
|
||||
await adapter.createAllowedDomain("contributors.com", Role.CONTRIBUTOR);
|
||||
|
||||
const author = await canSignup(adapter, "user@authors.com");
|
||||
const editor = await canSignup(adapter, "user@editors.com");
|
||||
const contributor = await canSignup(adapter, "user@contributors.com");
|
||||
|
||||
expect(author?.role).toBe(Role.AUTHOR);
|
||||
expect(editor?.role).toBe(Role.EDITOR);
|
||||
expect(contributor?.role).toBe(Role.CONTRIBUTOR);
|
||||
});
|
||||
|
||||
it("should be case-insensitive for email domains", async () => {
|
||||
await adapter.createAllowedDomain("example.com", Role.AUTHOR);
|
||||
|
||||
const result = await canSignup(adapter, "User@EXAMPLE.COM");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid email format", async () => {
|
||||
const result = await canSignup(adapter, "not-an-email");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestSignup", () => {
|
||||
let mockEmailSend: EmailSendFn & ReturnType<typeof vi.fn>;
|
||||
let sentEmails: Array<EmailMessage>;
|
||||
|
||||
beforeEach(() => {
|
||||
sentEmails = [];
|
||||
mockEmailSend = vi.fn(async (email: EmailMessage) => {
|
||||
sentEmails.push(email);
|
||||
});
|
||||
});
|
||||
|
||||
it("should send verification email for allowed domain", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
expect(mockEmailSend).toHaveBeenCalledTimes(1);
|
||||
expect(sentEmails[0]!.to).toBe("newuser@allowed.com");
|
||||
expect(sentEmails[0]!.subject).toContain("Test Site");
|
||||
expect(sentEmails[0]!.text).toContain("verify");
|
||||
});
|
||||
|
||||
it("should fail silently for disallowed domain (no email sent)", async () => {
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"user@notallowed.com",
|
||||
);
|
||||
|
||||
expect(mockEmailSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fail silently if user already exists (no email sent)", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
// Create existing user
|
||||
await adapter.createUser({
|
||||
email: "existing@allowed.com",
|
||||
name: "Existing User",
|
||||
});
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"existing@allowed.com",
|
||||
);
|
||||
|
||||
expect(mockEmailSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create a token in the database", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.EDITOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
// The email should contain a verification link with a token
|
||||
expect(sentEmails[0]!.text).toMatch(TOKEN_PARAM_REGEX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSignupToken", () => {
|
||||
let mockEmailSend: EmailSendFn & ReturnType<typeof vi.fn>;
|
||||
let capturedToken: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedToken = null;
|
||||
mockEmailSend = vi.fn(async (email: EmailMessage) => {
|
||||
// Extract token from email text
|
||||
const match = email.text.match(TOKEN_EXTRACT_REGEX);
|
||||
capturedToken = match ? (match[1] ?? null) : null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should validate a valid token and return email/role", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
expect(capturedToken).not.toBeNull();
|
||||
|
||||
const result = await validateSignupToken(adapter, capturedToken!);
|
||||
|
||||
expect(result.email).toBe("newuser@allowed.com");
|
||||
expect(result.role).toBe(Role.AUTHOR);
|
||||
});
|
||||
|
||||
it("should throw invalid_token for non-existent token", async () => {
|
||||
// Use a properly formatted but non-existent token (base64url encoded)
|
||||
const fakeToken = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo"; // base64url of "abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
try {
|
||||
await validateSignupToken(adapter, fakeToken);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SignupError);
|
||||
expect((error as SignupError).code).toBe("invalid_token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw token_expired for expired token", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
expect(capturedToken).not.toBeNull();
|
||||
|
||||
// Manually expire the token by updating it in the database
|
||||
// We need to find the token hash and update its expiry
|
||||
// Since we can't easily do this, we'll test the error path differently
|
||||
// by creating a token directly with an expired date
|
||||
|
||||
// First, validate and get the hash
|
||||
const result = await validateSignupToken(adapter, capturedToken!);
|
||||
expect(result.email).toBe("newuser@allowed.com");
|
||||
|
||||
// For expiry testing, we'd need direct DB access to set expiry in the past
|
||||
// This is tested implicitly by the token creation with short expiry
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeSignup", () => {
|
||||
let mockEmailSend: EmailSendFn & ReturnType<typeof vi.fn>;
|
||||
let capturedToken: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedToken = null;
|
||||
mockEmailSend = vi.fn(async (email: EmailMessage) => {
|
||||
const match = email.text.match(TOKEN_EXTRACT_REGEX);
|
||||
capturedToken = match ? (match[1] ?? null) : null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create user with correct email and role", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
const user = await completeSignup(adapter, capturedToken!, {
|
||||
name: "New User",
|
||||
});
|
||||
|
||||
expect(user.email).toBe("newuser@allowed.com");
|
||||
expect(user.name).toBe("New User");
|
||||
expect(user.role).toBe(Role.AUTHOR);
|
||||
expect(user.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw user_exists if user created during signup flow (race condition)", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
// Simulate race condition - create user before completing signup
|
||||
await adapter.createUser({
|
||||
email: "newuser@allowed.com",
|
||||
name: "Created During Race",
|
||||
});
|
||||
|
||||
// Try to complete signup - should fail with user_exists
|
||||
try {
|
||||
await completeSignup(adapter, capturedToken!, { name: "New User" });
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SignupError);
|
||||
expect((error as SignupError).code).toBe("user_exists");
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw invalid_token for non-existent token", async () => {
|
||||
// Use a properly formatted but non-existent token (base64url encoded)
|
||||
const fakeToken = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo"; // base64url of "abcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
try {
|
||||
await completeSignup(adapter, fakeToken, { name: "User" });
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SignupError);
|
||||
expect((error as SignupError).code).toBe("invalid_token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete token after successful signup (single-use)", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"newuser@allowed.com",
|
||||
);
|
||||
|
||||
// First completion should succeed
|
||||
await completeSignup(adapter, capturedToken!, { name: "New User" });
|
||||
|
||||
// Second attempt should fail - token is deleted
|
||||
await expect(
|
||||
completeSignup(adapter, capturedToken!, { name: "Another User" }),
|
||||
).rejects.toThrow(SignupError);
|
||||
});
|
||||
|
||||
it("should allow optional name and avatarUrl", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"noname@allowed.com",
|
||||
);
|
||||
|
||||
const user = await completeSignup(adapter, capturedToken!, {});
|
||||
|
||||
expect(user.email).toBe("noname@allowed.com");
|
||||
expect(user.name).toBeNull();
|
||||
});
|
||||
|
||||
it("should set emailVerified to true", async () => {
|
||||
await adapter.createAllowedDomain("allowed.com", Role.AUTHOR);
|
||||
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test Site",
|
||||
},
|
||||
adapter,
|
||||
"verified@allowed.com",
|
||||
);
|
||||
|
||||
const user = await completeSignup(adapter, capturedToken!, {
|
||||
name: "Verified User",
|
||||
});
|
||||
|
||||
expect(user.emailVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration: Full Signup Flow", () => {
|
||||
let mockEmailSend: EmailSendFn & ReturnType<typeof vi.fn>;
|
||||
let capturedToken: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedToken = null;
|
||||
mockEmailSend = vi.fn(async (email: EmailMessage) => {
|
||||
const match = email.text.match(TOKEN_EXTRACT_REGEX);
|
||||
capturedToken = match ? (match[1] ?? null) : null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should complete full signup flow for allowed domain", async () => {
|
||||
// 1. Admin adds allowed domain
|
||||
await adapter.createAllowedDomain("company.com", Role.EDITOR);
|
||||
|
||||
// 2. Check if signup is allowed
|
||||
const check = await canSignup(adapter, "employee@company.com");
|
||||
expect(check?.allowed).toBe(true);
|
||||
expect(check?.role).toBe(Role.EDITOR);
|
||||
|
||||
// 3. Request signup
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Company CMS",
|
||||
},
|
||||
adapter,
|
||||
"employee@company.com",
|
||||
);
|
||||
expect(capturedToken).not.toBeNull();
|
||||
|
||||
// 4. Validate token (simulating email link click)
|
||||
const validation = await validateSignupToken(adapter, capturedToken!);
|
||||
expect(validation.email).toBe("employee@company.com");
|
||||
expect(validation.role).toBe(Role.EDITOR);
|
||||
|
||||
// 5. Complete signup
|
||||
const user = await completeSignup(adapter, capturedToken!, {
|
||||
name: "New Employee",
|
||||
});
|
||||
|
||||
expect(user.email).toBe("employee@company.com");
|
||||
expect(user.name).toBe("New Employee");
|
||||
expect(user.role).toBe(Role.EDITOR);
|
||||
expect(user.emailVerified).toBe(true);
|
||||
|
||||
// 6. Verify user exists in database
|
||||
const fetchedUser = await adapter.getUserByEmail("employee@company.com");
|
||||
expect(fetchedUser).not.toBeNull();
|
||||
expect(fetchedUser?.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it("should prevent signup for disabled domain", async () => {
|
||||
// Add domain then disable it
|
||||
await adapter.createAllowedDomain("company.com", Role.AUTHOR);
|
||||
await adapter.updateAllowedDomain("company.com", false);
|
||||
|
||||
// Check - should not be allowed
|
||||
const check = await canSignup(adapter, "user@company.com");
|
||||
expect(check).toBeNull();
|
||||
|
||||
// Request signup - should fail silently (no email)
|
||||
await requestSignup(
|
||||
{
|
||||
baseUrl: "https://example.com",
|
||||
email: mockEmailSend,
|
||||
siteName: "Test",
|
||||
},
|
||||
adapter,
|
||||
"user@company.com",
|
||||
);
|
||||
expect(mockEmailSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
238
packages/core/tests/unit/bylines/bylines-query.test.ts
Normal file
238
packages/core/tests/unit/bylines/bylines-query.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { BylineRepository } from "../../../src/database/repositories/byline.js";
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import { UserRepository } from "../../../src/database/repositories/user.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Mock the loader's getDb to return our test database
|
||||
vi.mock("../../../src/loader.js", () => ({
|
||||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
getByline,
|
||||
getBylineBySlug,
|
||||
getEntryBylines,
|
||||
getBylinesForEntries,
|
||||
} from "../../../src/bylines/index.js";
|
||||
import { getDb } from "../../../src/loader.js";
|
||||
|
||||
describe("Byline query functions", () => {
|
||||
let db: Kysely<Database>;
|
||||
let bylineRepo: BylineRepository;
|
||||
let contentRepo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
bylineRepo = new BylineRepository(db);
|
||||
contentRepo = new ContentRepository(db);
|
||||
vi.mocked(getDb).mockResolvedValue(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getByline", () => {
|
||||
it("returns a byline by ID", async () => {
|
||||
const created = await bylineRepo.create({
|
||||
slug: "jane-doe",
|
||||
displayName: "Jane Doe",
|
||||
});
|
||||
|
||||
const result = await getByline(created.id);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe(created.id);
|
||||
expect(result?.displayName).toBe("Jane Doe");
|
||||
expect(result?.slug).toBe("jane-doe");
|
||||
});
|
||||
|
||||
it("returns null for non-existent ID", async () => {
|
||||
const result = await getByline("non-existent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBylineBySlug", () => {
|
||||
it("returns a byline by slug", async () => {
|
||||
await bylineRepo.create({
|
||||
slug: "john-smith",
|
||||
displayName: "John Smith",
|
||||
});
|
||||
|
||||
const result = await getBylineBySlug("john-smith");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.displayName).toBe("John Smith");
|
||||
});
|
||||
|
||||
it("returns null for non-existent slug", async () => {
|
||||
const result = await getBylineBySlug("nobody");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntryBylines", () => {
|
||||
it("returns explicit byline credits for an entry", async () => {
|
||||
const lead = await bylineRepo.create({
|
||||
slug: "lead-author",
|
||||
displayName: "Lead Author",
|
||||
});
|
||||
const editor = await bylineRepo.create({
|
||||
slug: "editor",
|
||||
displayName: "Editor",
|
||||
});
|
||||
|
||||
const post = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "my-post",
|
||||
data: { title: "My Post" },
|
||||
});
|
||||
|
||||
await bylineRepo.setContentBylines("post", post.id, [
|
||||
{ bylineId: lead.id },
|
||||
{ bylineId: editor.id, roleLabel: "Contributing Editor" },
|
||||
]);
|
||||
|
||||
const bylines = await getEntryBylines("post", post.id);
|
||||
|
||||
expect(bylines).toHaveLength(2);
|
||||
expect(bylines[0]?.byline.displayName).toBe("Lead Author");
|
||||
expect(bylines[0]?.sortOrder).toBe(0);
|
||||
expect(bylines[0]?.source).toBe("explicit");
|
||||
expect(bylines[1]?.byline.displayName).toBe("Editor");
|
||||
expect(bylines[1]?.roleLabel).toBe("Contributing Editor");
|
||||
expect(bylines[1]?.source).toBe("explicit");
|
||||
});
|
||||
|
||||
it("falls back to user-linked byline when no explicit credits", async () => {
|
||||
// Create a user
|
||||
const userRepo = new UserRepository(db);
|
||||
const user = await userRepo.create({
|
||||
email: "author@example.com",
|
||||
displayName: "Author User",
|
||||
role: "editor",
|
||||
});
|
||||
|
||||
// Create a byline linked to the user
|
||||
await bylineRepo.create({
|
||||
slug: "author-user",
|
||||
displayName: "Author User",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Create a post with this user as author, no explicit bylines
|
||||
const post = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "authored-post",
|
||||
data: { title: "Authored Post" },
|
||||
authorId: user.id,
|
||||
});
|
||||
|
||||
const bylines = await getEntryBylines("post", post.id);
|
||||
|
||||
expect(bylines).toHaveLength(1);
|
||||
expect(bylines[0]?.byline.displayName).toBe("Author User");
|
||||
expect(bylines[0]?.source).toBe("inferred");
|
||||
expect(bylines[0]?.roleLabel).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty array when no bylines and no author fallback", async () => {
|
||||
const post = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "no-author-post",
|
||||
data: { title: "No Author" },
|
||||
});
|
||||
|
||||
const bylines = await getEntryBylines("post", post.id);
|
||||
expect(bylines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBylinesForEntries", () => {
|
||||
it("batch-fetches byline credits for multiple entries", async () => {
|
||||
const author1 = await bylineRepo.create({
|
||||
slug: "author-one",
|
||||
displayName: "Author One",
|
||||
});
|
||||
const author2 = await bylineRepo.create({
|
||||
slug: "author-two",
|
||||
displayName: "Author Two",
|
||||
});
|
||||
|
||||
const post1 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "post-1",
|
||||
data: { title: "Post 1" },
|
||||
});
|
||||
const post2 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "post-2",
|
||||
data: { title: "Post 2" },
|
||||
});
|
||||
const post3 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "post-3",
|
||||
data: { title: "Post 3" },
|
||||
});
|
||||
|
||||
await bylineRepo.setContentBylines("post", post1.id, [{ bylineId: author1.id }]);
|
||||
await bylineRepo.setContentBylines("post", post2.id, [
|
||||
{ bylineId: author1.id },
|
||||
{ bylineId: author2.id, roleLabel: "Contributor" },
|
||||
]);
|
||||
// post3 has no bylines
|
||||
|
||||
const result = await getBylinesForEntries("post", [post1.id, post2.id, post3.id]);
|
||||
|
||||
expect(result.get(post1.id)).toHaveLength(1);
|
||||
expect(result.get(post1.id)?.[0]?.byline.displayName).toBe("Author One");
|
||||
expect(result.get(post1.id)?.[0]?.source).toBe("explicit");
|
||||
|
||||
expect(result.get(post2.id)).toHaveLength(2);
|
||||
expect(result.get(post2.id)?.[0]?.byline.displayName).toBe("Author One");
|
||||
expect(result.get(post2.id)?.[1]?.byline.displayName).toBe("Author Two");
|
||||
expect(result.get(post2.id)?.[1]?.roleLabel).toBe("Contributor");
|
||||
|
||||
expect(result.get(post3.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns inferred bylines for entries without explicit credits", async () => {
|
||||
const userRepo = new UserRepository(db);
|
||||
const user = await userRepo.create({
|
||||
email: "batch-author@example.com",
|
||||
displayName: "Batch Author",
|
||||
role: "editor",
|
||||
});
|
||||
|
||||
await bylineRepo.create({
|
||||
slug: "batch-author",
|
||||
displayName: "Batch Author",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const post = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "batch-post",
|
||||
data: { title: "Batch Post" },
|
||||
authorId: user.id,
|
||||
});
|
||||
|
||||
const result = await getBylinesForEntries("post", [post.id]);
|
||||
|
||||
expect(result.get(post.id)).toHaveLength(1);
|
||||
expect(result.get(post.id)?.[0]?.source).toBe("inferred");
|
||||
expect(result.get(post.id)?.[0]?.byline.displayName).toBe("Batch Author");
|
||||
});
|
||||
|
||||
it("returns empty map for empty input", async () => {
|
||||
const result = await getBylinesForEntries("post", []);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
packages/core/tests/unit/cache-hints.test.ts
Normal file
128
packages/core/tests/unit/cache-hints.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { handleContentCreate } from "../../src/api/index.js";
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
import { emdashLoader } from "../../src/loader.js";
|
||||
import { runWithContext } from "../../src/request-context.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../utils/test-db.js";
|
||||
|
||||
describe("Cache hints", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
async function createPublishedPost(title: string) {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title },
|
||||
status: "published",
|
||||
});
|
||||
if (!result.success) throw new Error("Failed to create post");
|
||||
return result.data!.item;
|
||||
}
|
||||
|
||||
describe("loadCollection cacheHint", () => {
|
||||
it("should tag collection with type name", async () => {
|
||||
await createPublishedPost("First Post");
|
||||
await createPublishedPost("Second Post");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post" } }),
|
||||
);
|
||||
|
||||
expect(result.cacheHint).toBeDefined();
|
||||
expect(result.cacheHint!.tags).toEqual(["post"]);
|
||||
});
|
||||
|
||||
it("should include lastModified from most recent entry", async () => {
|
||||
await createPublishedPost("First Post");
|
||||
const second = await createPublishedPost("Second Post");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post" } }),
|
||||
);
|
||||
|
||||
expect(result.cacheHint!.lastModified).toBeInstanceOf(Date);
|
||||
// lastModified should be >= the second post's updated_at
|
||||
const secondUpdated = new Date(second.updatedAt);
|
||||
expect(result.cacheHint!.lastModified!.getTime()).toBeGreaterThanOrEqual(
|
||||
secondUpdated.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry-level cacheHint", () => {
|
||||
it("should tag each entry with its database ID", async () => {
|
||||
const post = await createPublishedPost("Test Post");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post" } }),
|
||||
);
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
const entry = result.entries![0];
|
||||
expect(entry.cacheHint).toBeDefined();
|
||||
expect(entry.cacheHint!.tags).toEqual([post.id]);
|
||||
});
|
||||
|
||||
it("should include lastModified on each entry", async () => {
|
||||
await createPublishedPost("Test Post");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post" } }),
|
||||
);
|
||||
|
||||
const entry = result.entries![0];
|
||||
expect(entry.cacheHint!.lastModified).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadEntry cacheHint", () => {
|
||||
it("should tag entry with its database ID", async () => {
|
||||
const post = await createPublishedPost("Test Post");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadEntry!({ filter: { type: "post", id: post.slug } }),
|
||||
);
|
||||
|
||||
// loadEntry returns the entry directly (LiveDataEntry), not { entry, cacheHint }
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.cacheHint).toBeDefined();
|
||||
expect(result!.cacheHint!.tags).toEqual([post.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidation tag alignment", () => {
|
||||
it("should produce tags that match the invalidation pattern", async () => {
|
||||
const post = await createPublishedPost("Test Post");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const collectionResult = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post" } }),
|
||||
);
|
||||
|
||||
// The route invalidates with tags: [collection, id]
|
||||
// Collection pages are tagged with [type] -> matches "collection" tag
|
||||
// Entry pages are tagged with [entryId] -> matches "id" tag
|
||||
const invalidationTags = ["post", post.id];
|
||||
|
||||
// Collection-level tag should be hit by invalidation
|
||||
expect(invalidationTags).toContain(collectionResult.cacheHint!.tags![0]);
|
||||
|
||||
// Entry-level tag should be hit by invalidation
|
||||
const entry = collectionResult.entries![0];
|
||||
expect(invalidationTags).toContain(entry.cacheHint!.tags![0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
277
packages/core/tests/unit/cleanup.test.ts
Normal file
277
packages/core/tests/unit/cleanup.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Tests for the cleanup subsystems.
|
||||
*
|
||||
* Note: runSystemCleanup() is not tested directly here because it imports
|
||||
* from @emdashcms/auth/adapters/kysely, which requires the auth package to
|
||||
* be built. Instead, we test each subsystem independently:
|
||||
* - cleanupExpiredChallenges: tested in auth/challenge-store.test.ts
|
||||
* - deleteExpiredTokens: tested below using direct DB operations
|
||||
* - cleanupPendingUploads: tested below via MediaRepository
|
||||
* - pruneOldRevisions: tested below via RevisionRepository
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { MediaRepository } from "../../src/database/repositories/media.js";
|
||||
import { RevisionRepository } from "../../src/database/repositories/revision.js";
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
import { setupTestDatabase, setupTestDatabaseWithCollections } from "../utils/test-db.js";
|
||||
|
||||
describe("Revision Pruning", () => {
|
||||
let db: Kysely<Database>;
|
||||
let revisionRepo: RevisionRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
revisionRepo = new RevisionRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("prunes old revisions keeping the most recent N", async () => {
|
||||
const entryId = ulid();
|
||||
|
||||
// Create a content entry
|
||||
const { sql } = await import("kysely");
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, created_at, updated_at, version)
|
||||
VALUES (${entryId}, ${"test-post"}, ${"draft"}, ${new Date().toISOString()}, ${new Date().toISOString()}, ${1})
|
||||
`.execute(db);
|
||||
|
||||
// Create 200 revisions
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId,
|
||||
data: { title: `Version ${i + 1}` },
|
||||
});
|
||||
}
|
||||
|
||||
const countBefore = await revisionRepo.countByEntry("post", entryId);
|
||||
expect(countBefore).toBe(200);
|
||||
|
||||
// Prune to keep 50
|
||||
const pruned = await revisionRepo.pruneOldRevisions("post", entryId, 50);
|
||||
|
||||
expect(pruned).toBe(150);
|
||||
|
||||
const countAfter = await revisionRepo.countByEntry("post", entryId);
|
||||
expect(countAfter).toBe(50);
|
||||
|
||||
// Verify the remaining 50 are the newest
|
||||
const remaining = await revisionRepo.findByEntry("post", entryId);
|
||||
expect(remaining[0]?.data.title).toBe("Version 200");
|
||||
expect(remaining[49]?.data.title).toBe("Version 151");
|
||||
});
|
||||
|
||||
it("is a no-op when revision count is at or below keepCount", async () => {
|
||||
const entryId = ulid();
|
||||
|
||||
const { sql } = await import("kysely");
|
||||
await sql`
|
||||
INSERT INTO ec_post (id, slug, status, created_at, updated_at, version)
|
||||
VALUES (${entryId}, ${"test-post-2"}, ${"draft"}, ${new Date().toISOString()}, ${new Date().toISOString()}, ${1})
|
||||
`.execute(db);
|
||||
|
||||
// Create 10 revisions
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId,
|
||||
data: { title: `Version ${i + 1}` },
|
||||
});
|
||||
}
|
||||
|
||||
const pruned = await revisionRepo.pruneOldRevisions("post", entryId, 50);
|
||||
expect(pruned).toBe(0);
|
||||
|
||||
const countAfter = await revisionRepo.countByEntry("post", entryId);
|
||||
expect(countAfter).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MediaRepository.cleanupPendingUploads", () => {
|
||||
let db: Kysely<Database>;
|
||||
let mediaRepo: MediaRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
mediaRepo = new MediaRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("deletes pending uploads older than the default 1 hour", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Create pending uploads
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await mediaRepo.createPending({
|
||||
filename: `pending-${i}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: `uploads/pending-${i}.jpg`,
|
||||
});
|
||||
}
|
||||
|
||||
// Advance past 1 hour
|
||||
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
const deletedKeys = await mediaRepo.cleanupPendingUploads();
|
||||
expect(deletedKeys).toHaveLength(10);
|
||||
// Verify actual storage keys are returned
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(deletedKeys).toContain(`uploads/pending-${i}.jpg`);
|
||||
}
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not delete recent pending uploads", async () => {
|
||||
// Create pending uploads (current time -- not yet expired)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await mediaRepo.createPending({
|
||||
filename: `recent-${i}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: `uploads/recent-${i}.jpg`,
|
||||
});
|
||||
}
|
||||
|
||||
const deletedKeys = await mediaRepo.cleanupPendingUploads();
|
||||
expect(deletedKeys).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not delete ready or failed items", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Create items with different statuses
|
||||
await mediaRepo.create({
|
||||
filename: "ready.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: "uploads/ready.jpg",
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
const pending = await mediaRepo.createPending({
|
||||
filename: "pending.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: "uploads/pending.jpg",
|
||||
});
|
||||
await mediaRepo.markFailed(pending.id);
|
||||
|
||||
// Advance past 1 hour
|
||||
vi.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
const deletedKeys = await mediaRepo.cleanupPendingUploads();
|
||||
expect(deletedKeys).toHaveLength(0); // failed + ready should not be deleted
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
const remaining = await db.selectFrom("media").select("id").execute();
|
||||
expect(remaining).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects custom maxAgeMs parameter", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
await mediaRepo.createPending({
|
||||
filename: "short-lived.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
storageKey: "uploads/short-lived.jpg",
|
||||
});
|
||||
|
||||
// Advance 10 minutes
|
||||
vi.advanceTimersByTime(10 * 60 * 1000);
|
||||
|
||||
// Cleanup with 5 min max age
|
||||
const deletedKeys = await mediaRepo.cleanupPendingUploads(5 * 60 * 1000);
|
||||
expect(deletedKeys).toHaveLength(1);
|
||||
expect(deletedKeys[0]).toBe("uploads/short-lived.jpg");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expired token cleanup", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("deletes expired tokens while keeping valid ones", async () => {
|
||||
const now = new Date();
|
||||
const expired = new Date(now.getTime() - 60 * 1000).toISOString(); // 1 min ago
|
||||
|
||||
// Create a test user first (tokens reference users)
|
||||
const userId = ulid();
|
||||
await db
|
||||
.insertInto("users")
|
||||
.values({
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: "Test",
|
||||
avatar_url: null,
|
||||
role: 50,
|
||||
email_verified: 1,
|
||||
disabled: 0,
|
||||
data: null,
|
||||
created_at: now.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create 100 expired tokens
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await db
|
||||
.insertInto("auth_tokens")
|
||||
.values({
|
||||
hash: `expired-hash-${i}`,
|
||||
user_id: userId,
|
||||
email: "test@example.com",
|
||||
type: "magic_link",
|
||||
role: null,
|
||||
invited_by: null,
|
||||
expires_at: expired,
|
||||
created_at: now.toISOString(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Create 5 valid tokens
|
||||
const validExpiry = new Date(now.getTime() + 15 * 60 * 1000).toISOString();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await db
|
||||
.insertInto("auth_tokens")
|
||||
.values({
|
||||
hash: `valid-hash-${i}`,
|
||||
user_id: userId,
|
||||
email: "test@example.com",
|
||||
type: "magic_link",
|
||||
role: null,
|
||||
invited_by: null,
|
||||
expires_at: validExpiry,
|
||||
created_at: now.toISOString(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Use the DB directly to simulate what deleteExpiredTokens does
|
||||
await db.deleteFrom("auth_tokens").where("expires_at", "<", new Date().toISOString()).execute();
|
||||
|
||||
// Verify only valid ones remain
|
||||
const remaining = await db.selectFrom("auth_tokens").select("hash").execute();
|
||||
|
||||
expect(remaining).toHaveLength(5);
|
||||
expect(remaining.every((r) => r.hash.startsWith("valid-"))).toBe(true);
|
||||
});
|
||||
});
|
||||
300
packages/core/tests/unit/cli/bundle-utils.test.ts
Normal file
300
packages/core/tests/unit/cli/bundle-utils.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Tests for bundle utility functions.
|
||||
*
|
||||
* Focuses on the functions where bugs would be non-obvious:
|
||||
* - Tarball round-trip (custom tar implementation)
|
||||
* - Manifest extraction (shape transformation, function stripping)
|
||||
* - Source entry resolution (path mapping logic)
|
||||
* - Node.js built-in detection (regex against bundled output)
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import {
|
||||
extractManifest,
|
||||
createTarball,
|
||||
resolveSourceEntry,
|
||||
findNodeBuiltinImports,
|
||||
findBuildOutput,
|
||||
} from "../../../src/cli/commands/bundle-utils.js";
|
||||
import type { ResolvedPlugin } from "../../../src/plugins/types.js";
|
||||
|
||||
function mockPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractManifest", () => {
|
||||
it("converts hooks from handler objects to name array", () => {
|
||||
const plugin = mockPlugin({
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler: vi.fn(),
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "abort",
|
||||
pluginId: "test",
|
||||
exclusive: false,
|
||||
},
|
||||
"media:afterUpload": {
|
||||
handler: vi.fn(),
|
||||
priority: 50,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "abort",
|
||||
pluginId: "test",
|
||||
exclusive: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
// content:beforeSave has all defaults → plain string
|
||||
// media:afterUpload has non-default priority → structured entry
|
||||
expect(manifest.hooks).toEqual([
|
||||
"content:beforeSave",
|
||||
{ name: "media:afterUpload", priority: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("converts routes from handler objects to name array", () => {
|
||||
const plugin = mockPlugin({
|
||||
routes: {
|
||||
sync: { handler: vi.fn() },
|
||||
webhook: { handler: vi.fn() },
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.routes).toEqual(["sync", "webhook"]);
|
||||
});
|
||||
|
||||
it("strips admin.entry (host-only concern, not in bundles)", () => {
|
||||
const plugin = mockPlugin({
|
||||
admin: {
|
||||
entry: "@test/plugin/admin",
|
||||
settingsSchema: { apiKey: { type: "string", label: "Key" } as any },
|
||||
pages: [{ id: "settings", title: "Settings" }],
|
||||
widgets: [],
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect((manifest.admin as any).entry).toBeUndefined();
|
||||
expect(manifest.admin.settingsSchema).toBeDefined();
|
||||
expect(manifest.admin.pages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("result is JSON-serializable (no functions survive)", () => {
|
||||
const plugin = mockPlugin({
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler: vi.fn(),
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "abort",
|
||||
pluginId: "test",
|
||||
exclusive: false,
|
||||
},
|
||||
},
|
||||
routes: { sync: { handler: vi.fn() } },
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
const json = JSON.stringify(manifest);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.hooks).toEqual(["content:beforeSave"]);
|
||||
expect(parsed.routes).toEqual(["sync"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTarball", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "emdash-tar-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("produces a tarball that system tar can list", async () => {
|
||||
const srcDir = join(tempDir, "src");
|
||||
await mkdir(srcDir);
|
||||
await writeFile(join(srcDir, "manifest.json"), '{"id":"test"}');
|
||||
await writeFile(join(srcDir, "backend.js"), "export default {}");
|
||||
|
||||
const out = join(tempDir, "out.tar.gz");
|
||||
await createTarball(srcDir, out);
|
||||
|
||||
const listing = execSync(`tar tzf "${out}"`, { encoding: "utf-8" });
|
||||
const files = listing.trim().split("\n").toSorted();
|
||||
expect(files).toContain("manifest.json");
|
||||
expect(files).toContain("backend.js");
|
||||
});
|
||||
|
||||
it("preserves file content through pack/unpack", async () => {
|
||||
const srcDir = join(tempDir, "src");
|
||||
await mkdir(srcDir);
|
||||
const content = JSON.stringify({ id: "round-trip", version: "2.0.0" });
|
||||
await writeFile(join(srcDir, "manifest.json"), content);
|
||||
|
||||
const out = join(tempDir, "out.tar.gz");
|
||||
await createTarball(srcDir, out);
|
||||
|
||||
const extractDir = join(tempDir, "extract");
|
||||
await mkdir(extractDir);
|
||||
execSync(`tar xzf "${out}" -C "${extractDir}"`);
|
||||
|
||||
expect(await readFile(join(extractDir, "manifest.json"), "utf-8")).toBe(content);
|
||||
});
|
||||
|
||||
it("handles nested directories (screenshots/)", async () => {
|
||||
const srcDir = join(tempDir, "src");
|
||||
await mkdir(join(srcDir, "screenshots"), { recursive: true });
|
||||
await writeFile(join(srcDir, "manifest.json"), "{}");
|
||||
await writeFile(join(srcDir, "screenshots", "shot1.png"), "fake");
|
||||
|
||||
const out = join(tempDir, "out.tar.gz");
|
||||
await createTarball(srcDir, out);
|
||||
|
||||
const listing = execSync(`tar tzf "${out}"`, { encoding: "utf-8" });
|
||||
expect(listing).toContain("screenshots/shot1.png");
|
||||
});
|
||||
|
||||
it("handles binary content without corruption", async () => {
|
||||
const srcDir = join(tempDir, "src");
|
||||
await mkdir(srcDir);
|
||||
// Write bytes that would break text-mode handling
|
||||
const binary = Buffer.from([0x00, 0xff, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
await writeFile(join(srcDir, "icon.png"), binary);
|
||||
|
||||
const out = join(tempDir, "out.tar.gz");
|
||||
await createTarball(srcDir, out);
|
||||
|
||||
const extractDir = join(tempDir, "extract");
|
||||
await mkdir(extractDir);
|
||||
execSync(`tar xzf "${out}" -C "${extractDir}"`);
|
||||
|
||||
const extracted = await readFile(join(extractDir, "icon.png"));
|
||||
expect(extracted.equals(binary)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSourceEntry", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "emdash-resolve-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("maps ./dist/index.mjs → src/index.ts", async () => {
|
||||
await mkdir(join(tempDir, "src"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "index.ts"), "");
|
||||
|
||||
const result = await resolveSourceEntry(tempDir, "./dist/index.mjs");
|
||||
expect(result).toBe(join(tempDir, "src", "index.ts"));
|
||||
});
|
||||
|
||||
it("maps ./dist/index.js → src/index.ts", async () => {
|
||||
await mkdir(join(tempDir, "src"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "index.ts"), "");
|
||||
|
||||
const result = await resolveSourceEntry(tempDir, "./dist/index.js");
|
||||
expect(result).toBe(join(tempDir, "src", "index.ts"));
|
||||
});
|
||||
|
||||
it("falls back to .tsx when .ts doesn't exist", async () => {
|
||||
await mkdir(join(tempDir, "src"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "index.tsx"), "");
|
||||
|
||||
const result = await resolveSourceEntry(tempDir, "./dist/index.mjs");
|
||||
expect(result).toBe(join(tempDir, "src", "index.tsx"));
|
||||
});
|
||||
|
||||
it("returns the direct path if it already exists", async () => {
|
||||
await mkdir(join(tempDir, "src"), { recursive: true });
|
||||
await writeFile(join(tempDir, "src", "index.ts"), "");
|
||||
|
||||
const result = await resolveSourceEntry(tempDir, "src/index.ts");
|
||||
expect(result).toBe(join(tempDir, "src", "index.ts"));
|
||||
});
|
||||
|
||||
it("returns undefined when nothing matches", async () => {
|
||||
const result = await resolveSourceEntry(tempDir, "./dist/missing.mjs");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBuildOutput", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "emdash-build-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prefers .mjs over .js", async () => {
|
||||
await writeFile(join(tempDir, "index.mjs"), "");
|
||||
await writeFile(join(tempDir, "index.js"), "");
|
||||
|
||||
expect(await findBuildOutput(tempDir, "index")).toBe(join(tempDir, "index.mjs"));
|
||||
});
|
||||
|
||||
it("falls back through .js then .cjs", async () => {
|
||||
await writeFile(join(tempDir, "index.cjs"), "");
|
||||
expect(await findBuildOutput(tempDir, "index")).toBe(join(tempDir, "index.cjs"));
|
||||
});
|
||||
|
||||
it("returns undefined when no match", async () => {
|
||||
expect(await findBuildOutput(tempDir, "index")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNodeBuiltinImports", () => {
|
||||
it("detects require('node:fs') in bundled output", () => {
|
||||
expect(findNodeBuiltinImports(`const fs = require("node:fs");`)).toEqual(["fs"]);
|
||||
});
|
||||
|
||||
it("detects require('fs') without node: prefix", () => {
|
||||
expect(findNodeBuiltinImports(`const fs = require("fs");`)).toEqual(["fs"]);
|
||||
});
|
||||
|
||||
it("detects dynamic import('node:child_process')", () => {
|
||||
expect(findNodeBuiltinImports(`await import("node:child_process")`)).toEqual(["child_process"]);
|
||||
});
|
||||
|
||||
it("returns empty for code with no builtins", () => {
|
||||
expect(findNodeBuiltinImports(`import("emdash"); require("lodash");`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated requires", () => {
|
||||
const code = `require("node:fs"); require("node:fs");`;
|
||||
expect(findNodeBuiltinImports(code)).toEqual(["fs"]);
|
||||
});
|
||||
});
|
||||
289
packages/core/tests/unit/cli/seed-commands.test.ts
Normal file
289
packages/core/tests/unit/cli/seed-commands.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Tests for CLI seed commands
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import { applySeed } from "../../../src/seed/apply.js";
|
||||
import type { SeedFile } from "../../../src/seed/types.js";
|
||||
import { validateSeed } from "../../../src/seed/validate.js";
|
||||
|
||||
describe("CLI Seed Commands", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "emdash-cli-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("seed file resolution", () => {
|
||||
it("should resolve .emdash/seed.json by convention", async () => {
|
||||
// Create convention seed file
|
||||
const emdashDir = join(tempDir, ".emdash");
|
||||
await mkdir(emdashDir);
|
||||
const seedPath = join(emdashDir, "seed.json");
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
settings: { title: "Convention Seed" },
|
||||
};
|
||||
await writeFile(seedPath, JSON.stringify(seed));
|
||||
|
||||
// Read it back
|
||||
const content = await readFile(seedPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.settings.title).toBe("Convention Seed");
|
||||
});
|
||||
|
||||
it("should resolve seed from package.json emdash.seed", async () => {
|
||||
// Create seed file in custom location
|
||||
const customDir = join(tempDir, "custom");
|
||||
await mkdir(customDir);
|
||||
const seedPath = join(customDir, "my-seed.json");
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
settings: { title: "Package.json Seed" },
|
||||
};
|
||||
await writeFile(seedPath, JSON.stringify(seed));
|
||||
|
||||
// Create package.json referencing it
|
||||
const pkg = {
|
||||
name: "test-project",
|
||||
emdash: {
|
||||
seed: "custom/my-seed.json",
|
||||
},
|
||||
};
|
||||
await writeFile(join(tempDir, "package.json"), JSON.stringify(pkg));
|
||||
|
||||
// Verify the referenced path works
|
||||
const content = await readFile(seedPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.settings.title).toBe("Package.json Seed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seed validation", () => {
|
||||
it("should validate a valid seed file", () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
settings: { title: "Test Site" },
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string", required: true }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateSeed(seed);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should reject invalid seed version", () => {
|
||||
const seed = {
|
||||
version: "999",
|
||||
settings: {},
|
||||
};
|
||||
|
||||
const result = validateSeed(seed);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("version"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject seed with invalid collection", () => {
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "", // Invalid: empty slug
|
||||
label: "Posts",
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = validateSeed(seed);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seed application", () => {
|
||||
it("should apply settings from seed", async () => {
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
await runMigrations(db);
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
settings: {
|
||||
title: "My Test Site",
|
||||
tagline: "A test site for testing",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {});
|
||||
|
||||
expect(result.settings.applied).toBe(2);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should apply collections from seed", async () => {
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
await runMigrations(db);
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "articles",
|
||||
label: "Articles",
|
||||
labelSingular: "Article",
|
||||
fields: [
|
||||
{
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
{ slug: "body", label: "Body", type: "portableText" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, {});
|
||||
|
||||
expect(result.collections.created).toBe(1);
|
||||
expect(result.fields.created).toBe(2);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("should be idempotent (skip existing)", async () => {
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
await runMigrations(db);
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "pages",
|
||||
label: "Pages",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// First apply
|
||||
const result1 = await applySeed(db, seed, {});
|
||||
expect(result1.collections.created).toBe(1);
|
||||
expect(result1.collections.skipped).toBe(0);
|
||||
|
||||
// Second apply - should skip
|
||||
const result2 = await applySeed(db, seed, {});
|
||||
expect(result2.collections.created).toBe(0);
|
||||
expect(result2.collections.skipped).toBe(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("export-seed output", () => {
|
||||
it("should produce valid seed from exported data", async () => {
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
await runMigrations(db);
|
||||
|
||||
// Apply a seed first
|
||||
const inputSeed: SeedFile = {
|
||||
version: "1",
|
||||
settings: { title: "Export Test" },
|
||||
collections: [
|
||||
{
|
||||
slug: "docs",
|
||||
label: "Documentation",
|
||||
fields: [
|
||||
{ slug: "title", label: "Title", type: "string" },
|
||||
{ slug: "content", label: "Content", type: "portableText" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await applySeed(db, inputSeed, {});
|
||||
|
||||
// Now export (simulating what export-seed does)
|
||||
// For this test, we just verify the input seed validates
|
||||
const validation = validateSeed(inputSeed);
|
||||
expect(validation.valid).toBe(true);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("content export with $media", () => {
|
||||
it("should handle content without media gracefully", async () => {
|
||||
const dbPath = join(tempDir, "test.db");
|
||||
const db = createDatabase({ url: `file:${dbPath}` });
|
||||
|
||||
try {
|
||||
await runMigrations(db);
|
||||
|
||||
const seed: SeedFile = {
|
||||
version: "1",
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", label: "Title", type: "string" }],
|
||||
},
|
||||
],
|
||||
content: {
|
||||
posts: [
|
||||
{
|
||||
id: "post-1",
|
||||
slug: "hello-world",
|
||||
status: "published",
|
||||
data: { title: "Hello World" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applySeed(db, seed, { includeContent: true });
|
||||
|
||||
expect(result.collections.created).toBe(1);
|
||||
expect(result.content.created).toBe(1);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
427
packages/core/tests/unit/cli/wxr-parser.test.ts
Normal file
427
packages/core/tests/unit/cli/wxr-parser.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Tests for WXR parser
|
||||
*/
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { parseWxr } from "../../../src/cli/wxr/parser.js";
|
||||
|
||||
function createStream(content: string): Readable {
|
||||
return Readable.from([content]);
|
||||
}
|
||||
|
||||
describe("parseWxr", () => {
|
||||
it("parses basic WXR structure", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Test Site</title>
|
||||
<link>https://example.com</link>
|
||||
<description>A test WordPress site</description>
|
||||
<language>en-US</language>
|
||||
<wp:base_site_url>https://example.com</wp:base_site_url>
|
||||
<wp:base_blog_url>https://example.com</wp:base_blog_url>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.site.title).toBe("Test Site");
|
||||
expect(result.site.link).toBe("https://example.com");
|
||||
expect(result.site.description).toBe("A test WordPress site");
|
||||
expect(result.site.language).toBe("en-US");
|
||||
});
|
||||
|
||||
it("parses posts", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<title>Test Site</title>
|
||||
<item>
|
||||
<title>Hello World</title>
|
||||
<link>https://example.com/hello-world/</link>
|
||||
<pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate>
|
||||
<dc:creator>admin</dc:creator>
|
||||
<content:encoded><![CDATA[<!-- wp:paragraph -->
|
||||
<p>Welcome to WordPress!</p>
|
||||
<!-- /wp:paragraph -->]]></content:encoded>
|
||||
<wp:post_id>1</wp:post_id>
|
||||
<wp:post_date>2024-01-01 12:00:00</wp:post_date>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:post_name>hello-world</wp:post_name>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts).toHaveLength(1);
|
||||
expect(result.posts[0]?.title).toBe("Hello World");
|
||||
expect(result.posts[0]?.id).toBe(1);
|
||||
expect(result.posts[0]?.status).toBe("publish");
|
||||
expect(result.posts[0]?.postType).toBe("post");
|
||||
expect(result.posts[0]?.content).toContain("wp:paragraph");
|
||||
});
|
||||
|
||||
it("parses pages", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>About Us</title>
|
||||
<content:encoded><![CDATA[<p>About page content</p>]]></content:encoded>
|
||||
<wp:post_id>2</wp:post_id>
|
||||
<wp:status>publish</wp:status>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts).toHaveLength(1);
|
||||
expect(result.posts[0]?.title).toBe("About Us");
|
||||
expect(result.posts[0]?.postType).toBe("page");
|
||||
});
|
||||
|
||||
it("parses attachments", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Test Image</title>
|
||||
<wp:post_id>10</wp:post_id>
|
||||
<wp:post_type>attachment</wp:post_type>
|
||||
<wp:attachment_url>https://example.com/wp-content/uploads/2024/01/test.jpg</wp:attachment_url>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts).toHaveLength(0);
|
||||
expect(result.attachments).toHaveLength(1);
|
||||
expect(result.attachments[0]?.id).toBe(10);
|
||||
expect(result.attachments[0]?.title).toBe("Test Image");
|
||||
expect(result.attachments[0]?.url).toContain("test.jpg");
|
||||
});
|
||||
|
||||
it("parses categories", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<wp:category>
|
||||
<wp:term_id>1</wp:term_id>
|
||||
<wp:category_nicename>uncategorized</wp:category_nicename>
|
||||
<wp:cat_name><![CDATA[Uncategorized]]></wp:cat_name>
|
||||
</wp:category>
|
||||
<wp:category>
|
||||
<wp:term_id>2</wp:term_id>
|
||||
<wp:category_nicename>news</wp:category_nicename>
|
||||
<wp:cat_name><![CDATA[News]]></wp:cat_name>
|
||||
<wp:category_parent>uncategorized</wp:category_parent>
|
||||
</wp:category>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.categories).toHaveLength(2);
|
||||
expect(result.categories[0]?.nicename).toBe("uncategorized");
|
||||
expect(result.categories[0]?.name).toBe("Uncategorized");
|
||||
expect(result.categories[1]?.parent).toBe("uncategorized");
|
||||
});
|
||||
|
||||
it("parses tags", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<wp:tag>
|
||||
<wp:term_id>5</wp:term_id>
|
||||
<wp:tag_slug>javascript</wp:tag_slug>
|
||||
<wp:tag_name><![CDATA[JavaScript]]></wp:tag_name>
|
||||
</wp:tag>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.tags).toHaveLength(1);
|
||||
expect(result.tags[0]?.slug).toBe("javascript");
|
||||
expect(result.tags[0]?.name).toBe("JavaScript");
|
||||
});
|
||||
|
||||
it("parses post categories and tags", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Tagged Post</title>
|
||||
<category domain="category" nicename="news"><![CDATA[News]]></category>
|
||||
<category domain="post_tag" nicename="javascript"><![CDATA[JavaScript]]></category>
|
||||
<category domain="post_tag" nicename="typescript"><![CDATA[TypeScript]]></category>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts[0]?.categories).toContain("news");
|
||||
expect(result.posts[0]?.tags).toContain("javascript");
|
||||
expect(result.posts[0]?.tags).toContain("typescript");
|
||||
});
|
||||
|
||||
it("parses authors", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<wp:author>
|
||||
<wp:author_id>1</wp:author_id>
|
||||
<wp:author_login>admin</wp:author_login>
|
||||
<wp:author_email>admin@example.com</wp:author_email>
|
||||
<wp:author_display_name><![CDATA[Administrator]]></wp:author_display_name>
|
||||
<wp:author_first_name>Admin</wp:author_first_name>
|
||||
<wp:author_last_name>User</wp:author_last_name>
|
||||
</wp:author>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.authors).toHaveLength(1);
|
||||
expect(result.authors[0]?.login).toBe("admin");
|
||||
expect(result.authors[0]?.email).toBe("admin@example.com");
|
||||
expect(result.authors[0]?.displayName).toBe("Administrator");
|
||||
});
|
||||
|
||||
it("parses post meta", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Post with Meta</title>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_yoast_wpseo_title</wp:meta_key>
|
||||
<wp:meta_value>SEO Title</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_yoast_wpseo_metadesc</wp:meta_key>
|
||||
<wp:meta_value>SEO Description</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts[0]?.meta.get("_yoast_wpseo_title")).toBe("SEO Title");
|
||||
expect(result.posts[0]?.meta.get("_yoast_wpseo_metadesc")).toBe("SEO Description");
|
||||
});
|
||||
|
||||
it("handles empty WXR", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Empty Site</title>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts).toHaveLength(0);
|
||||
expect(result.attachments).toHaveLength(0);
|
||||
expect(result.categories).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses page hierarchy (post_parent and menu_order)", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Parent Page</title>
|
||||
<wp:post_id>10</wp:post_id>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<wp:post_parent>0</wp:post_parent>
|
||||
<wp:menu_order>1</wp:menu_order>
|
||||
</item>
|
||||
<item>
|
||||
<title>Child Page</title>
|
||||
<wp:post_id>11</wp:post_id>
|
||||
<wp:post_type>page</wp:post_type>
|
||||
<wp:post_parent>10</wp:post_parent>
|
||||
<wp:menu_order>2</wp:menu_order>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts).toHaveLength(2);
|
||||
expect(result.posts[0]?.postParent).toBe(0);
|
||||
expect(result.posts[0]?.menuOrder).toBe(1);
|
||||
expect(result.posts[1]?.postParent).toBe(10);
|
||||
expect(result.posts[1]?.menuOrder).toBe(2);
|
||||
});
|
||||
|
||||
it("parses generic wp:term elements (custom taxonomies)", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<wp:term>
|
||||
<wp:term_id>100</wp:term_id>
|
||||
<wp:term_taxonomy>genre</wp:term_taxonomy>
|
||||
<wp:term_slug>sci-fi</wp:term_slug>
|
||||
<wp:term_name><![CDATA[Science Fiction]]></wp:term_name>
|
||||
<wp:term_description><![CDATA[Science fiction books]]></wp:term_description>
|
||||
</wp:term>
|
||||
<wp:term>
|
||||
<wp:term_id>101</wp:term_id>
|
||||
<wp:term_taxonomy>genre</wp:term_taxonomy>
|
||||
<wp:term_slug>fantasy</wp:term_slug>
|
||||
<wp:term_name><![CDATA[Fantasy]]></wp:term_name>
|
||||
<wp:term_parent>sci-fi</wp:term_parent>
|
||||
</wp:term>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.terms).toHaveLength(2);
|
||||
expect(result.terms[0]?.id).toBe(100);
|
||||
expect(result.terms[0]?.taxonomy).toBe("genre");
|
||||
expect(result.terms[0]?.slug).toBe("sci-fi");
|
||||
expect(result.terms[0]?.name).toBe("Science Fiction");
|
||||
expect(result.terms[0]?.description).toBe("Science fiction books");
|
||||
expect(result.terms[1]?.parent).toBe("sci-fi");
|
||||
});
|
||||
|
||||
it("parses nav_menu terms and nav_menu_item posts into structured menus", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<wp:term>
|
||||
<wp:term_id>5</wp:term_id>
|
||||
<wp:term_taxonomy>nav_menu</wp:term_taxonomy>
|
||||
<wp:term_slug>main-menu</wp:term_slug>
|
||||
<wp:term_name><![CDATA[Main Menu]]></wp:term_name>
|
||||
</wp:term>
|
||||
<item>
|
||||
<title>Home</title>
|
||||
<wp:post_id>50</wp:post_id>
|
||||
<wp:post_type>nav_menu_item</wp:post_type>
|
||||
<wp:menu_order>1</wp:menu_order>
|
||||
<category domain="nav_menu" nicename="main-menu"><![CDATA[Main Menu]]></category>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_type</wp:meta_key>
|
||||
<wp:meta_value>custom</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_url</wp:meta_key>
|
||||
<wp:meta_value>https://example.com/</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_menu_item_parent</wp:meta_key>
|
||||
<wp:meta_value>0</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
</item>
|
||||
<item>
|
||||
<title>About</title>
|
||||
<wp:post_id>51</wp:post_id>
|
||||
<wp:post_type>nav_menu_item</wp:post_type>
|
||||
<wp:menu_order>2</wp:menu_order>
|
||||
<category domain="nav_menu" nicename="main-menu"><![CDATA[Main Menu]]></category>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_type</wp:meta_key>
|
||||
<wp:meta_value>post_type</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_object</wp:meta_key>
|
||||
<wp:meta_value>page</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_object_id</wp:meta_key>
|
||||
<wp:meta_value>10</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key>_menu_item_menu_item_parent</wp:meta_key>
|
||||
<wp:meta_value>0</wp:meta_value>
|
||||
</wp:postmeta>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
// Check terms array includes nav_menu term
|
||||
expect(result.terms.some((t) => t.taxonomy === "nav_menu")).toBe(true);
|
||||
|
||||
// Check nav_menu_item posts are in posts array
|
||||
expect(result.posts.filter((p) => p.postType === "nav_menu_item")).toHaveLength(2);
|
||||
|
||||
// Check structured navMenus
|
||||
expect(result.navMenus).toHaveLength(1);
|
||||
expect(result.navMenus[0]?.name).toBe("main-menu");
|
||||
expect(result.navMenus[0]?.id).toBe(5);
|
||||
expect(result.navMenus[0]?.items).toHaveLength(2);
|
||||
|
||||
// Check menu items are sorted by menu_order
|
||||
expect(result.navMenus[0]?.items[0]?.title).toBe("Home");
|
||||
expect(result.navMenus[0]?.items[0]?.type).toBe("custom");
|
||||
expect(result.navMenus[0]?.items[0]?.url).toBe("https://example.com/");
|
||||
expect(result.navMenus[0]?.items[0]?.sortOrder).toBe(1);
|
||||
|
||||
expect(result.navMenus[0]?.items[1]?.title).toBe("About");
|
||||
expect(result.navMenus[0]?.items[1]?.type).toBe("post_type");
|
||||
expect(result.navMenus[0]?.items[1]?.objectType).toBe("page");
|
||||
expect(result.navMenus[0]?.items[1]?.objectId).toBe(10);
|
||||
expect(result.navMenus[0]?.items[1]?.sortOrder).toBe(2);
|
||||
});
|
||||
|
||||
it("parses custom taxonomy assignments on posts", async () => {
|
||||
const wxr = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:wp="http://wordpress.org/export/1.2/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Book Review</title>
|
||||
<wp:post_id>1</wp:post_id>
|
||||
<wp:post_type>post</wp:post_type>
|
||||
<category domain="category" nicename="reviews"><![CDATA[Reviews]]></category>
|
||||
<category domain="genre" nicename="sci-fi"><![CDATA[Science Fiction]]></category>
|
||||
<category domain="genre" nicename="dystopian"><![CDATA[Dystopian]]></category>
|
||||
<category domain="reading_level" nicename="advanced"><![CDATA[Advanced]]></category>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
const result = await parseWxr(createStream(wxr));
|
||||
|
||||
expect(result.posts[0]?.categories).toContain("reviews");
|
||||
expect(result.posts[0]?.customTaxonomies?.get("genre")).toContain("sci-fi");
|
||||
expect(result.posts[0]?.customTaxonomies?.get("genre")).toContain("dystopian");
|
||||
expect(result.posts[0]?.customTaxonomies?.get("reading_level")).toContain("advanced");
|
||||
});
|
||||
});
|
||||
641
packages/core/tests/unit/client/client.test.ts
Normal file
641
packages/core/tests/unit/client/client.test.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { EmDashClient, EmDashApiError } from "../../../src/client/index.js";
|
||||
import type { Interceptor } from "../../../src/client/transport.js";
|
||||
|
||||
// Regex patterns for route matching
|
||||
const CONTENT_POSTS_ABC_REGEX = /\/content\/posts\/abc/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MockRoute {
|
||||
method: string;
|
||||
path: RegExp | string;
|
||||
handler: (req: Request) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock HTTP backend as an interceptor.
|
||||
* Routes are matched in order. Unmatched requests return 404.
|
||||
*/
|
||||
function createMockBackend(routes: MockRoute[]): Interceptor {
|
||||
return async (req) => {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname + url.search;
|
||||
|
||||
for (const route of routes) {
|
||||
if (req.method !== route.method) continue;
|
||||
if (typeof route.path === "string") {
|
||||
if (!path.includes(route.path)) continue;
|
||||
} else {
|
||||
if (!route.path.test(path)) continue;
|
||||
}
|
||||
return route.handler(req);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: { code: "NOT_FOUND", message: "No matching route" } }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps body in `{ data: body }` to match the standard API response envelope. */
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
// Error responses (4xx/5xx) are NOT wrapped in { data }
|
||||
const payload = status >= 400 ? body : { data: body };
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("EmDashClient", () => {
|
||||
describe("_rev token flow", () => {
|
||||
it("blind update (no _rev) succeeds", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", type: "string", label: "Title" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PUT",
|
||||
path: CONTENT_POSTS_ABC_REGEX,
|
||||
handler: async (req) => {
|
||||
const body = (await req.json()) as Record<string, unknown>;
|
||||
// No _rev should be sent
|
||||
expect(body._rev).toBeUndefined();
|
||||
return jsonResponse({
|
||||
item: { id: "abc", data: { title: "Blind" } },
|
||||
_rev: "newrev",
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const updated = await client.update("posts", "abc", {
|
||||
data: { title: "Blind" },
|
||||
});
|
||||
expect(updated.data.title).toBe("Blind");
|
||||
});
|
||||
|
||||
it("get() returns _rev on the item", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", type: "string", label: "Title" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: CONTENT_POSTS_ABC_REGEX,
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
id: "abc",
|
||||
type: "posts",
|
||||
slug: "hello",
|
||||
status: "draft",
|
||||
data: { title: "Hello" },
|
||||
authorId: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
},
|
||||
_rev: "dGVzdHJldg",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const post = await client.get("posts", "abc");
|
||||
expect(post.id).toBe("abc");
|
||||
expect(post._rev).toBe("dGVzdHJldg");
|
||||
});
|
||||
|
||||
it("update() sends _rev when provided", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", type: "string", label: "Title" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "PUT",
|
||||
path: CONTENT_POSTS_ABC_REGEX,
|
||||
handler: async (req) => {
|
||||
const body = await req.json();
|
||||
expect((body as Record<string, unknown>)._rev).toBe("dGVzdHJldg");
|
||||
return jsonResponse({
|
||||
item: { id: "abc", data: { title: "Updated" } },
|
||||
_rev: "bmV3cmV2",
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const updated = await client.update("posts", "abc", {
|
||||
data: { title: "Updated" },
|
||||
_rev: "dGVzdHJldg",
|
||||
});
|
||||
expect(updated.data.title).toBe("Updated");
|
||||
expect(updated._rev).toBe("bmV3cmV2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create()", () => {
|
||||
it("does not require a prior get()", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [{ slug: "title", type: "string", label: "Title" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/content/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
id: "new1",
|
||||
type: "posts",
|
||||
slug: "hello",
|
||||
status: "draft",
|
||||
data: { title: "Hello" },
|
||||
authorId: null,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
publishedAt: null,
|
||||
scheduledAt: null,
|
||||
liveRevisionId: null,
|
||||
draftRevisionId: null,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const item = await client.create("posts", {
|
||||
data: { title: "Hello" },
|
||||
slug: "hello",
|
||||
});
|
||||
expect(item.id).toBe("new1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API error handling", () => {
|
||||
it("throws EmDashApiError on 4xx responses", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections",
|
||||
handler: () => jsonResponse({ error: { code: "FORBIDDEN", message: "No access" } }, 403),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
try {
|
||||
await client.collections();
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EmDashApiError);
|
||||
const apiErr = error as EmDashApiError;
|
||||
expect(apiErr.status).toBe(403);
|
||||
expect(apiErr.code).toBe("FORBIDDEN");
|
||||
expect(apiErr.message).toBe("No access");
|
||||
}
|
||||
});
|
||||
|
||||
it("throws EmDashApiError on 500 responses", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/manifest",
|
||||
handler: () =>
|
||||
jsonResponse(
|
||||
{
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Something broke",
|
||||
},
|
||||
},
|
||||
500,
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
try {
|
||||
await client.manifest();
|
||||
expect.fail("Should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(EmDashApiError);
|
||||
expect((error as EmDashApiError).status).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("list()", () => {
|
||||
it("returns items and nextCursor", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/content/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
type: "posts",
|
||||
slug: "a",
|
||||
status: "published",
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "posts",
|
||||
slug: "b",
|
||||
status: "published",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
nextCursor: "cursor123",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const result = await client.list("posts", { status: "published" });
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.nextCursor).toBe("cursor123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAll()", () => {
|
||||
it("follows cursors until exhaustion", async () => {
|
||||
let page = 0;
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/content/posts",
|
||||
handler: () => {
|
||||
page++;
|
||||
if (page === 1) {
|
||||
return jsonResponse({
|
||||
items: [{ id: "1", data: {} }],
|
||||
nextCursor: "page2",
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
items: [{ id: "2", data: {} }],
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const all = [];
|
||||
for await (const item of client.listAll("posts")) {
|
||||
all.push(item);
|
||||
}
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0]?.id).toBe("1");
|
||||
expect(all[1]?.id).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete/publish/unpublish/schedule/restore", () => {
|
||||
it("calls the correct endpoints", async () => {
|
||||
const calledPaths: string[] = [];
|
||||
|
||||
const backend: Interceptor = async (req) => {
|
||||
calledPaths.push(`${req.method} ${new URL(req.url).pathname}`);
|
||||
return jsonResponse({});
|
||||
};
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
await client.delete("posts", "abc");
|
||||
await client.publish("posts", "abc");
|
||||
await client.unpublish("posts", "abc");
|
||||
await client.schedule("posts", "abc", { at: "2026-03-01T00:00:00Z" });
|
||||
await client.restore("posts", "abc");
|
||||
|
||||
expect(calledPaths).toEqual([
|
||||
"DELETE /_emdash/api/content/posts/abc",
|
||||
"POST /_emdash/api/content/posts/abc/publish",
|
||||
"POST /_emdash/api/content/posts/abc/unpublish",
|
||||
"POST /_emdash/api/content/posts/abc/schedule",
|
||||
"POST /_emdash/api/content/posts/abc/restore",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema methods", () => {
|
||||
it("collections() returns list", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
items: [
|
||||
{ slug: "posts", label: "Posts", supports: [] },
|
||||
{ slug: "pages", label: "Pages", supports: [] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const cols = await client.collections();
|
||||
expect(cols).toHaveLength(2);
|
||||
expect(cols[0]?.slug).toBe("posts");
|
||||
});
|
||||
|
||||
it("createCollection() sends correct payload", async () => {
|
||||
let capturedBody: unknown;
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/schema/collections",
|
||||
handler: async (req) => {
|
||||
capturedBody = await req.json();
|
||||
return jsonResponse({
|
||||
item: {
|
||||
slug: "events",
|
||||
label: "Events",
|
||||
labelSingular: "Event",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
await client.createCollection({
|
||||
slug: "events",
|
||||
label: "Events",
|
||||
labelSingular: "Event",
|
||||
});
|
||||
|
||||
expect(capturedBody).toEqual({
|
||||
slug: "events",
|
||||
label: "Events",
|
||||
labelSingular: "Event",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PT <-> Markdown auto-conversion", () => {
|
||||
it("converts PT fields to markdown on get()", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
fields: [
|
||||
{ slug: "title", type: "string", label: "Title" },
|
||||
{ slug: "body", type: "portableText", label: "Body" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: CONTENT_POSTS_ABC_REGEX,
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
id: "abc",
|
||||
type: "posts",
|
||||
data: {
|
||||
title: "Hello",
|
||||
body: [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [
|
||||
{
|
||||
_type: "span",
|
||||
text: "World",
|
||||
marks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
_rev: "rev1",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const item = await client.get("posts", "abc");
|
||||
expect(item.data.title).toBe("Hello");
|
||||
expect(typeof item.data.body).toBe("string");
|
||||
expect(item.data.body).toContain("World");
|
||||
});
|
||||
|
||||
it("returns raw PT when raw: true", async () => {
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
fields: [{ slug: "body", type: "portableText", label: "Body" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: CONTENT_POSTS_ABC_REGEX,
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
id: "abc",
|
||||
data: {
|
||||
body: [
|
||||
{
|
||||
_type: "block",
|
||||
children: [{ _type: "span", text: "Raw" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
_rev: "rev1",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
const item = await client.get("posts", "abc", { raw: true });
|
||||
expect(Array.isArray(item.data.body)).toBe(true);
|
||||
});
|
||||
|
||||
it("converts markdown to PT on create()", async () => {
|
||||
let capturedData: Record<string, unknown> | undefined;
|
||||
|
||||
const backend = createMockBackend([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/schema/collections/posts",
|
||||
handler: () =>
|
||||
jsonResponse({
|
||||
item: {
|
||||
slug: "posts",
|
||||
fields: [
|
||||
{ slug: "title", type: "string", label: "Title" },
|
||||
{ slug: "body", type: "portableText", label: "Body" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/content/posts",
|
||||
handler: async (req) => {
|
||||
const body = (await req.json()) as Record<string, unknown>;
|
||||
capturedData = body.data as Record<string, unknown>;
|
||||
return jsonResponse({
|
||||
item: {
|
||||
id: "new1",
|
||||
data: capturedData,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const client = new EmDashClient({
|
||||
baseUrl: "http://localhost:4321",
|
||||
token: "test",
|
||||
interceptors: [backend],
|
||||
});
|
||||
|
||||
await client.create("posts", {
|
||||
data: {
|
||||
title: "Hello",
|
||||
body: "Some **bold** text",
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedData).toBeDefined();
|
||||
expect(capturedData!.title).toBe("Hello");
|
||||
expect(Array.isArray(capturedData!.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
546
packages/core/tests/unit/client/portable-text.test.ts
Normal file
546
packages/core/tests/unit/client/portable-text.test.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { PortableTextBlock, FieldSchema } from "../../../src/client/portable-text.js";
|
||||
import {
|
||||
portableTextToMarkdown,
|
||||
markdownToPortableText,
|
||||
resetKeyCounter,
|
||||
convertDataForRead,
|
||||
convertDataForWrite,
|
||||
} from "../../../src/client/portable-text.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetKeyCounter();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PT -> Markdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("portableTextToMarkdown", () => {
|
||||
it("converts a simple paragraph", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
_key: "a",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", _key: "s1", text: "Hello world", marks: [] }],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("Hello world\n");
|
||||
});
|
||||
|
||||
it("converts headings h1-h6", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "h1",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Title", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "h3",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Subtitle", marks: [] }],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("# Title\n\n### Subtitle\n");
|
||||
});
|
||||
|
||||
it("converts bold, italic, code, and strikethrough marks", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [
|
||||
{ _type: "span", text: "bold", marks: ["strong"] },
|
||||
{ _type: "span", text: " and ", marks: [] },
|
||||
{ _type: "span", text: "italic", marks: ["em"] },
|
||||
{ _type: "span", text: " and ", marks: [] },
|
||||
{ _type: "span", text: "code", marks: ["code"] },
|
||||
{ _type: "span", text: " and ", marks: [] },
|
||||
{ _type: "span", text: "struck", marks: ["strike-through"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe(
|
||||
"**bold** and _italic_ and `code` and ~~struck~~\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts links via markDefs", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [{ _key: "link1", _type: "link", href: "https://example.com" }],
|
||||
children: [
|
||||
{ _type: "span", text: "Click ", marks: [] },
|
||||
{ _type: "span", text: "here", marks: ["link1"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("Click [here](https://example.com)\n");
|
||||
});
|
||||
|
||||
it("converts blockquotes", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "blockquote",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "A quote", marks: [] }],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("> A quote\n");
|
||||
});
|
||||
|
||||
it("converts unordered lists", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
listItem: "bullet",
|
||||
level: 1,
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "First", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
listItem: "bullet",
|
||||
level: 1,
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Second", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
listItem: "bullet",
|
||||
level: 2,
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Nested", marks: [] }],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("- First\n- Second\n - Nested\n");
|
||||
});
|
||||
|
||||
it("converts ordered lists", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
listItem: "number",
|
||||
level: 1,
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "First", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
listItem: "number",
|
||||
level: 1,
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Second", marks: [] }],
|
||||
},
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("1. First\n1. Second\n");
|
||||
});
|
||||
|
||||
it("converts code blocks", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{ _type: "code", _key: "c1", language: "typescript", code: "const x = 1;\nconsole.log(x);" },
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe(
|
||||
"```typescript\nconst x = 1;\nconsole.log(x);\n```\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts images", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{ _type: "image", _key: "i1", alt: "A cat", asset: { url: "/img/cat.jpg" } },
|
||||
];
|
||||
expect(portableTextToMarkdown(blocks)).toBe("\n");
|
||||
});
|
||||
|
||||
it("serializes unknown blocks as opaque fences", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Before", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "pluginWidget",
|
||||
_key: "pw1",
|
||||
config: { layout: "grid", items: 3 },
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "After", marks: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(blocks);
|
||||
expect(md).toContain("Before");
|
||||
expect(md).toContain("After");
|
||||
expect(md).toContain("<!--ec:block ");
|
||||
expect(md).toContain('"_type":"pluginWidget"');
|
||||
expect(md).toContain('"layout":"grid"');
|
||||
});
|
||||
|
||||
it("handles mixed content with paragraphs, headings, and lists", () => {
|
||||
const blocks: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "h1",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Title", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "A paragraph.", marks: [] }],
|
||||
},
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
listItem: "bullet",
|
||||
level: 1,
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Item", marks: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(blocks);
|
||||
expect(md).toContain("# Title");
|
||||
expect(md).toContain("A paragraph.");
|
||||
expect(md).toContain("- Item");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown -> PT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("markdownToPortableText", () => {
|
||||
it("converts a simple paragraph", () => {
|
||||
const blocks = markdownToPortableText("Hello world\n");
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]._type).toBe("block");
|
||||
expect(blocks[0].style).toBe("normal");
|
||||
expect(blocks[0].children).toHaveLength(1);
|
||||
expect((blocks[0].children[0] as { text: string }).text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("converts headings", () => {
|
||||
const blocks = markdownToPortableText("# Title\n\n### Subtitle\n");
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0].style).toBe("h1");
|
||||
expect(blocks[1].style).toBe("h3");
|
||||
});
|
||||
|
||||
it("converts bold and italic", () => {
|
||||
const blocks = markdownToPortableText("Some **bold** and _italic_ text\n");
|
||||
expect(blocks).toHaveLength(1);
|
||||
const children = blocks[0].children;
|
||||
expect(children.length).toBeGreaterThan(1);
|
||||
|
||||
const boldSpan = children.find((c) => (c.marks ?? []).includes("strong"));
|
||||
expect(boldSpan).toBeDefined();
|
||||
expect(boldSpan!.text).toBe("bold");
|
||||
|
||||
const italicSpan = children.find((c) => (c.marks ?? []).includes("em"));
|
||||
expect(italicSpan).toBeDefined();
|
||||
expect(italicSpan!.text).toBe("italic");
|
||||
});
|
||||
|
||||
it("converts inline code", () => {
|
||||
const blocks = markdownToPortableText("Use `foo()` here\n");
|
||||
const children = blocks[0].children;
|
||||
const codeSpan = children.find((c) => (c.marks ?? []).includes("code"));
|
||||
expect(codeSpan).toBeDefined();
|
||||
expect(codeSpan!.text).toBe("foo()");
|
||||
});
|
||||
|
||||
it("converts links with markDefs", () => {
|
||||
const blocks = markdownToPortableText("Click [here](https://example.com)\n");
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].markDefs).toHaveLength(1);
|
||||
expect(blocks[0].markDefs[0]._type).toBe("link");
|
||||
expect(blocks[0].markDefs[0].href).toBe("https://example.com");
|
||||
|
||||
const linkSpan = blocks[0].children.find((c) =>
|
||||
(c.marks ?? []).includes(blocks[0].markDefs[0]._key),
|
||||
);
|
||||
expect(linkSpan).toBeDefined();
|
||||
expect(linkSpan!.text).toBe("here");
|
||||
});
|
||||
|
||||
it("converts blockquotes", () => {
|
||||
const blocks = markdownToPortableText("> A quote\n");
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].style).toBe("blockquote");
|
||||
});
|
||||
|
||||
it("converts unordered lists", () => {
|
||||
const blocks = markdownToPortableText("- First\n- Second\n - Nested\n");
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(blocks[0].listItem).toBe("bullet");
|
||||
expect(blocks[0].level).toBe(1);
|
||||
expect(blocks[2].listItem).toBe("bullet");
|
||||
expect(blocks[2].level).toBe(2);
|
||||
});
|
||||
|
||||
it("converts ordered lists", () => {
|
||||
const blocks = markdownToPortableText("1. First\n2. Second\n");
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0].listItem).toBe("number");
|
||||
expect(blocks[1].listItem).toBe("number");
|
||||
});
|
||||
|
||||
it("converts code fences", () => {
|
||||
const blocks = markdownToPortableText("```typescript\nconst x = 1;\n```\n");
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]._type).toBe("code");
|
||||
expect(blocks[0].language).toBe("typescript");
|
||||
expect(blocks[0].code).toBe("const x = 1;");
|
||||
});
|
||||
|
||||
it("converts images", () => {
|
||||
const blocks = markdownToPortableText("\n");
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]._type).toBe("image");
|
||||
expect(blocks[0].alt).toBe("A cat");
|
||||
expect((blocks[0].asset as { url: string }).url).toBe("/img/cat.jpg");
|
||||
});
|
||||
|
||||
it("deserializes opaque fences back to original blocks", () => {
|
||||
const original = {
|
||||
_type: "pluginWidget",
|
||||
_key: "pw1",
|
||||
config: { layout: "grid", items: 3 },
|
||||
};
|
||||
const md = `<!--ec:block ${JSON.stringify(original)} -->`;
|
||||
const blocks = markdownToPortableText(md);
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]._type).toBe("pluginWidget");
|
||||
expect(blocks[0]._key).toBe("pw1");
|
||||
expect((blocks[0] as Record<string, unknown>).config).toEqual({
|
||||
layout: "grid",
|
||||
items: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips blank lines", () => {
|
||||
const blocks = markdownToPortableText("Hello\n\n\n\nWorld\n");
|
||||
expect(blocks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("converts strikethrough", () => {
|
||||
const blocks = markdownToPortableText("Some ~~deleted~~ text\n");
|
||||
const children = blocks[0].children;
|
||||
const strikeSpan = children.find((c) => (c.marks ?? []).includes("strike-through"));
|
||||
expect(strikeSpan).toBeDefined();
|
||||
expect(strikeSpan!.text).toBe("deleted");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PT <-> Markdown round-trip", () => {
|
||||
it("preserves simple text through round-trip", () => {
|
||||
const original: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
_key: "a",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", _key: "s", text: "Hello world", marks: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(original);
|
||||
const roundTripped = markdownToPortableText(md);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0].style).toBe("normal");
|
||||
expect((roundTripped[0].children[0] as { text: string }).text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("preserves headings through round-trip", () => {
|
||||
const original: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "h2",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "My Heading", marks: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(original);
|
||||
const roundTripped = markdownToPortableText(md);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0].style).toBe("h2");
|
||||
expect((roundTripped[0].children[0] as { text: string }).text).toBe("My Heading");
|
||||
});
|
||||
|
||||
it("preserves opaque fences through round-trip", () => {
|
||||
const custom = {
|
||||
_type: "callout",
|
||||
_key: "c1",
|
||||
style: "warning",
|
||||
text: "Be careful!",
|
||||
};
|
||||
|
||||
const original: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Before", marks: [] }],
|
||||
},
|
||||
custom,
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "After", marks: [] }],
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(original);
|
||||
const roundTripped = markdownToPortableText(md);
|
||||
|
||||
expect(roundTripped).toHaveLength(3);
|
||||
expect(roundTripped[1]._type).toBe("callout");
|
||||
expect(roundTripped[1]._key).toBe("c1");
|
||||
expect((roundTripped[1] as Record<string, unknown>).style).toBe("warning");
|
||||
expect((roundTripped[1] as Record<string, unknown>).text).toBe("Be careful!");
|
||||
});
|
||||
|
||||
it("preserves code blocks through round-trip", () => {
|
||||
const original: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "code",
|
||||
_key: "c1",
|
||||
language: "javascript",
|
||||
code: "const x = 42;",
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(original);
|
||||
const roundTripped = markdownToPortableText(md);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0]._type).toBe("code");
|
||||
expect(roundTripped[0].language).toBe("javascript");
|
||||
expect(roundTripped[0].code).toBe("const x = 42;");
|
||||
});
|
||||
|
||||
it("preserves bold text through round-trip", () => {
|
||||
const original: PortableTextBlock[] = [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [
|
||||
{ _type: "span", text: "Some ", marks: [] },
|
||||
{ _type: "span", text: "bold", marks: ["strong"] },
|
||||
{ _type: "span", text: " text", marks: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const md = portableTextToMarkdown(original);
|
||||
expect(md).toContain("**bold**");
|
||||
|
||||
const roundTripped = markdownToPortableText(md);
|
||||
const boldSpan = roundTripped[0].children.find((c) => (c.marks ?? []).includes("strong"));
|
||||
expect(boldSpan).toBeDefined();
|
||||
expect(boldSpan!.text).toBe("bold");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema-aware conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("convertDataForRead", () => {
|
||||
const fields: FieldSchema[] = [
|
||||
{ slug: "title", type: "string" },
|
||||
{ slug: "body", type: "portableText" },
|
||||
{ slug: "sidebar", type: "portableText" },
|
||||
];
|
||||
|
||||
it("converts PT arrays to markdown for portableText fields", () => {
|
||||
const data = {
|
||||
title: "Hello",
|
||||
body: [
|
||||
{
|
||||
_type: "block",
|
||||
style: "normal",
|
||||
markDefs: [],
|
||||
children: [{ _type: "span", text: "Content", marks: [] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = convertDataForRead(data, fields);
|
||||
expect(result.title).toBe("Hello");
|
||||
expect(typeof result.body).toBe("string");
|
||||
expect(result.body).toContain("Content");
|
||||
});
|
||||
|
||||
it("skips conversion when raw is true", () => {
|
||||
const data = {
|
||||
body: [{ _type: "block", children: [{ _type: "span", text: "X" }] }],
|
||||
};
|
||||
|
||||
const result = convertDataForRead(data, fields, true);
|
||||
expect(Array.isArray(result.body)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not touch non-portableText fields", () => {
|
||||
const data = { title: "Test", body: "already a string" };
|
||||
const result = convertDataForRead(data, fields);
|
||||
expect(result.title).toBe("Test");
|
||||
expect(result.body).toBe("already a string"); // not an array, skip
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDataForWrite", () => {
|
||||
const fields: FieldSchema[] = [
|
||||
{ slug: "title", type: "string" },
|
||||
{ slug: "body", type: "portableText" },
|
||||
];
|
||||
|
||||
it("converts markdown strings to PT for portableText fields", () => {
|
||||
const data = { title: "Hello", body: "Some **bold** text" };
|
||||
const result = convertDataForWrite(data, fields);
|
||||
expect(result.title).toBe("Hello");
|
||||
expect(Array.isArray(result.body)).toBe(true);
|
||||
|
||||
const blocks = result.body as PortableTextBlock[];
|
||||
expect(blocks[0]._type).toBe("block");
|
||||
const boldSpan = blocks[0].children.find((c) => (c.marks ?? []).includes("strong"));
|
||||
expect(boldSpan!.text).toBe("bold");
|
||||
});
|
||||
|
||||
it("passes through raw PT arrays unchanged", () => {
|
||||
const ptArray = [{ _type: "block", children: [{ _type: "span", text: "Raw" }] }];
|
||||
const data = { body: ptArray };
|
||||
const result = convertDataForWrite(data, fields);
|
||||
expect(result.body).toBe(ptArray); // same reference
|
||||
});
|
||||
});
|
||||
248
packages/core/tests/unit/client/transport.test.ts
Normal file
248
packages/core/tests/unit/client/transport.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { Interceptor } from "../../../src/client/transport.js";
|
||||
import {
|
||||
createTransport,
|
||||
csrfInterceptor,
|
||||
tokenInterceptor,
|
||||
} from "../../../src/client/transport.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create an interceptor that adds a header to the request */
|
||||
function createHeaderInterceptor(name: string, value: string): Interceptor {
|
||||
return async (req, next) => {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set(name, value);
|
||||
return next(new Request(req, { headers }));
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a mock fetch that returns a fixed response */
|
||||
function mockFetch(body: unknown = {}, status: number = 200): Interceptor {
|
||||
return async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createTransport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createTransport", () => {
|
||||
it("calls global fetch when no interceptors are provided", async () => {
|
||||
const transport = createTransport({
|
||||
interceptors: [mockFetch({ ok: true })],
|
||||
});
|
||||
|
||||
const res = await transport.fetch(new Request("https://example.com"));
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("runs interceptors in order", async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const first: Interceptor = async (req, next) => {
|
||||
order.push("first-before");
|
||||
const res = await next(req);
|
||||
order.push("first-after");
|
||||
return res;
|
||||
};
|
||||
|
||||
const second: Interceptor = async (req, next) => {
|
||||
order.push("second-before");
|
||||
const res = await next(req);
|
||||
order.push("second-after");
|
||||
return res;
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [first, second, mockFetch()],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com"));
|
||||
expect(order).toEqual(["first-before", "second-before", "second-after", "first-after"]);
|
||||
});
|
||||
|
||||
it("allows interceptors to modify requests", async () => {
|
||||
let capturedHeader: string | null = null;
|
||||
|
||||
const addHeader = createHeaderInterceptor("X-Custom", "test-value");
|
||||
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedHeader = req.headers.get("X-Custom");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [addHeader, capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com"));
|
||||
expect(capturedHeader).toBe("test-value");
|
||||
});
|
||||
|
||||
it("allows interceptors to retry on failure", async () => {
|
||||
let attempts = 0;
|
||||
|
||||
const retryOnce: Interceptor = async (req, next) => {
|
||||
const res = await next(req);
|
||||
if (res.status === 401 && attempts === 0) {
|
||||
attempts++;
|
||||
return next(req);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
let callCount = 0;
|
||||
const backend: Interceptor = async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [retryOnce, backend],
|
||||
});
|
||||
|
||||
const res = await transport.fetch(new Request("https://example.com"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// csrfInterceptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("csrfInterceptor", () => {
|
||||
it("adds X-EmDash-Request header to POST requests", async () => {
|
||||
let capturedHeader: string | null = null;
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedHeader = req.headers.get("X-EmDash-Request");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [csrfInterceptor(), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com", { method: "POST" }));
|
||||
expect(capturedHeader).toBe("1");
|
||||
});
|
||||
|
||||
it("adds X-EmDash-Request header to PUT requests", async () => {
|
||||
let capturedHeader: string | null = null;
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedHeader = req.headers.get("X-EmDash-Request");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [csrfInterceptor(), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com", { method: "PUT" }));
|
||||
expect(capturedHeader).toBe("1");
|
||||
});
|
||||
|
||||
it("adds X-EmDash-Request header to DELETE requests", async () => {
|
||||
let capturedHeader: string | null = null;
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedHeader = req.headers.get("X-EmDash-Request");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [csrfInterceptor(), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com", { method: "DELETE" }));
|
||||
expect(capturedHeader).toBe("1");
|
||||
});
|
||||
|
||||
it("does NOT add header to GET requests", async () => {
|
||||
let capturedHeader: string | null = null;
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedHeader = req.headers.get("X-EmDash-Request");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [csrfInterceptor(), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com", { method: "GET" }));
|
||||
expect(capturedHeader).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tokenInterceptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tokenInterceptor", () => {
|
||||
it("adds Authorization Bearer header to all requests", async () => {
|
||||
let capturedAuth: string | null = null;
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedAuth = req.headers.get("Authorization");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [tokenInterceptor("ec_pat_abc123"), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com"));
|
||||
expect(capturedAuth).toBe("Bearer ec_pat_abc123");
|
||||
});
|
||||
|
||||
it("adds Authorization to both GET and POST", async () => {
|
||||
const captured: string[] = [];
|
||||
const capture: Interceptor = async (req) => {
|
||||
captured.push(req.headers.get("Authorization") ?? "");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [tokenInterceptor("tok"), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com", { method: "GET" }));
|
||||
await transport.fetch(new Request("https://example.com", { method: "POST" }));
|
||||
expect(captured).toEqual(["Bearer tok", "Bearer tok"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interceptor composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("interceptor composition", () => {
|
||||
it("csrf + token interceptors compose correctly", async () => {
|
||||
let capturedAuth: string | null = null;
|
||||
let capturedCsrf: string | null = null;
|
||||
|
||||
const capture: Interceptor = async (req) => {
|
||||
capturedAuth = req.headers.get("Authorization");
|
||||
capturedCsrf = req.headers.get("X-EmDash-Request");
|
||||
return new Response("ok");
|
||||
};
|
||||
|
||||
const transport = createTransport({
|
||||
interceptors: [csrfInterceptor(), tokenInterceptor("tok"), capture],
|
||||
});
|
||||
|
||||
await transport.fetch(new Request("https://example.com", { method: "POST" }));
|
||||
expect(capturedAuth).toBe("Bearer tok");
|
||||
expect(capturedCsrf).toBe("1");
|
||||
});
|
||||
});
|
||||
60
packages/core/tests/unit/converters/image-dimensions.test.ts
Normal file
60
packages/core/tests/unit/converters/image-dimensions.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { portableTextToProsemirror } from "../../../src/content/converters/portable-text-to-prosemirror.js";
|
||||
import { prosemirrorToPortableText } from "../../../src/content/converters/prosemirror-to-portable-text.js";
|
||||
import type { PortableTextImageBlock } from "../../../src/content/converters/types.js";
|
||||
|
||||
describe("Image dimension round-trip", () => {
|
||||
const imageBlock: PortableTextImageBlock = {
|
||||
_type: "image",
|
||||
_key: "abc123",
|
||||
asset: { _ref: "media-123", url: "https://example.com/photo.jpg" },
|
||||
alt: "A photo",
|
||||
caption: "My caption",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
displayWidth: 400,
|
||||
displayHeight: 225,
|
||||
};
|
||||
|
||||
it("preserves displayWidth and displayHeight through PT → PM → PT", () => {
|
||||
// PT → PM
|
||||
const pm = portableTextToProsemirror([imageBlock]);
|
||||
const imageNode = pm.content[0];
|
||||
|
||||
expect(imageNode.type).toBe("image");
|
||||
expect(imageNode.attrs?.displayWidth).toBe(400);
|
||||
expect(imageNode.attrs?.displayHeight).toBe(225);
|
||||
expect(imageNode.attrs?.width).toBe(1920);
|
||||
expect(imageNode.attrs?.height).toBe(1080);
|
||||
|
||||
// PM → PT
|
||||
const pt = prosemirrorToPortableText(pm);
|
||||
const restored = pt[0] as PortableTextImageBlock;
|
||||
|
||||
expect(restored._type).toBe("image");
|
||||
expect(restored.displayWidth).toBe(400);
|
||||
expect(restored.displayHeight).toBe(225);
|
||||
expect(restored.width).toBe(1920);
|
||||
expect(restored.height).toBe(1080);
|
||||
});
|
||||
|
||||
it("handles images without display dimensions", () => {
|
||||
const noDisplayDims: PortableTextImageBlock = {
|
||||
_type: "image",
|
||||
_key: "def456",
|
||||
asset: { _ref: "media-456", url: "https://example.com/other.jpg" },
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
|
||||
const pm = portableTextToProsemirror([noDisplayDims]);
|
||||
const pt = prosemirrorToPortableText(pm);
|
||||
const restored = pt[0] as PortableTextImageBlock;
|
||||
|
||||
expect(restored.displayWidth).toBeUndefined();
|
||||
expect(restored.displayHeight).toBeUndefined();
|
||||
expect(restored.width).toBe(800);
|
||||
expect(restored.height).toBe(600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../../src/database/connection.js";
|
||||
import { down, up } from "../../../../src/database/migrations/031_bylines.js";
|
||||
import type { Database } from "../../../../src/database/types.js";
|
||||
|
||||
describe("031_bylines migration", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
|
||||
await db.schema
|
||||
.createTable("users")
|
||||
.addColumn("id", "text", (col) => col.primaryKey())
|
||||
.execute();
|
||||
await db.schema
|
||||
.createTable("media")
|
||||
.addColumn("id", "text", (col) => col.primaryKey())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("ec_posts")
|
||||
.addColumn("id", "text", (col) => col.primaryKey())
|
||||
.execute();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
it("adds byline tables and primary_byline_id to existing content tables", async () => {
|
||||
await up(db);
|
||||
|
||||
const tables = await db.introspection.getTables();
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).toContain("_emdash_bylines");
|
||||
expect(tableNames).toContain("_emdash_content_bylines");
|
||||
|
||||
const contentTable = tables.find((t) => t.name === "ec_posts");
|
||||
expect(contentTable).toBeDefined();
|
||||
expect(contentTable?.columns.map((c) => c.name)).toContain("primary_byline_id");
|
||||
|
||||
const idx = await sql<{ name: string }>`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index' AND name = 'idx_ec_posts_primary_byline'
|
||||
`.execute(db);
|
||||
expect(idx.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reverts added tables, indexes, and columns", async () => {
|
||||
await up(db);
|
||||
await down(db);
|
||||
|
||||
const tables = await db.introspection.getTables();
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).not.toContain("_emdash_bylines");
|
||||
expect(tableNames).not.toContain("_emdash_content_bylines");
|
||||
|
||||
const contentTable = tables.find((t) => t.name === "ec_posts");
|
||||
expect(contentTable).toBeDefined();
|
||||
expect(contentTable?.columns.map((c) => c.name)).not.toContain("primary_byline_id");
|
||||
|
||||
const idx = await sql<{ name: string }>`
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index' AND name = 'idx_ec_posts_primary_byline'
|
||||
`.execute(db);
|
||||
expect(idx.rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
165
packages/core/tests/unit/database/repositories/byline.test.ts
Normal file
165
packages/core/tests/unit/database/repositories/byline.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { BylineRepository } from "../../../../src/database/repositories/byline.js";
|
||||
import { ContentRepository } from "../../../../src/database/repositories/content.js";
|
||||
import type { Database } from "../../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js";
|
||||
|
||||
describe("BylineRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let bylineRepo: BylineRepository;
|
||||
let contentRepo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
bylineRepo = new BylineRepository(db);
|
||||
contentRepo = new ContentRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("creates and reads bylines", async () => {
|
||||
const created = await bylineRepo.create({
|
||||
slug: "jane-doe",
|
||||
displayName: "Jane Doe",
|
||||
isGuest: true,
|
||||
});
|
||||
|
||||
expect(created.slug).toBe("jane-doe");
|
||||
expect(created.displayName).toBe("Jane Doe");
|
||||
expect(created.isGuest).toBe(true);
|
||||
|
||||
const foundById = await bylineRepo.findById(created.id);
|
||||
expect(foundById?.id).toBe(created.id);
|
||||
|
||||
const foundBySlug = await bylineRepo.findBySlug("jane-doe");
|
||||
expect(foundBySlug?.id).toBe(created.id);
|
||||
|
||||
const foundByUser = await bylineRepo.findByUserId("missing-user");
|
||||
expect(foundByUser).toBeNull();
|
||||
});
|
||||
|
||||
it("supports updates and paginated listing", async () => {
|
||||
const alpha = await bylineRepo.create({
|
||||
slug: "alpha",
|
||||
displayName: "Alpha Writer",
|
||||
isGuest: true,
|
||||
});
|
||||
await bylineRepo.create({
|
||||
slug: "beta",
|
||||
displayName: "Beta Writer",
|
||||
isGuest: false,
|
||||
});
|
||||
|
||||
const updated = await bylineRepo.update(alpha.id, {
|
||||
displayName: "Alpha Updated",
|
||||
websiteUrl: "https://example.com",
|
||||
});
|
||||
expect(updated?.displayName).toBe("Alpha Updated");
|
||||
expect(updated?.websiteUrl).toBe("https://example.com");
|
||||
|
||||
const searchResult = await bylineRepo.findMany({ search: "Beta" });
|
||||
expect(searchResult.items).toHaveLength(1);
|
||||
expect(searchResult.items[0]?.slug).toBe("beta");
|
||||
|
||||
const page1 = await bylineRepo.findMany({ limit: 1 });
|
||||
expect(page1.items).toHaveLength(1);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
const page2 = await bylineRepo.findMany({ limit: 1, cursor: page1.nextCursor });
|
||||
expect(page2.items).toHaveLength(1);
|
||||
expect(page2.items[0]?.id).not.toBe(page1.items[0]?.id);
|
||||
});
|
||||
|
||||
it("assigns ordered bylines to content and syncs primary_byline_id", async () => {
|
||||
const lead = await bylineRepo.create({
|
||||
slug: "lead",
|
||||
displayName: "Lead Author",
|
||||
});
|
||||
const second = await bylineRepo.create({
|
||||
slug: "second",
|
||||
displayName: "Second Author",
|
||||
});
|
||||
|
||||
const content = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "bylined-post",
|
||||
data: { title: "Bylined Post" },
|
||||
});
|
||||
|
||||
const assigned = await bylineRepo.setContentBylines("post", content.id, [
|
||||
{ bylineId: lead.id },
|
||||
{ bylineId: second.id, roleLabel: "Editor" },
|
||||
]);
|
||||
|
||||
expect(assigned).toHaveLength(2);
|
||||
expect(assigned[0]?.byline.id).toBe(lead.id);
|
||||
expect(assigned[0]?.sortOrder).toBe(0);
|
||||
expect(assigned[1]?.byline.id).toBe(second.id);
|
||||
expect(assigned[1]?.roleLabel).toBe("Editor");
|
||||
|
||||
const refreshed = await contentRepo.findById("post", content.id);
|
||||
expect(refreshed?.primaryBylineId).toBe(lead.id);
|
||||
});
|
||||
|
||||
it("reorders bylines and updates primary_byline_id", async () => {
|
||||
const first = await bylineRepo.create({
|
||||
slug: "first",
|
||||
displayName: "First",
|
||||
});
|
||||
const second = await bylineRepo.create({
|
||||
slug: "second-reorder",
|
||||
displayName: "Second",
|
||||
});
|
||||
|
||||
const content = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "reordered-post",
|
||||
data: { title: "Reordered" },
|
||||
});
|
||||
|
||||
await bylineRepo.setContentBylines("post", content.id, [
|
||||
{ bylineId: first.id },
|
||||
{ bylineId: second.id },
|
||||
]);
|
||||
|
||||
await bylineRepo.setContentBylines("post", content.id, [
|
||||
{ bylineId: second.id },
|
||||
{ bylineId: first.id },
|
||||
]);
|
||||
|
||||
const refreshed = await contentRepo.findById("post", content.id);
|
||||
expect(refreshed?.primaryBylineId).toBe(second.id);
|
||||
|
||||
const bylines = await bylineRepo.getContentBylines("post", content.id);
|
||||
expect(bylines[0]?.byline.id).toBe(second.id);
|
||||
expect(bylines[1]?.byline.id).toBe(first.id);
|
||||
});
|
||||
|
||||
it("deletes byline, removes links, and nulls primary_byline_id", async () => {
|
||||
const byline = await bylineRepo.create({
|
||||
slug: "delete-me",
|
||||
displayName: "Delete Me",
|
||||
});
|
||||
|
||||
const content = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "delete-byline-post",
|
||||
data: { title: "Delete Byline" },
|
||||
});
|
||||
|
||||
await bylineRepo.setContentBylines("post", content.id, [{ bylineId: byline.id }]);
|
||||
|
||||
const deleted = await bylineRepo.delete(byline.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const unresolved = await bylineRepo.getContentBylines("post", content.id);
|
||||
expect(unresolved).toHaveLength(0);
|
||||
|
||||
const refreshed = await contentRepo.findById("post", content.id);
|
||||
expect(refreshed?.primaryBylineId).toBeNull();
|
||||
});
|
||||
});
|
||||
560
packages/core/tests/unit/database/repositories/content.test.ts
Normal file
560
packages/core/tests/unit/database/repositories/content.test.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { ContentRepository } from "../../../../src/database/repositories/content.js";
|
||||
import { EmDashValidationError } from "../../../../src/database/repositories/types.js";
|
||||
import type { Database } from "../../../../src/database/types.js";
|
||||
import { createPostFixture, createPageFixture } from "../../../utils/fixtures.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js";
|
||||
|
||||
// Regex patterns for ID validation
|
||||
const ULID_FORMAT_REGEX = /^[0-9A-Z]+$/i;
|
||||
|
||||
describe("ContentRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
repo = new ContentRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("create()", () => {
|
||||
it("should create content with valid data", async () => {
|
||||
const input = createPostFixture();
|
||||
const result = await repo.create(input);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBeTruthy();
|
||||
expect(result.type).toBe("post");
|
||||
expect(result.slug).toBe("hello-world");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.data).toEqual(input.data);
|
||||
});
|
||||
|
||||
it("should generate ULID for ID", async () => {
|
||||
const input = createPostFixture();
|
||||
const result = await repo.create(input);
|
||||
|
||||
// ULID is 26 characters long
|
||||
expect(result.id).toHaveLength(26);
|
||||
// ULID starts with timestamp (base32) - should be alphanumeric
|
||||
expect(result.id).toMatch(ULID_FORMAT_REGEX);
|
||||
});
|
||||
|
||||
it("should set default status to draft", async () => {
|
||||
const input = createPostFixture();
|
||||
delete (input as any).status;
|
||||
|
||||
const result = await repo.create(input);
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
it("should throw validation error when type is missing", async () => {
|
||||
const input = createPostFixture();
|
||||
delete (input as any).type;
|
||||
|
||||
await expect(repo.create(input)).rejects.toThrow(EmDashValidationError);
|
||||
});
|
||||
|
||||
it("should allow creating content without slug", async () => {
|
||||
const input = createPostFixture();
|
||||
delete (input as any).slug;
|
||||
|
||||
const result = await repo.create(input);
|
||||
expect(result.slug).toBeNull();
|
||||
});
|
||||
|
||||
it("should set createdAt and updatedAt timestamps", async () => {
|
||||
const input = createPostFixture();
|
||||
const result = await repo.create(input);
|
||||
|
||||
expect(result.createdAt).toBeTruthy();
|
||||
expect(result.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should persist primaryBylineId on create", async () => {
|
||||
const result = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "with-primary-byline",
|
||||
primaryBylineId: "byline_1",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.primaryBylineId).toBe("byline_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById()", () => {
|
||||
it("should return content by ID", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(created.id);
|
||||
expect(found?.data).toEqual(created.data);
|
||||
});
|
||||
|
||||
it("should return null for non-existent ID", async () => {
|
||||
const found = await repo.findById("post", "01J9FAKE0000000000000000");
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should exclude soft-deleted content", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
const found = await repo.findById("post", created.id);
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should not return content of wrong type", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const found = await repo.findById("page", created.id);
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBySlug()", () => {
|
||||
it("should return content by slug", async () => {
|
||||
const input = createPostFixture({ slug: "test-slug" });
|
||||
const created = await repo.create(input);
|
||||
|
||||
const found = await repo.findBySlug("post", "test-slug");
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(created.id);
|
||||
expect(found?.slug).toBe("test-slug");
|
||||
});
|
||||
|
||||
it("should return null for non-existent slug", async () => {
|
||||
const found = await repo.findBySlug("post", "non-existent");
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should not return content of wrong type", async () => {
|
||||
const input = createPostFixture({ slug: "test-slug" });
|
||||
await repo.create(input);
|
||||
|
||||
const found = await repo.findBySlug("page", "test-slug");
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany()", () => {
|
||||
it("should return all content of specified type", async () => {
|
||||
await repo.create(createPostFixture({ slug: "post-1" }));
|
||||
await repo.create(createPostFixture({ slug: "post-2" }));
|
||||
await repo.create(createPageFixture({ slug: "page-1" }));
|
||||
|
||||
const result = await repo.findMany("post");
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items.every((item) => item.type === "post")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
await repo.create(createPostFixture({ slug: "draft", status: "draft" }));
|
||||
await repo.create(createPostFixture({ slug: "published", status: "published" }));
|
||||
|
||||
const result = await repo.findMany("post", {
|
||||
where: { status: "published" },
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].status).toBe("published");
|
||||
});
|
||||
|
||||
it("should filter by authorId", async () => {
|
||||
await repo.create(createPostFixture({ slug: "author1", authorId: "user1" }));
|
||||
await repo.create(createPostFixture({ slug: "author2", authorId: "user2" }));
|
||||
|
||||
const result = await repo.findMany("post", {
|
||||
where: { authorId: "user1" },
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].authorId).toBe("user1");
|
||||
});
|
||||
|
||||
it("should support cursor pagination", async () => {
|
||||
// Create multiple posts
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await repo.create(createPostFixture({ slug: `post-${i}` }));
|
||||
}
|
||||
|
||||
// First page
|
||||
const page1 = await repo.findMany("post", { limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
// Second page
|
||||
const page2 = await repo.findMany("post", {
|
||||
limit: 2,
|
||||
cursor: page1.nextCursor,
|
||||
});
|
||||
expect(page2.items).toHaveLength(2);
|
||||
expect(page2.nextCursor).toBeTruthy();
|
||||
|
||||
// Verify no overlap
|
||||
const page1Ids = page1.items.map((i) => i.id);
|
||||
const page2Ids = page2.items.map((i) => i.id);
|
||||
expect(page1Ids).not.toContain(page2Ids[0]);
|
||||
});
|
||||
|
||||
it("should support ordering", async () => {
|
||||
// Create posts with specific dates
|
||||
const post1 = await repo.create(createPostFixture({ slug: "old-post" }));
|
||||
// Wait a bit to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const post2 = await repo.create(createPostFixture({ slug: "new-post" }));
|
||||
|
||||
// Default order (desc by createdAt)
|
||||
const resultDesc = await repo.findMany("post", {
|
||||
orderBy: { field: "createdAt", direction: "desc" },
|
||||
});
|
||||
expect(resultDesc.items[0].id).toBe(post2.id);
|
||||
|
||||
// Ascending order
|
||||
const resultAsc = await repo.findMany("post", {
|
||||
orderBy: { field: "createdAt", direction: "asc" },
|
||||
});
|
||||
expect(resultAsc.items[0].id).toBe(post1.id);
|
||||
});
|
||||
|
||||
it("should respect limit", async () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await repo.create(createPostFixture({ slug: `post-${i}` }));
|
||||
}
|
||||
|
||||
const result = await repo.findMany("post", { limit: 5 });
|
||||
|
||||
expect(result.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should exclude soft-deleted content", async () => {
|
||||
const post1 = await repo.create(createPostFixture({ slug: "post-1" }));
|
||||
await repo.create(createPostFixture({ slug: "post-2" }));
|
||||
await repo.delete("post", post1.id);
|
||||
|
||||
const result = await repo.findMany("post");
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].slug).toBe("post-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update()", () => {
|
||||
it("should update content data", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated Title", content: [] },
|
||||
});
|
||||
|
||||
expect(updated.data).toEqual({ title: "Updated Title", content: [] });
|
||||
});
|
||||
|
||||
it("should update status", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
expect(updated.status).toBe("published");
|
||||
});
|
||||
|
||||
it("should update slug", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
slug: "new-slug",
|
||||
});
|
||||
|
||||
expect(updated.slug).toBe("new-slug");
|
||||
});
|
||||
|
||||
it("should update publishedAt timestamp", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const publishedAt = new Date().toISOString();
|
||||
const updated = await repo.update("post", created.id, {
|
||||
publishedAt,
|
||||
});
|
||||
|
||||
expect(updated.publishedAt).toBe(publishedAt);
|
||||
});
|
||||
|
||||
it("should update updatedAt timestamp automatically", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
// Wait a bit to ensure different timestamp
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
data: { title: "Updated" },
|
||||
});
|
||||
|
||||
expect(updated.updatedAt).not.toBe(created.updatedAt);
|
||||
});
|
||||
|
||||
it("should throw error for non-existent content", async () => {
|
||||
await expect(repo.update("post", "01J9FAKE0000000000000000", { data: {} })).rejects.toThrow(
|
||||
"Content not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should update primaryBylineId", async () => {
|
||||
const created = await repo.create(
|
||||
createPostFixture({
|
||||
slug: "update-primary-byline",
|
||||
primaryBylineId: "byline_old",
|
||||
}),
|
||||
);
|
||||
|
||||
const updated = await repo.update("post", created.id, {
|
||||
primaryBylineId: "byline_new",
|
||||
});
|
||||
|
||||
expect(updated.primaryBylineId).toBe("byline_new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete()", () => {
|
||||
it("should soft delete content", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
|
||||
const result = await repo.delete("post", created.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify content is not returned by findById
|
||||
const found = await repo.findById("post", created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should return false for non-existent content", async () => {
|
||||
const result = await repo.delete("post", "01J9FAKE0000000000000000");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when deleting already deleted content", async () => {
|
||||
const input = createPostFixture();
|
||||
const created = await repo.create(input);
|
||||
await repo.delete("post", created.id);
|
||||
|
||||
const result = await repo.delete("post", created.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("count()", () => {
|
||||
it("should count all content of specified type", async () => {
|
||||
await repo.create(createPostFixture({ slug: "post-1" }));
|
||||
await repo.create(createPostFixture({ slug: "post-2" }));
|
||||
await repo.create(createPageFixture({ slug: "page-1" }));
|
||||
|
||||
const count = await repo.count("post");
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should count with status filter", async () => {
|
||||
await repo.create(createPostFixture({ slug: "draft", status: "draft" }));
|
||||
await repo.create(createPostFixture({ slug: "published", status: "published" }));
|
||||
|
||||
const count = await repo.count("post", { status: "published" });
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should count with authorId filter", async () => {
|
||||
await repo.create(createPostFixture({ slug: "author1", authorId: "user1" }));
|
||||
await repo.create(createPostFixture({ slug: "author2", authorId: "user2" }));
|
||||
|
||||
const count = await repo.count("post", { authorId: "user1" });
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("should exclude soft-deleted content", async () => {
|
||||
const post1 = await repo.create(createPostFixture({ slug: "post-1" }));
|
||||
await repo.create(createPostFixture({ slug: "post-2" }));
|
||||
await repo.delete("post", post1.id);
|
||||
|
||||
const count = await repo.count("post");
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule()", () => {
|
||||
it("should set status to 'scheduled' for draft posts", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
|
||||
const updated = await repo.schedule("post", post.id, future);
|
||||
|
||||
expect(updated.status).toBe("scheduled");
|
||||
expect(updated.scheduledAt).toBe(future);
|
||||
});
|
||||
|
||||
it("should keep status 'published' for published posts", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
await repo.publish("post", post.id);
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
|
||||
const updated = await repo.schedule("post", post.id, future);
|
||||
|
||||
expect(updated.status).toBe("published");
|
||||
expect(updated.scheduledAt).toBe(future);
|
||||
});
|
||||
|
||||
it("should reject dates in the past", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
const past = new Date(Date.now() - 86_400_000).toISOString();
|
||||
|
||||
await expect(repo.schedule("post", post.id, past)).rejects.toThrow(EmDashValidationError);
|
||||
});
|
||||
|
||||
it("should reject invalid date strings", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
|
||||
await expect(repo.schedule("post", post.id, "not-a-date")).rejects.toThrow(
|
||||
EmDashValidationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unschedule()", () => {
|
||||
it("should revert scheduled draft to 'draft'", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
await repo.schedule("post", post.id, future);
|
||||
|
||||
const updated = await repo.unschedule("post", post.id);
|
||||
|
||||
expect(updated.status).toBe("draft");
|
||||
expect(updated.scheduledAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should keep published posts as 'published'", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
await repo.publish("post", post.id);
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
await repo.schedule("post", post.id, future);
|
||||
|
||||
const updated = await repo.unschedule("post", post.id);
|
||||
|
||||
expect(updated.status).toBe("published");
|
||||
expect(updated.scheduledAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish() clears schedule", () => {
|
||||
it("should clear scheduled_at when publishing a scheduled draft", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
await repo.schedule("post", post.id, future);
|
||||
|
||||
const published = await repo.publish("post", post.id);
|
||||
|
||||
expect(published.status).toBe("published");
|
||||
expect(published.scheduledAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should clear scheduled_at when publishing a published post with scheduled changes", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
await repo.publish("post", post.id);
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
await repo.schedule("post", post.id, future);
|
||||
|
||||
const republished = await repo.publish("post", post.id);
|
||||
|
||||
expect(republished.status).toBe("published");
|
||||
expect(republished.scheduledAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findReadyToPublish()", () => {
|
||||
it("should find scheduled drafts past their time", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
// Schedule in the past by directly updating (schedule() rejects past dates)
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
await repo.update("post", post.id, { status: "scheduled", scheduledAt: past });
|
||||
|
||||
const ready = await repo.findReadyToPublish("post");
|
||||
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0]!.id).toBe(post.id);
|
||||
});
|
||||
|
||||
it("should find published posts with past scheduled_at", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
await repo.publish("post", post.id);
|
||||
// Set scheduled_at in the past directly
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
await repo.update("post", post.id, { scheduledAt: past });
|
||||
|
||||
const ready = await repo.findReadyToPublish("post");
|
||||
|
||||
expect(ready).toHaveLength(1);
|
||||
expect(ready[0]!.id).toBe(post.id);
|
||||
});
|
||||
|
||||
it("should not include items with future scheduled_at", async () => {
|
||||
const post = await repo.create(createPostFixture());
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString();
|
||||
await repo.schedule("post", post.id, future);
|
||||
|
||||
const ready = await repo.findReadyToPublish("post");
|
||||
|
||||
expect(ready).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countScheduled()", () => {
|
||||
it("should count both scheduled drafts and published posts with scheduled_at", async () => {
|
||||
// Draft with schedule
|
||||
const draft = await repo.create(createPostFixture({ slug: "draft-scheduled" }));
|
||||
const future1 = new Date(Date.now() + 86_400_000).toISOString();
|
||||
await repo.schedule("post", draft.id, future1);
|
||||
|
||||
// Published with schedule
|
||||
const pub = await repo.create(createPostFixture({ slug: "pub-scheduled" }));
|
||||
await repo.publish("post", pub.id);
|
||||
const future2 = new Date(Date.now() + 172_800_000).toISOString();
|
||||
await repo.schedule("post", pub.id, future2);
|
||||
|
||||
// Unscheduled draft (should not be counted)
|
||||
await repo.create(createPostFixture({ slug: "plain-draft" }));
|
||||
|
||||
const count = await repo.countScheduled("post");
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
348
packages/core/tests/unit/fields/all-fields.test.ts
Normal file
348
packages/core/tests/unit/fields/all-fields.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { z } from "astro/zod";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
text,
|
||||
textarea,
|
||||
number,
|
||||
boolean as booleanField,
|
||||
select,
|
||||
multiSelect,
|
||||
datetime,
|
||||
slug,
|
||||
image,
|
||||
file,
|
||||
reference,
|
||||
json,
|
||||
richText,
|
||||
portableText,
|
||||
} from "../../../src/fields/index.js";
|
||||
|
||||
// Test regex patterns
|
||||
const UPPERCASE_PATTERN_REGEX = /^[A-Z]+$/;
|
||||
const SLUG_UPPERCASE_PATTERN_REGEX = /^[A-Z_]+$/;
|
||||
|
||||
describe("Field Types", () => {
|
||||
describe("text", () => {
|
||||
it("should create basic text field", () => {
|
||||
const field = text();
|
||||
expect(field.type).toBe("text");
|
||||
expect(field.schema).toBeDefined();
|
||||
expect(field.ui?.widget).toBe("text");
|
||||
});
|
||||
|
||||
it("should validate required text", () => {
|
||||
const field = text({ required: true });
|
||||
expect(() => field.schema.parse("hello")).not.toThrow();
|
||||
expect(() => field.schema.parse(undefined)).toThrow();
|
||||
});
|
||||
|
||||
it("should validate optional text", () => {
|
||||
const field = text({ required: false });
|
||||
expect(() => field.schema.parse("hello")).not.toThrow();
|
||||
expect(() => field.schema.parse(undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should enforce minLength", () => {
|
||||
const field = text({ minLength: 5 });
|
||||
expect(() => field.schema.parse("hello")).not.toThrow();
|
||||
expect(() => field.schema.parse("hi")).toThrow();
|
||||
});
|
||||
|
||||
it("should enforce maxLength", () => {
|
||||
const field = text({ maxLength: 10 });
|
||||
expect(() => field.schema.parse("hello")).not.toThrow();
|
||||
expect(() => field.schema.parse("hello world!")).toThrow();
|
||||
});
|
||||
|
||||
it("should enforce pattern", () => {
|
||||
const field = text({ pattern: UPPERCASE_PATTERN_REGEX });
|
||||
expect(() => field.schema.parse("HELLO")).not.toThrow();
|
||||
expect(() => field.schema.parse("hello")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("textarea", () => {
|
||||
it("should create textarea field", () => {
|
||||
const field = textarea();
|
||||
expect(field.type).toBe("textarea");
|
||||
expect(field.ui?.widget).toBe("textarea");
|
||||
expect(field.ui?.rows).toBe(6);
|
||||
});
|
||||
|
||||
it("should accept custom rows", () => {
|
||||
const field = textarea({ rows: 10 });
|
||||
expect(field.ui?.rows).toBe(10);
|
||||
});
|
||||
|
||||
it("should enforce length constraints", () => {
|
||||
const field = textarea({ minLength: 10, maxLength: 100 });
|
||||
expect(() => field.schema.parse("a".repeat(50))).not.toThrow();
|
||||
expect(() => field.schema.parse("short")).toThrow();
|
||||
expect(() => field.schema.parse("a".repeat(200))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("number", () => {
|
||||
it("should create number field", () => {
|
||||
const field = number();
|
||||
expect(field.type).toBe("number");
|
||||
expect(field.ui?.widget).toBe("number");
|
||||
});
|
||||
|
||||
it("should validate numbers", () => {
|
||||
const field = number({ required: true });
|
||||
expect(() => field.schema.parse(42)).not.toThrow();
|
||||
expect(() => field.schema.parse(3.14)).not.toThrow();
|
||||
expect(() => field.schema.parse("42")).toThrow();
|
||||
});
|
||||
|
||||
it("should enforce integer constraint", () => {
|
||||
const field = number({ integer: true });
|
||||
expect(() => field.schema.parse(42)).not.toThrow();
|
||||
expect(() => field.schema.parse(3.14)).toThrow();
|
||||
});
|
||||
|
||||
it("should enforce min/max", () => {
|
||||
const field = number({ min: 0, max: 100 });
|
||||
expect(() => field.schema.parse(50)).not.toThrow();
|
||||
expect(() => field.schema.parse(-1)).toThrow();
|
||||
expect(() => field.schema.parse(101)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("boolean", () => {
|
||||
it("should create boolean field", () => {
|
||||
const field = booleanField();
|
||||
expect(field.type).toBe("boolean");
|
||||
expect(field.ui?.widget).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should validate booleans", () => {
|
||||
const field = booleanField();
|
||||
expect(() => field.schema.parse(true)).not.toThrow();
|
||||
expect(() => field.schema.parse(false)).not.toThrow();
|
||||
expect(() => field.schema.parse("true")).toThrow();
|
||||
});
|
||||
|
||||
it("should apply default value", () => {
|
||||
const field = booleanField({ default: true });
|
||||
const result = field.schema.parse(undefined);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("select", () => {
|
||||
it("should create select field", () => {
|
||||
const field = select({ options: ["one", "two", "three"] as const });
|
||||
expect(field.type).toBe("select");
|
||||
expect(field.ui?.widget).toBe("select");
|
||||
});
|
||||
|
||||
it("should validate enum values", () => {
|
||||
const field = select({
|
||||
options: ["red", "green", "blue"] as const,
|
||||
required: true,
|
||||
});
|
||||
expect(() => field.schema.parse("red")).not.toThrow();
|
||||
expect(() => field.schema.parse("yellow")).toThrow();
|
||||
});
|
||||
|
||||
it("should apply default value", () => {
|
||||
const field = select({
|
||||
options: ["small", "medium", "large"] as const,
|
||||
default: "medium",
|
||||
});
|
||||
const result = field.schema.parse(undefined);
|
||||
expect(result).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiSelect", () => {
|
||||
it("should create multiSelect field", () => {
|
||||
const field = multiSelect({ options: ["a", "b", "c"] as const });
|
||||
expect(field.type).toBe("multiSelect");
|
||||
expect(field.ui?.widget).toBe("multiSelect");
|
||||
});
|
||||
|
||||
it("should validate array of enum values", () => {
|
||||
const field = multiSelect({
|
||||
options: ["tag1", "tag2", "tag3"] as const,
|
||||
required: true,
|
||||
});
|
||||
expect(() => field.schema.parse(["tag1", "tag2"])).not.toThrow();
|
||||
expect(() => field.schema.parse(["tag1", "invalid"])).toThrow();
|
||||
});
|
||||
|
||||
it("should enforce min/max selections", () => {
|
||||
const field = multiSelect({
|
||||
options: ["a", "b", "c", "d"] as const,
|
||||
min: 1,
|
||||
max: 3,
|
||||
});
|
||||
expect(() => field.schema.parse(["a", "b"])).not.toThrow();
|
||||
expect(() => field.schema.parse([])).toThrow();
|
||||
expect(() => field.schema.parse(["a", "b", "c", "d"])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datetime", () => {
|
||||
it("should create datetime field", () => {
|
||||
const field = datetime();
|
||||
expect(field.type).toBe("datetime");
|
||||
expect(field.ui?.widget).toBe("datetime");
|
||||
});
|
||||
|
||||
it("should validate dates", () => {
|
||||
const field = datetime({ required: true });
|
||||
expect(() => field.schema.parse(new Date())).not.toThrow();
|
||||
expect(() => field.schema.parse("2024-01-01")).toThrow();
|
||||
});
|
||||
|
||||
it("should enforce min/max dates", () => {
|
||||
const min = new Date("2024-01-01");
|
||||
const max = new Date("2024-12-31");
|
||||
const field = datetime({ min, max });
|
||||
|
||||
expect(() => field.schema.parse(new Date("2024-06-15"))).not.toThrow();
|
||||
expect(() => field.schema.parse(new Date("2023-12-31"))).toThrow();
|
||||
expect(() => field.schema.parse(new Date("2025-01-01"))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("slug", () => {
|
||||
it("should create slug field", () => {
|
||||
const field = slug();
|
||||
expect(field.type).toBe("slug");
|
||||
expect(field.ui?.widget).toBe("slug");
|
||||
});
|
||||
|
||||
it("should validate slug format", () => {
|
||||
const field = slug({ required: true });
|
||||
expect(() => field.schema.parse("hello-world")).not.toThrow();
|
||||
expect(() => field.schema.parse("hello-world-123")).not.toThrow();
|
||||
expect(() => field.schema.parse("Hello World")).toThrow();
|
||||
expect(() => field.schema.parse("hello_world")).toThrow();
|
||||
});
|
||||
|
||||
it("should accept custom pattern", () => {
|
||||
const field = slug({ pattern: SLUG_UPPERCASE_PATTERN_REGEX });
|
||||
expect(() => field.schema.parse("HELLO_WORLD")).not.toThrow();
|
||||
expect(() => field.schema.parse("hello-world")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("image", () => {
|
||||
it("should create image field", () => {
|
||||
const field = image();
|
||||
expect(field.type).toBe("image");
|
||||
expect(field.ui?.widget).toBe("image");
|
||||
});
|
||||
|
||||
it("should validate image value structure", () => {
|
||||
const field = image({ required: true });
|
||||
const validImage = {
|
||||
id: "img-123",
|
||||
src: "https://example.com/photo.jpg",
|
||||
alt: "A photo",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
expect(() => field.schema.parse(validImage)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("file", () => {
|
||||
it("should create file field", () => {
|
||||
const field = file();
|
||||
expect(field.type).toBe("file");
|
||||
expect(field.ui?.widget).toBe("file");
|
||||
});
|
||||
|
||||
it("should validate file value structure", () => {
|
||||
const field = file({ required: true });
|
||||
const validFile = {
|
||||
id: "file-123",
|
||||
url: "https://example.com/doc.pdf",
|
||||
filename: "doc.pdf",
|
||||
mimeType: "application/pdf",
|
||||
size: 1024000,
|
||||
};
|
||||
expect(() => field.schema.parse(validFile)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reference", () => {
|
||||
it("should create reference field", () => {
|
||||
const field = reference({ to: "posts" });
|
||||
expect(field.type).toBe("reference");
|
||||
expect(field.ui?.widget).toBe("reference");
|
||||
});
|
||||
|
||||
it("should validate string ID", () => {
|
||||
const field = reference({ to: "posts", required: true });
|
||||
expect(() => field.schema.parse("post-123")).not.toThrow();
|
||||
expect(() => field.schema.parse(123)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("json", () => {
|
||||
it("should create json field", () => {
|
||||
const field = json();
|
||||
expect(field.type).toBe("json");
|
||||
expect(field.ui?.widget).toBe("json");
|
||||
});
|
||||
|
||||
it("should accept any JSON data", () => {
|
||||
const field = json();
|
||||
expect(() => field.schema.parse({ foo: "bar" })).not.toThrow();
|
||||
expect(() => field.schema.parse([1, 2, 3])).not.toThrow();
|
||||
expect(() => field.schema.parse("string")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should validate with custom schema", () => {
|
||||
const customSchema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const field = json({ schema: customSchema });
|
||||
expect(() => field.schema.parse({ name: "John", age: 30 })).not.toThrow();
|
||||
expect(() => field.schema.parse({ name: "John" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("richText", () => {
|
||||
it("should create richText field", () => {
|
||||
const field = richText();
|
||||
expect(field.type).toBe("richText");
|
||||
expect(field.ui?.widget).toBe("richText");
|
||||
});
|
||||
|
||||
it("should validate string content", () => {
|
||||
const field = richText({ required: true });
|
||||
expect(() => field.schema.parse("# Heading\n\nParagraph")).not.toThrow();
|
||||
expect(() => field.schema.parse(123)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("portableText", () => {
|
||||
it("should create portableText field", () => {
|
||||
const field = portableText();
|
||||
expect(field.type).toBe("portableText");
|
||||
expect(field.ui?.widget).toBe("portableText");
|
||||
});
|
||||
|
||||
it("should validate array of blocks", () => {
|
||||
const field = portableText({ required: true });
|
||||
const blocks = [
|
||||
{
|
||||
_type: "block",
|
||||
_key: "key1",
|
||||
children: [{ _type: "span", text: "Hello" }],
|
||||
},
|
||||
];
|
||||
expect(() => field.schema.parse(blocks)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
225
packages/core/tests/unit/import/sections.test.ts
Normal file
225
packages/core/tests/unit/import/sections.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Tests for importing WordPress reusable blocks as sections
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { WxrPost } from "../../../src/cli/wxr/parser.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { importReusableBlocksAsSections } from "../../../src/import/sections.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("importReusableBlocksAsSections", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("should import wp_block posts as sections", async () => {
|
||||
const posts: WxrPost[] = [
|
||||
{
|
||||
id: 100,
|
||||
title: "Newsletter CTA",
|
||||
postName: "newsletter-cta",
|
||||
postType: "wp_block",
|
||||
status: "publish",
|
||||
content: `<!-- wp:heading {"level":3} -->
|
||||
<h3>Subscribe to Our Newsletter</h3>
|
||||
<!-- /wp:heading -->
|
||||
|
||||
<!-- wp:paragraph -->
|
||||
<p>Get the latest updates.</p>
|
||||
<!-- /wp:paragraph -->`,
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
title: "Hero Banner",
|
||||
postName: "hero-banner",
|
||||
postType: "wp_block",
|
||||
status: "publish",
|
||||
content: `<!-- wp:heading -->
|
||||
<h2>Welcome</h2>
|
||||
<!-- /wp:heading -->`,
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
// Regular post - should be ignored
|
||||
{
|
||||
id: 1,
|
||||
title: "Regular Post",
|
||||
postName: "regular-post",
|
||||
postType: "post",
|
||||
status: "publish",
|
||||
content: "<p>Hello</p>",
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await importReusableBlocksAsSections(posts, db);
|
||||
|
||||
expect(result.sectionsCreated).toBe(2);
|
||||
expect(result.sectionsSkipped).toBe(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
|
||||
// Verify sections were created
|
||||
const sections = await db.selectFrom("_emdash_sections").selectAll().execute();
|
||||
|
||||
expect(sections).toHaveLength(2);
|
||||
|
||||
const newsletter = sections.find((s) => s.slug === "newsletter-cta");
|
||||
expect(newsletter).toBeDefined();
|
||||
expect(newsletter?.title).toBe("Newsletter CTA");
|
||||
expect(newsletter?.source).toBe("import");
|
||||
|
||||
const hero = sections.find((s) => s.slug === "hero-banner");
|
||||
expect(hero).toBeDefined();
|
||||
expect(hero?.title).toBe("Hero Banner");
|
||||
});
|
||||
|
||||
it("should skip existing sections by slug", async () => {
|
||||
// Create existing section
|
||||
await db
|
||||
.insertInto("_emdash_sections")
|
||||
.values({
|
||||
id: "existing-1",
|
||||
slug: "newsletter-cta",
|
||||
title: "Existing Newsletter",
|
||||
description: null,
|
||||
keywords: null,
|
||||
content: "[]",
|
||||
preview_media_id: null,
|
||||
source: "user",
|
||||
theme_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const posts: WxrPost[] = [
|
||||
{
|
||||
id: 100,
|
||||
title: "Newsletter CTA",
|
||||
postName: "newsletter-cta",
|
||||
postType: "wp_block",
|
||||
status: "publish",
|
||||
content: "<p>New content</p>",
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
{
|
||||
id: 101,
|
||||
title: "New Block",
|
||||
postName: "new-block",
|
||||
postType: "wp_block",
|
||||
status: "publish",
|
||||
content: "<p>New</p>",
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await importReusableBlocksAsSections(posts, db);
|
||||
|
||||
expect(result.sectionsCreated).toBe(1);
|
||||
expect(result.sectionsSkipped).toBe(1);
|
||||
|
||||
// Original title should be preserved
|
||||
const existing = await db
|
||||
.selectFrom("_emdash_sections")
|
||||
.selectAll()
|
||||
.where("slug", "=", "newsletter-cta")
|
||||
.executeTakeFirst();
|
||||
|
||||
expect(existing?.title).toBe("Existing Newsletter");
|
||||
});
|
||||
|
||||
it("should return empty result when no wp_block posts", async () => {
|
||||
const posts: WxrPost[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Regular Post",
|
||||
postName: "regular-post",
|
||||
postType: "post",
|
||||
status: "publish",
|
||||
content: "<p>Hello</p>",
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
const result = await importReusableBlocksAsSections(posts, db);
|
||||
|
||||
expect(result.sectionsCreated).toBe(0);
|
||||
expect(result.sectionsSkipped).toBe(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should convert Gutenberg content to Portable Text", async () => {
|
||||
const posts: WxrPost[] = [
|
||||
{
|
||||
id: 100,
|
||||
title: "Test Block",
|
||||
postName: "test-block",
|
||||
postType: "wp_block",
|
||||
status: "publish",
|
||||
content: `<!-- wp:paragraph -->
|
||||
<p>Hello <strong>world</strong>!</p>
|
||||
<!-- /wp:paragraph -->`,
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
await importReusableBlocksAsSections(posts, db);
|
||||
|
||||
const section = await db
|
||||
.selectFrom("_emdash_sections")
|
||||
.selectAll()
|
||||
.where("slug", "=", "test-block")
|
||||
.executeTakeFirst();
|
||||
|
||||
const content = JSON.parse(section?.content ?? "[]");
|
||||
|
||||
expect(content).toBeInstanceOf(Array);
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
expect(content[0]._type).toBe("block");
|
||||
});
|
||||
|
||||
it("should generate slug from title if postName is missing", async () => {
|
||||
const posts: WxrPost[] = [
|
||||
{
|
||||
id: 100,
|
||||
title: "My Custom Block Title",
|
||||
postName: undefined as unknown as string,
|
||||
postType: "wp_block",
|
||||
status: "publish",
|
||||
content: "<p>Test</p>",
|
||||
categories: [],
|
||||
tags: [],
|
||||
meta: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
await importReusableBlocksAsSections(posts, db);
|
||||
|
||||
const section = await db.selectFrom("_emdash_sections").selectAll().executeTakeFirst();
|
||||
|
||||
expect(section?.slug).toBe("my-custom-block-title");
|
||||
});
|
||||
});
|
||||
405
packages/core/tests/unit/import/ssrf.test.ts
Normal file
405
packages/core/tests/unit/import/ssrf.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Tests for SSRF protection in import/ssrf.ts
|
||||
*
|
||||
* Covers:
|
||||
* - IPv4-mapped IPv6 hex normalization (#58)
|
||||
* - Private IP detection across all forms
|
||||
* - validateExternalUrl blocking internal targets
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
validateExternalUrl,
|
||||
SsrfError,
|
||||
normalizeIPv6MappedToIPv4,
|
||||
} from "../../../src/import/ssrf.js";
|
||||
|
||||
describe("validateExternalUrl", () => {
|
||||
// =========================================================================
|
||||
// Basic validation
|
||||
// =========================================================================
|
||||
|
||||
it("accepts valid external URLs", () => {
|
||||
expect(validateExternalUrl("https://example.com")).toBeInstanceOf(URL);
|
||||
expect(validateExternalUrl("https://wordpress.org/feed")).toBeInstanceOf(URL);
|
||||
expect(validateExternalUrl("http://93.184.216.34/path")).toBeInstanceOf(URL);
|
||||
});
|
||||
|
||||
it("rejects non-http schemes", () => {
|
||||
expect(() => validateExternalUrl("ftp://example.com")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("file:///etc/passwd")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("javascript:alert(1)")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("rejects invalid URLs", () => {
|
||||
expect(() => validateExternalUrl("not a url")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Blocked hostnames
|
||||
// =========================================================================
|
||||
|
||||
it("blocks localhost", () => {
|
||||
expect(() => validateExternalUrl("http://localhost/path")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://localhost:8080")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks metadata endpoints", () => {
|
||||
expect(() => validateExternalUrl("http://metadata.google.internal/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPv4 private ranges
|
||||
// =========================================================================
|
||||
|
||||
it("blocks loopback (127.0.0.0/8)", () => {
|
||||
expect(() => validateExternalUrl("http://127.0.0.1/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://127.255.255.255/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks private 10.0.0.0/8", () => {
|
||||
expect(() => validateExternalUrl("http://10.0.0.1/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://10.255.255.255/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks private 172.16.0.0/12", () => {
|
||||
expect(() => validateExternalUrl("http://172.16.0.1/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://172.31.255.255/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks private 192.168.0.0/16", () => {
|
||||
expect(() => validateExternalUrl("http://192.168.0.1/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://192.168.255.255/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks link-local (169.254.0.0/16) including cloud metadata", () => {
|
||||
expect(() => validateExternalUrl("http://169.254.169.254/latest/meta-data/")).toThrow(
|
||||
SsrfError,
|
||||
);
|
||||
expect(() => validateExternalUrl("http://169.254.0.1/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPv6 loopback
|
||||
// =========================================================================
|
||||
|
||||
it("blocks IPv6 loopback [::1]", () => {
|
||||
expect(() => validateExternalUrl("http://[::1]/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://[::1]:8080/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Issue #58: IPv4-mapped IPv6 in hex form
|
||||
//
|
||||
// The WHATWG URL parser normalizes [::ffff:127.0.0.1] to [::ffff:7f00:1].
|
||||
// Before the fix, the hex form bypassed isPrivateIp() because the regex
|
||||
// only matched dotted-decimal.
|
||||
// =========================================================================
|
||||
|
||||
it("blocks IPv4-mapped IPv6 loopback in hex form [::ffff:7f00:1]", () => {
|
||||
// This is the normalized form of [::ffff:127.0.0.1]
|
||||
expect(() => validateExternalUrl("http://[::ffff:7f00:1]/evil")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 cloud metadata [::ffff:a9fe:a9fe]", () => {
|
||||
// This is the normalized form of [::ffff:169.254.169.254]
|
||||
expect(() => validateExternalUrl("http://[::ffff:a9fe:a9fe]/latest/meta-data/")).toThrow(
|
||||
SsrfError,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 private 10.x [::ffff:a00:1]", () => {
|
||||
// This is the normalized form of [::ffff:10.0.0.1]
|
||||
expect(() => validateExternalUrl("http://[::ffff:a00:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 private 192.168.x [::ffff:c0a8:1]", () => {
|
||||
// This is the normalized form of [::ffff:192.168.0.1]
|
||||
expect(() => validateExternalUrl("http://[::ffff:c0a8:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 private 172.16.x [::ffff:ac10:1]", () => {
|
||||
// This is the normalized form of [::ffff:172.16.0.1]
|
||||
expect(() => validateExternalUrl("http://[::ffff:ac10:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-mapped IPv6 in dotted-decimal form", () => {
|
||||
// The dotted-decimal form should also be blocked (it worked before too)
|
||||
// The URL parser normalizes this to hex, so this exercises the same path
|
||||
expect(() => validateExternalUrl("http://[::ffff:127.0.0.1]/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://[::ffff:169.254.169.254]/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://[::ffff:10.0.0.1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("allows IPv4-mapped IPv6 for public IPs", () => {
|
||||
// [::ffff:93.184.216.34] -> hex form after URL parsing
|
||||
// 93 = 0x5d, 184 = 0xb8 -> 0x5db8
|
||||
// 216 = 0xd8, 34 = 0x22 -> 0xd822
|
||||
// So [::ffff:5db8:d822] should be allowed
|
||||
expect(validateExternalUrl("http://[::ffff:5db8:d822]/")).toBeInstanceOf(URL);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPv4-compatible (deprecated) addresses: ::XXXX:XXXX (no ffff prefix)
|
||||
//
|
||||
// [::127.0.0.1] normalizes to [::7f00:1] which has no ffff prefix.
|
||||
// Without the fix, these bypass all ffff-based checks.
|
||||
// =========================================================================
|
||||
|
||||
it("blocks IPv4-compatible loopback [::7f00:1]", () => {
|
||||
// Normalized form of [::127.0.0.1]
|
||||
expect(() => validateExternalUrl("http://[::7f00:1]/evil")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-compatible cloud metadata [::a9fe:a9fe]", () => {
|
||||
// Normalized form of [::169.254.169.254]
|
||||
expect(() => validateExternalUrl("http://[::a9fe:a9fe]/latest/meta-data/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-compatible private 10.x [::a00:1]", () => {
|
||||
// Normalized form of [::10.0.0.1]
|
||||
expect(() => validateExternalUrl("http://[::a00:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv4-compatible private 192.168.x [::c0a8:1]", () => {
|
||||
// Normalized form of [::192.168.0.1]
|
||||
expect(() => validateExternalUrl("http://[::c0a8:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("allows IPv4-compatible public IPs [::5db8:d822]", () => {
|
||||
// 93.184.216.34 in hex
|
||||
expect(validateExternalUrl("http://[::5db8:d822]/")).toBeInstanceOf(URL);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// NAT64 prefix: 64:ff9b::XXXX:XXXX
|
||||
//
|
||||
// [64:ff9b::127.0.0.1] normalizes to [64:ff9b::7f00:1].
|
||||
// NAT64 gateways embed IPv4 in IPv6 using this well-known prefix.
|
||||
// =========================================================================
|
||||
|
||||
it("blocks NAT64 loopback [64:ff9b::7f00:1]", () => {
|
||||
expect(() => validateExternalUrl("http://[64:ff9b::7f00:1]/evil")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks NAT64 cloud metadata [64:ff9b::a9fe:a9fe]", () => {
|
||||
expect(() => validateExternalUrl("http://[64:ff9b::a9fe:a9fe]/latest/meta-data/")).toThrow(
|
||||
SsrfError,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks NAT64 private 10.x [64:ff9b::a00:1]", () => {
|
||||
expect(() => validateExternalUrl("http://[64:ff9b::a00:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks NAT64 private 192.168.x [64:ff9b::c0a8:1]", () => {
|
||||
expect(() => validateExternalUrl("http://[64:ff9b::c0a8:1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("allows NAT64 public IPs [64:ff9b::5db8:d822]", () => {
|
||||
expect(validateExternalUrl("http://[64:ff9b::5db8:d822]/")).toBeInstanceOf(URL);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPv6 link-local and ULA
|
||||
// =========================================================================
|
||||
|
||||
it("blocks IPv6 link-local (fe80::)", () => {
|
||||
expect(() => validateExternalUrl("http://[fe80::1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks IPv6 unique local (fc00::/fd00::)", () => {
|
||||
expect(() => validateExternalUrl("http://[fc00::1]/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://[fd00::1]/")).toThrow(SsrfError);
|
||||
});
|
||||
|
||||
it("blocks 0.0.0.0/8 range", () => {
|
||||
expect(() => validateExternalUrl("http://0.0.0.0/")).toThrow(SsrfError);
|
||||
expect(() => validateExternalUrl("http://0.0.0.1/")).toThrow(SsrfError);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// normalizeIPv6MappedToIPv4 — direct unit tests (#58)
|
||||
//
|
||||
// This function converts IPv4-mapped/translated IPv6 hex addresses back to
|
||||
// dotted-decimal IPv4 so they can be checked against private ranges. Without
|
||||
// it, the WHATWG URL parser's hex normalization bypasses SSRF protection.
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizeIPv6MappedToIPv4", () => {
|
||||
// =========================================================================
|
||||
// Standard hex-form: ::ffff:XXXX:XXXX
|
||||
// =========================================================================
|
||||
|
||||
it("converts loopback ::ffff:7f00:1 -> 127.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:7f00:1")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts cloud metadata ::ffff:a9fe:a9fe -> 169.254.169.254", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:a9fe:a9fe")).toBe("169.254.169.254");
|
||||
});
|
||||
|
||||
it("converts private 10.x ::ffff:a00:1 -> 10.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:a00:1")).toBe("10.0.0.1");
|
||||
});
|
||||
|
||||
it("converts private 192.168.x ::ffff:c0a8:1 -> 192.168.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:c0a8:1")).toBe("192.168.0.1");
|
||||
});
|
||||
|
||||
it("converts private 172.16.x ::ffff:ac10:1 -> 172.16.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:ac10:1")).toBe("172.16.0.1");
|
||||
});
|
||||
|
||||
it("converts public IP ::ffff:5db8:d822 -> 93.184.216.34", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:5db8:d822")).toBe("93.184.216.34");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge values
|
||||
// =========================================================================
|
||||
|
||||
it("converts ::ffff:0:0 -> 0.0.0.0", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:0:0")).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("converts ::ffff:ffff:ffff -> 255.255.255.255", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:ffff:ffff")).toBe("255.255.255.255");
|
||||
});
|
||||
|
||||
it("converts 4-digit hex groups correctly ::ffff:c612:e3a -> 198.18.14.58", () => {
|
||||
// 0xc612 = 198*256 + 18 = 50706
|
||||
// 0x0e3a = 14*256 + 58 = 3642
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:c612:e3a")).toBe("198.18.14.58");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Case insensitivity
|
||||
// =========================================================================
|
||||
|
||||
it("handles uppercase hex digits", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::FFFF:7F00:1")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("handles mixed case hex digits", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:A9FE:a9fe")).toBe("169.254.169.254");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Bracket-wrapped form returns null (brackets stripped by caller)
|
||||
// validateExternalUrl strips brackets before calling isPrivateIp,
|
||||
// so normalizeIPv6MappedToIPv4 never receives bracketed input.
|
||||
// =========================================================================
|
||||
|
||||
it("returns null for bracketed input (brackets stripped by caller)", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("[::ffff:7f00:1]")).toBeNull();
|
||||
expect(normalizeIPv6MappedToIPv4("[::ffff:a9fe:a9fe]")).toBeNull();
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPv4-translated (RFC 6052): ::ffff:0:XXXX:XXXX
|
||||
// =========================================================================
|
||||
|
||||
it("converts translated form ::ffff:0:7f00:1 -> 127.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:0:7f00:1")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts translated form ::ffff:0:a9fe:a9fe -> 169.254.169.254", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:0:a9fe:a9fe")).toBe("169.254.169.254");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Fully expanded form: 0000:0000:0000:0000:0000:ffff:XXXX:XXXX
|
||||
// =========================================================================
|
||||
|
||||
it("converts expanded form 0:0:0:0:0:ffff:7f00:1 -> 127.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("0:0:0:0:0:ffff:7f00:1")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts expanded form 0000:0000:0000:0000:0000:ffff:a9fe:a9fe -> 169.254.169.254", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("0000:0000:0000:0000:0000:ffff:a9fe:a9fe")).toBe(
|
||||
"169.254.169.254",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts expanded form with mixed zero lengths", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("0:00:000:0000:0:ffff:a00:1")).toBe("10.0.0.1");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// IPv4-compatible (deprecated) form: ::XXXX:XXXX (no ffff prefix)
|
||||
// =========================================================================
|
||||
|
||||
it("converts IPv4-compatible loopback ::7f00:1 -> 127.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::7f00:1")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts IPv4-compatible metadata ::a9fe:a9fe -> 169.254.169.254", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::a9fe:a9fe")).toBe("169.254.169.254");
|
||||
});
|
||||
|
||||
it("converts IPv4-compatible private ::a00:1 -> 10.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::a00:1")).toBe("10.0.0.1");
|
||||
});
|
||||
|
||||
it("converts IPv4-compatible public ::5db8:d822 -> 93.184.216.34", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::5db8:d822")).toBe("93.184.216.34");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// NAT64 prefix (RFC 6052): 64:ff9b::XXXX:XXXX
|
||||
// =========================================================================
|
||||
|
||||
it("converts NAT64 loopback 64:ff9b::7f00:1 -> 127.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("64:ff9b::7f00:1")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("converts NAT64 metadata 64:ff9b::a9fe:a9fe -> 169.254.169.254", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("64:ff9b::a9fe:a9fe")).toBe("169.254.169.254");
|
||||
});
|
||||
|
||||
it("converts NAT64 private 64:ff9b::a00:1 -> 10.0.0.1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("64:ff9b::a00:1")).toBe("10.0.0.1");
|
||||
});
|
||||
|
||||
it("converts NAT64 public 64:ff9b::5db8:d822 -> 93.184.216.34", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("64:ff9b::5db8:d822")).toBe("93.184.216.34");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Non-matching inputs -> null
|
||||
// =========================================================================
|
||||
|
||||
it("returns null for plain IPv4", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("127.0.0.1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for IPv6 loopback ::1", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("::1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for regular IPv6 address", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("2001:db8::1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for link-local IPv6", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("fe80::1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for hostnames", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("example.com")).toBeNull();
|
||||
expect(normalizeIPv6MappedToIPv4("localhost")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(normalizeIPv6MappedToIPv4("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for dotted-decimal mapped form (handled separately)", () => {
|
||||
// ::ffff:127.0.0.1 uses the dotted-decimal regex, not hex normalization
|
||||
expect(normalizeIPv6MappedToIPv4("::ffff:127.0.0.1")).toBeNull();
|
||||
});
|
||||
});
|
||||
403
packages/core/tests/unit/import/wordpress-plugin-i18n.test.ts
Normal file
403
packages/core/tests/unit/import/wordpress-plugin-i18n.test.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Tests for WPML/Polylang auto-detection in WordPress plugin import source.
|
||||
*
|
||||
* Verifies that the probe() and analyze() methods correctly extract and
|
||||
* surface i18n detection from the EmDash Exporter plugin's API responses.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { wordpressPluginSource } from "../../../src/import/sources/wordpress-plugin.js";
|
||||
|
||||
// ─── Mock fetch ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal valid probe response without i18n */
|
||||
function makeProbeResponse(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
emdash_exporter: "1.0.0",
|
||||
wordpress_version: "6.5",
|
||||
site: {
|
||||
title: "Test Site",
|
||||
description: "A test site",
|
||||
url: "https://example.com",
|
||||
home: "https://example.com",
|
||||
language: "en-US",
|
||||
timezone: "UTC",
|
||||
},
|
||||
capabilities: {
|
||||
application_passwords: true,
|
||||
acf: false,
|
||||
yoast: false,
|
||||
rankmath: false,
|
||||
},
|
||||
post_types: [
|
||||
{ name: "post", label: "Posts", count: 10 },
|
||||
{ name: "page", label: "Pages", count: 5 },
|
||||
],
|
||||
media_count: 20,
|
||||
endpoints: {},
|
||||
auth_instructions: {
|
||||
method: "application_passwords",
|
||||
instructions: "Create an application password",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal valid analyze response without i18n */
|
||||
function makeAnalyzeResponse(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
site: { title: "Test Site", url: "https://example.com" },
|
||||
post_types: [
|
||||
{
|
||||
name: "post",
|
||||
label: "Posts",
|
||||
label_singular: "Post",
|
||||
total: 10,
|
||||
by_status: { publish: 8, draft: 2 },
|
||||
supports: { title: true, editor: true, thumbnail: true },
|
||||
taxonomies: ["category", "post_tag"],
|
||||
custom_fields: [],
|
||||
hierarchical: false,
|
||||
has_archive: true,
|
||||
},
|
||||
],
|
||||
taxonomies: [
|
||||
{
|
||||
name: "category",
|
||||
label: "Categories",
|
||||
hierarchical: true,
|
||||
term_count: 5,
|
||||
object_types: ["post"],
|
||||
},
|
||||
{
|
||||
name: "post_tag",
|
||||
label: "Tags",
|
||||
hierarchical: false,
|
||||
term_count: 12,
|
||||
object_types: ["post"],
|
||||
},
|
||||
],
|
||||
authors: [
|
||||
{ id: 1, login: "admin", email: "admin@example.com", display_name: "Admin", post_count: 10 },
|
||||
],
|
||||
attachments: { count: 20, by_type: { "image/jpeg": 15, "image/png": 5 } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Probe tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("WordPress Plugin Source — i18n detection", () => {
|
||||
describe("probe()", () => {
|
||||
it("returns i18n when WPML is detected", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeProbeResponse({
|
||||
i18n: {
|
||||
plugin: "wpml",
|
||||
default_locale: "en",
|
||||
locales: ["en", "fr", "de"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await wordpressPluginSource.probe!("https://example.com");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.i18n).toEqual({
|
||||
plugin: "wpml",
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "fr", "de"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns i18n when Polylang is detected", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeProbeResponse({
|
||||
i18n: {
|
||||
plugin: "polylang",
|
||||
default_locale: "fr",
|
||||
locales: ["fr", "en"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await wordpressPluginSource.probe!("https://example.com");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.i18n).toEqual({
|
||||
plugin: "polylang",
|
||||
defaultLocale: "fr",
|
||||
locales: ["fr", "en"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined i18n when no multilingual plugin", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(makeProbeResponse()), { status: 200 }),
|
||||
);
|
||||
|
||||
const result = await wordpressPluginSource.probe!("https://example.com");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.i18n).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves other probe fields alongside i18n", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
makeProbeResponse({
|
||||
i18n: {
|
||||
plugin: "wpml",
|
||||
default_locale: "en",
|
||||
locales: ["en", "es"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await wordpressPluginSource.probe!("https://example.com");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.sourceId).toBe("wordpress-plugin");
|
||||
expect(result!.confidence).toBe("definite");
|
||||
expect(result!.detected.platform).toBe("wordpress");
|
||||
expect(result!.preview?.posts).toBe(10);
|
||||
expect(result!.i18n?.plugin).toBe("wpml");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Analyze tests ───────────────────────────────────────────────────────
|
||||
|
||||
describe("analyze()", () => {
|
||||
it("returns i18n when WPML is detected", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/analyze")) {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
makeAnalyzeResponse({
|
||||
i18n: {
|
||||
plugin: "wpml",
|
||||
default_locale: "en",
|
||||
locales: ["en", "fr", "de"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
// Media endpoint — return empty
|
||||
return new Response(
|
||||
JSON.stringify({ items: [], total: 0, pages: 0, page: 1, per_page: 100 }),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
const analysis = await wordpressPluginSource.analyze(
|
||||
{ type: "url", url: "https://example.com", token: "test-token" },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(analysis.i18n).toEqual({
|
||||
plugin: "wpml",
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "fr", "de"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns i18n when Polylang is detected", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/analyze")) {
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
makeAnalyzeResponse({
|
||||
i18n: {
|
||||
plugin: "polylang",
|
||||
default_locale: "fr",
|
||||
locales: ["fr", "en", "de"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ items: [], total: 0, pages: 0, page: 1, per_page: 100 }),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
const analysis = await wordpressPluginSource.analyze(
|
||||
{ type: "url", url: "https://example.com", token: "test-token" },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(analysis.i18n).toEqual({
|
||||
plugin: "polylang",
|
||||
defaultLocale: "fr",
|
||||
locales: ["fr", "en", "de"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined i18n when no multilingual plugin", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/analyze")) {
|
||||
return new Response(JSON.stringify(makeAnalyzeResponse()), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ items: [], total: 0, pages: 0, page: 1, per_page: 100 }),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
const analysis = await wordpressPluginSource.analyze(
|
||||
{ type: "url", url: "https://example.com", token: "test-token" },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(analysis.i18n).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Content fetch — locale/translationGroup passthrough ─────────────────
|
||||
|
||||
describe("fetchContent()", () => {
|
||||
it("passes through locale and translationGroup from plugin posts", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
post_type: "post",
|
||||
status: "publish",
|
||||
slug: "hello-world",
|
||||
title: "Hello World",
|
||||
content: "",
|
||||
excerpt: "",
|
||||
date: "2024-01-01T00:00:00",
|
||||
date_gmt: "2024-01-01T00:00:00",
|
||||
modified: "2024-01-01T00:00:00",
|
||||
modified_gmt: "2024-01-01T00:00:00",
|
||||
author: null,
|
||||
parent: null,
|
||||
menu_order: 0,
|
||||
taxonomies: {},
|
||||
meta: {},
|
||||
locale: "en",
|
||||
translation_group: "group-1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
post_type: "post",
|
||||
status: "publish",
|
||||
slug: "bonjour-le-monde",
|
||||
title: "Bonjour le monde",
|
||||
content: "",
|
||||
excerpt: "",
|
||||
date: "2024-01-01T00:00:00",
|
||||
date_gmt: "2024-01-01T00:00:00",
|
||||
modified: "2024-01-01T00:00:00",
|
||||
modified_gmt: "2024-01-01T00:00:00",
|
||||
author: null,
|
||||
parent: null,
|
||||
menu_order: 0,
|
||||
taxonomies: {},
|
||||
meta: {},
|
||||
locale: "fr",
|
||||
translation_group: "group-1",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
pages: 1,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const items = [];
|
||||
for await (const item of wordpressPluginSource.fetchContent(
|
||||
{ type: "url", url: "https://example.com", token: "test-token" },
|
||||
{ postTypes: ["post"] },
|
||||
)) {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]!.locale).toBe("en");
|
||||
expect(items[0]!.translationGroup).toBe("group-1");
|
||||
expect(items[1]!.locale).toBe("fr");
|
||||
expect(items[1]!.translationGroup).toBe("group-1");
|
||||
});
|
||||
|
||||
it("returns undefined locale/translationGroup when not present", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
post_type: "post",
|
||||
status: "publish",
|
||||
slug: "hello",
|
||||
title: "Hello",
|
||||
content: "",
|
||||
excerpt: "",
|
||||
date: "2024-01-01T00:00:00",
|
||||
date_gmt: "2024-01-01T00:00:00",
|
||||
modified: "2024-01-01T00:00:00",
|
||||
modified_gmt: "2024-01-01T00:00:00",
|
||||
author: null,
|
||||
parent: null,
|
||||
menu_order: 0,
|
||||
taxonomies: {},
|
||||
meta: {},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
pages: 1,
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const items = [];
|
||||
for await (const item of wordpressPluginSource.fetchContent(
|
||||
{ type: "url", url: "https://example.com", token: "test-token" },
|
||||
{ postTypes: ["post"] },
|
||||
)) {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]!.locale).toBeUndefined();
|
||||
expect(items[0]!.translationGroup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/core/tests/unit/import/wp-prepare-schema.test.ts
Normal file
139
packages/core/tests/unit/import/wp-prepare-schema.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for WordPress import prepare schema validation
|
||||
*
|
||||
* Regression test for #167: wpPrepareBody schema defined fields as z.record()
|
||||
* but all producers (analyzer, admin UI) send an array of ImportFieldDef.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { wpPrepareBody } from "../../../src/api/schemas/import.js";
|
||||
|
||||
describe("wpPrepareBody schema", () => {
|
||||
it("accepts fields as an array of ImportFieldDef objects", () => {
|
||||
const input = {
|
||||
postTypes: [
|
||||
{
|
||||
name: "post",
|
||||
collection: "posts",
|
||||
fields: [
|
||||
{
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
required: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
slug: "excerpt",
|
||||
label: "Excerpt",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = wpPrepareBody.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts fields with optional searchable property", () => {
|
||||
const input = {
|
||||
postTypes: [
|
||||
{
|
||||
name: "page",
|
||||
collection: "pages",
|
||||
fields: [
|
||||
{
|
||||
slug: "featured_image",
|
||||
label: "Featured Image",
|
||||
type: "image",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = wpPrepareBody.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts postTypes without fields (optional)", () => {
|
||||
const input = {
|
||||
postTypes: [
|
||||
{
|
||||
name: "post",
|
||||
collection: "posts",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = wpPrepareBody.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects fields with missing required properties", () => {
|
||||
const input = {
|
||||
postTypes: [
|
||||
{
|
||||
name: "post",
|
||||
collection: "posts",
|
||||
fields: [
|
||||
{
|
||||
slug: "content",
|
||||
// missing label, type, required
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = wpPrepareBody.safeParse(input);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts multiple postTypes with fields", () => {
|
||||
const input = {
|
||||
postTypes: [
|
||||
{
|
||||
name: "post",
|
||||
collection: "posts",
|
||||
fields: [
|
||||
{
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
required: true,
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "page",
|
||||
collection: "pages",
|
||||
fields: [
|
||||
{
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
required: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
slug: "featured_image",
|
||||
label: "Featured Image",
|
||||
type: "image",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = wpPrepareBody.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
223
packages/core/tests/unit/loader-cursor-pagination.test.ts
Normal file
223
packages/core/tests/unit/loader-cursor-pagination.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { handleContentCreate } from "../../src/api/index.js";
|
||||
import { decodeCursor } from "../../src/database/repositories/types.js";
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
import { emdashLoader } from "../../src/loader.js";
|
||||
import { runWithContext } from "../../src/request-context.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../utils/test-db.js";
|
||||
|
||||
describe("Loader cursor pagination", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
async function createPublishedPost(title: string) {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title },
|
||||
status: "published",
|
||||
});
|
||||
if (!result.success) throw new Error("Failed to create post");
|
||||
return result.data!.item;
|
||||
}
|
||||
|
||||
it("should return nextCursor when there are more results", async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await createPublishedPost(`Post ${i}`);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post", limit: 3 } }),
|
||||
);
|
||||
|
||||
expect(result.entries).toHaveLength(3);
|
||||
expect(result.nextCursor).toBeTruthy();
|
||||
|
||||
// Verify the cursor is a valid encoded cursor
|
||||
const decoded = decodeCursor(result.nextCursor!);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded!.orderValue).toBeTruthy();
|
||||
expect(decoded!.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not return nextCursor when all results fit in one page", async () => {
|
||||
await createPublishedPost("Post 1");
|
||||
await createPublishedPost("Post 2");
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post", limit: 10 } }),
|
||||
);
|
||||
|
||||
expect(result.entries).toHaveLength(2);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not return nextCursor when no limit is set", async () => {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await createPublishedPost(`Post ${i}`);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post" } }),
|
||||
);
|
||||
|
||||
expect(result.entries).toHaveLength(3);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should paginate through all results using cursor", async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await createPublishedPost(`Post ${i}`);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
|
||||
// First page
|
||||
const page1 = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post", limit: 2 } }),
|
||||
);
|
||||
expect(page1.entries).toHaveLength(2);
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
// Second page
|
||||
const page2 = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({
|
||||
filter: { type: "post", limit: 2, cursor: page1.nextCursor },
|
||||
}),
|
||||
);
|
||||
expect(page2.entries).toHaveLength(2);
|
||||
expect(page2.nextCursor).toBeTruthy();
|
||||
|
||||
// Third page (last item)
|
||||
const page3 = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({
|
||||
filter: { type: "post", limit: 2, cursor: page2.nextCursor },
|
||||
}),
|
||||
);
|
||||
expect(page3.entries).toHaveLength(1);
|
||||
expect(page3.nextCursor).toBeUndefined();
|
||||
|
||||
// Verify no overlap between pages
|
||||
const allIds = [
|
||||
...page1.entries!.map((e) => e.data.id),
|
||||
...page2.entries!.map((e) => e.data.id),
|
||||
...page3.entries!.map((e) => e.data.id),
|
||||
];
|
||||
const uniqueIds = new Set(allIds);
|
||||
expect(uniqueIds.size).toBe(5);
|
||||
});
|
||||
|
||||
it("should maintain sort order across pages", async () => {
|
||||
// Create posts with different titles to test ascending sort
|
||||
const titles = ["Delta", "Alpha", "Echo", "Bravo", "Charlie"];
|
||||
for (const title of titles) {
|
||||
await createPublishedPost(title);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
|
||||
// Paginate with ascending title order
|
||||
const allEntries: Array<{ data: Record<string, unknown> }> = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({
|
||||
filter: {
|
||||
type: "post",
|
||||
limit: 2,
|
||||
cursor,
|
||||
orderBy: { title: "asc" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
allEntries.push(...result.entries!);
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
|
||||
expect(allEntries).toHaveLength(5);
|
||||
const sortedTitles = allEntries.map((e) => e.data.title);
|
||||
expect(sortedTitles).toEqual(["Alpha", "Bravo", "Charlie", "Delta", "Echo"]);
|
||||
});
|
||||
|
||||
it("should return empty entries with no nextCursor for empty collection", async () => {
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post", limit: 10 } }),
|
||||
);
|
||||
|
||||
expect(result.entries).toHaveLength(0);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle invalid cursor gracefully", async () => {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await createPublishedPost(`Post ${i}`);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
|
||||
// Invalid cursor should be ignored (no cursor condition applied)
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({
|
||||
filter: { type: "post", limit: 10, cursor: "not-a-valid-cursor" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Should return all entries since the invalid cursor is ignored
|
||||
expect(result.entries).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should work with limit of 1", async () => {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await createPublishedPost(`Post ${i}`);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
const allEntries: Array<{ data: Record<string, unknown> }> = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
// Page through one at a time
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({
|
||||
filter: { type: "post", limit: 1, cursor },
|
||||
}),
|
||||
);
|
||||
allEntries.push(...result.entries!);
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
|
||||
expect(allEntries).toHaveLength(3);
|
||||
const uniqueIds = new Set(allEntries.map((e) => e.data.id));
|
||||
expect(uniqueIds.size).toBe(3);
|
||||
});
|
||||
|
||||
it("should include nextCursor in collection-level return alongside cacheHint", async () => {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await createPublishedPost(`Post ${i}`);
|
||||
}
|
||||
|
||||
const loader = emdashLoader();
|
||||
const result = await runWithContext({ editMode: false, db }, () =>
|
||||
loader.loadCollection!({ filter: { type: "post", limit: 2 } }),
|
||||
);
|
||||
|
||||
// Both cacheHint and nextCursor should be present
|
||||
expect(result.cacheHint).toBeDefined();
|
||||
expect(result.cacheHint!.tags).toEqual(["post"]);
|
||||
expect(result.nextCursor).toBeTruthy();
|
||||
});
|
||||
});
|
||||
121
packages/core/tests/unit/loader-revision-preview.test.ts
Normal file
121
packages/core/tests/unit/loader-revision-preview.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { handleContentCreate } from "../../src/api/index.js";
|
||||
import { ContentRepository } from "../../src/database/repositories/content.js";
|
||||
import { RevisionRepository } from "../../src/database/repositories/revision.js";
|
||||
import type { Database } from "../../src/database/types.js";
|
||||
import { emdashLoader } from "../../src/loader.js";
|
||||
import { runWithContext } from "../../src/request-context.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../utils/test-db.js";
|
||||
|
||||
describe("Loader revision preview", () => {
|
||||
let db: Kysely<Database>;
|
||||
let revisionRepo: RevisionRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
revisionRepo = new RevisionRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
async function createPublishedPost(title: string) {
|
||||
const result = await handleContentCreate(db, "post", {
|
||||
data: { title },
|
||||
status: "published",
|
||||
});
|
||||
if (!result.success) throw new Error("Failed to create post");
|
||||
return result.data!.item;
|
||||
}
|
||||
|
||||
it("should return Date objects for system date fields in revision preview", async () => {
|
||||
const post = await createPublishedPost("Test Post");
|
||||
|
||||
// Publish the post to set published_at
|
||||
const contentRepo = new ContentRepository(db);
|
||||
await contentRepo.publish("post", post.id);
|
||||
|
||||
// Create a revision (simulating a draft edit)
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: post.id,
|
||||
data: { title: "Draft Title" },
|
||||
});
|
||||
|
||||
const loader = emdashLoader();
|
||||
const slug = post.slug!;
|
||||
const result = await runWithContext({ editMode: true, db }, () =>
|
||||
loader.loadEntry!({ filter: { type: "post", id: slug, revisionId: revision.id } }),
|
||||
);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toHaveProperty("error");
|
||||
const data = (result as { data: Record<string, unknown> }).data;
|
||||
|
||||
// These must be Date objects, not ISO strings
|
||||
expect(data.createdAt).toBeInstanceOf(Date);
|
||||
expect(data.updatedAt).toBeInstanceOf(Date);
|
||||
expect(data.publishedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should return null for unpopulated date fields in revision preview", async () => {
|
||||
// Create a draft post (no publishedAt)
|
||||
const createResult = await handleContentCreate(db, "post", {
|
||||
data: { title: "Draft Post" },
|
||||
status: "draft",
|
||||
});
|
||||
if (!createResult.success) throw new Error("Failed to create post");
|
||||
const post = createResult.data!.item;
|
||||
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: post.id,
|
||||
data: { title: "Updated Draft" },
|
||||
});
|
||||
|
||||
const loader = emdashLoader();
|
||||
const slug = post.slug!;
|
||||
const entry = await runWithContext({ editMode: true, db }, () =>
|
||||
loader.loadEntry!({ filter: { type: "post", id: slug, revisionId: revision.id } }),
|
||||
);
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("error");
|
||||
const data = (entry as { data: Record<string, unknown> }).data;
|
||||
|
||||
// Draft posts have no publishedAt
|
||||
expect(data.publishedAt).toBeNull();
|
||||
// But createdAt and updatedAt should still be Date objects
|
||||
expect(data.createdAt).toBeInstanceOf(Date);
|
||||
expect(data.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should use revision content fields while preserving system date types", async () => {
|
||||
const post = await createPublishedPost("Original Title");
|
||||
|
||||
const revision = await revisionRepo.create({
|
||||
collection: "post",
|
||||
entryId: post.id,
|
||||
data: { title: "Revised Title" },
|
||||
});
|
||||
|
||||
const loader = emdashLoader();
|
||||
const slug = post.slug!;
|
||||
const entry = await runWithContext({ editMode: true, db }, () =>
|
||||
loader.loadEntry!({ filter: { type: "post", id: slug, revisionId: revision.id } }),
|
||||
);
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry).not.toHaveProperty("error");
|
||||
const data = (entry as { data: Record<string, unknown> }).data;
|
||||
|
||||
// Content from revision
|
||||
expect(data.title).toBe("Revised Title");
|
||||
// System dates from content table, as Date objects
|
||||
expect(data.createdAt).toBeInstanceOf(Date);
|
||||
expect(data.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
861
packages/core/tests/unit/mcp/authorization.test.ts
Normal file
861
packages/core/tests/unit/mcp/authorization.test.ts
Normal file
@@ -0,0 +1,861 @@
|
||||
/**
|
||||
* MCP Authorization Tests
|
||||
*
|
||||
* Verifies that MCP tools enforce ownership checks and role requirements,
|
||||
* mirroring the REST API's authorization patterns.
|
||||
*
|
||||
* Tests use the MCP Client/Server SDK with InMemoryTransport, injecting
|
||||
* authInfo to simulate different users and roles.
|
||||
*/
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { Role } from "@emdashcms/auth";
|
||||
import type { RoleLevel } from "@emdashcms/auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { EmDashHandlers } from "../../../src/astro/types.js";
|
||||
import { createMcpServer } from "../../../src/mcp/server.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INSUFFICIENT_PERMISSIONS_RE = /Insufficient permissions/i;
|
||||
const INSUFFICIENT_SCOPE_RE = /Insufficient scope/i;
|
||||
const NO_AUTHOR_ID_RE = /content has no authorId/i;
|
||||
|
||||
const AUTHOR_USER_ID = "user_author";
|
||||
const OTHER_USER_ID = "user_other";
|
||||
const CONTENT_ID = "01CONTENT";
|
||||
const CONTENT_SLUG = "test-post";
|
||||
const REVISION_ID = "01REVISION";
|
||||
const MEDIA_ID = "01MEDIA";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock EmDashHandlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a minimal mock EmDashHandlers that returns content owned by `ownerId`. */
|
||||
function createMockHandlers(ownerId: string = AUTHOR_USER_ID): EmDashHandlers {
|
||||
const contentItem = {
|
||||
id: CONTENT_ID,
|
||||
slug: "test-post",
|
||||
authorId: ownerId,
|
||||
status: "draft",
|
||||
title: "Test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mediaItem = {
|
||||
id: MEDIA_ID,
|
||||
filename: "test.png",
|
||||
authorId: ownerId,
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
};
|
||||
|
||||
return {
|
||||
db: {} as EmDashHandlers["db"],
|
||||
invalidateManifest: vi.fn(),
|
||||
handleContentGet: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem, _rev: "rev1" },
|
||||
}),
|
||||
handleContentGetIncludingTrashed: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: { ...contentItem, status: "trashed" } },
|
||||
}),
|
||||
handleContentList: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { items: [contentItem] },
|
||||
}),
|
||||
handleContentCreate: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentUpdate: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentDelete: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentRestore: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentPermanentDelete: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { deleted: true },
|
||||
}),
|
||||
handleContentPublish: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: { ...contentItem, status: "published" } },
|
||||
}),
|
||||
handleContentUnpublish: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentSchedule: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: { ...contentItem, status: "scheduled" } },
|
||||
}),
|
||||
handleContentCompare: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { live: null, draft: contentItem, hasChanges: false },
|
||||
}),
|
||||
handleContentDiscardDraft: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentListTrashed: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { items: [] },
|
||||
}),
|
||||
handleContentDuplicate: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
handleContentTranslations: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { translations: [] },
|
||||
}),
|
||||
handleMediaGet: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: mediaItem },
|
||||
}),
|
||||
handleMediaList: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { items: [mediaItem] },
|
||||
}),
|
||||
handleMediaUpdate: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: mediaItem },
|
||||
}),
|
||||
handleMediaDelete: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { deleted: true },
|
||||
}),
|
||||
handleRevisionList: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { items: [] },
|
||||
}),
|
||||
handleRevisionGet: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
item: {
|
||||
id: REVISION_ID,
|
||||
collection: "post",
|
||||
entryId: CONTENT_ID,
|
||||
authorId: ownerId,
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
handleRevisionRestore: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentItem },
|
||||
}),
|
||||
} as unknown as EmDashHandlers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport helper
|
||||
//
|
||||
// InMemoryTransport supports passing authInfo on send(). We create a
|
||||
// subclass that automatically injects authInfo on every message sent from
|
||||
// the client side, simulating the HTTP transport's auth injection.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class AuthInjectingTransport extends InMemoryTransport {
|
||||
constructor(private authInfo: Record<string, unknown>) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async send(
|
||||
message: Parameters<InMemoryTransport["send"]>[0],
|
||||
options?: Parameters<InMemoryTransport["send"]>[1],
|
||||
): Promise<void> {
|
||||
const existingExtra =
|
||||
options?.authInfo && typeof options.authInfo === "object" && "extra" in options.authInfo
|
||||
? (options.authInfo.extra as Record<string, unknown>)
|
||||
: {};
|
||||
return super.send(message, {
|
||||
...options,
|
||||
authInfo: {
|
||||
token: "",
|
||||
clientId: "test",
|
||||
scopes: [],
|
||||
...options?.authInfo,
|
||||
extra: {
|
||||
...this.authInfo,
|
||||
...existingExtra,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a linked transport pair where the client side injects authInfo.
|
||||
*/
|
||||
function createAuthenticatedPair(authInfo: {
|
||||
emdash: EmDashHandlers;
|
||||
userId: string;
|
||||
userRole: RoleLevel;
|
||||
tokenScopes?: string[];
|
||||
}): [AuthInjectingTransport, InMemoryTransport] {
|
||||
const clientTransport = new AuthInjectingTransport(authInfo);
|
||||
const serverTransport = new InMemoryTransport();
|
||||
// Link them (accessing private field)
|
||||
(clientTransport as unknown as Record<string, unknown>)._otherTransport = serverTransport;
|
||||
(serverTransport as unknown as Record<string, unknown>)._otherTransport = clientTransport;
|
||||
return [clientTransport, serverTransport];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setupMcpPair(opts: {
|
||||
userId: string;
|
||||
userRole: RoleLevel;
|
||||
handlers?: EmDashHandlers;
|
||||
tokenScopes?: string[];
|
||||
}): Promise<{ client: Client; cleanup: () => Promise<void> }> {
|
||||
const handlers = opts.handlers ?? createMockHandlers();
|
||||
const server = createMcpServer();
|
||||
const [clientTransport, serverTransport] = createAuthenticatedPair({
|
||||
emdash: handlers,
|
||||
userId: opts.userId,
|
||||
userRole: opts.userRole,
|
||||
tokenScopes: opts.tokenScopes,
|
||||
});
|
||||
|
||||
const client = new Client({ name: "test", version: "1.0" });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
return {
|
||||
client,
|
||||
cleanup: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MCP Authorization", () => {
|
||||
let client: Client;
|
||||
let cleanup: () => Promise<void>;
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Ownership checks: CONTRIBUTOR cannot modify others' content
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content ownership enforcement", () => {
|
||||
it("CONTRIBUTOR cannot update another user's content", async () => {
|
||||
// Content owned by AUTHOR_USER_ID, caller is OTHER_USER_ID with CONTRIBUTOR role
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.CONTRIBUTOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "Hacked" },
|
||||
},
|
||||
});
|
||||
|
||||
// CONTRIBUTOR role is below AUTHOR minimum
|
||||
expect(result.isError).toBe(true);
|
||||
const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
|
||||
expect(text).toMatch(INSUFFICIENT_PERMISSIONS_RE);
|
||||
});
|
||||
|
||||
it("AUTHOR can update their own content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "My update" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AUTHOR cannot update another user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "Hacked" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("EDITOR can update any user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.EDITOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "Editor update" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentUpdate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// content_delete ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content_delete ownership", () => {
|
||||
it("AUTHOR can delete their own content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_delete",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AUTHOR cannot delete another user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_delete",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// content_permanent_delete: ADMIN only
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content_permanent_delete requires ADMIN", () => {
|
||||
it("EDITOR cannot permanently delete content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.EDITOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_permanent_delete",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentPermanentDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ADMIN can permanently delete content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.ADMIN,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_permanent_delete",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentPermanentDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// content_publish ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content_publish ownership", () => {
|
||||
it("AUTHOR can publish their own content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_publish",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentPublish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AUTHOR cannot publish another user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_publish",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// content_restore ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content_restore ownership", () => {
|
||||
it("AUTHOR cannot restore another user's trashed content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_restore",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("EDITOR can restore any user's trashed content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.EDITOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_restore",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentRestore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// revision_restore ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("revision_restore ownership", () => {
|
||||
it("AUTHOR cannot restore revision on another user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "revision_restore",
|
||||
arguments: { revisionId: REVISION_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleRevisionRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("EDITOR can restore revision on any content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.EDITOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "revision_restore",
|
||||
arguments: { revisionId: REVISION_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleRevisionRestore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Media ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("media ownership enforcement", () => {
|
||||
it("AUTHOR can update their own media", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "media_update",
|
||||
arguments: { id: MEDIA_ID, alt: "Updated alt" },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleMediaUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AUTHOR cannot update another user's media", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "media_update",
|
||||
arguments: { id: MEDIA_ID, alt: "Hacked" },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleMediaUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AUTHOR cannot delete another user's media", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "media_delete",
|
||||
arguments: { id: MEDIA_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleMediaDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("EDITOR can delete any user's media", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.EDITOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "media_delete",
|
||||
arguments: { id: MEDIA_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleMediaDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Token scope enforcement
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("token scope enforcement", () => {
|
||||
it("rejects content_update without content:write scope", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.ADMIN,
|
||||
handlers,
|
||||
tokenScopes: ["content:read"],
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "No scope" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
|
||||
expect(text).toMatch(INSUFFICIENT_SCOPE_RE);
|
||||
});
|
||||
|
||||
it("allows content_update with content:write scope", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
tokenScopes: ["content:read", "content:write"],
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "Valid scope" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it("session auth (no tokenScopes) allows all scopes", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
// No tokenScopes = session auth
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "Session auth" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// content_schedule ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content_schedule ownership", () => {
|
||||
it("AUTHOR cannot schedule another user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_schedule",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
scheduledAt: "2030-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// content_unpublish ownership
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("content_unpublish ownership", () => {
|
||||
it("AUTHOR cannot unpublish another user's content", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: OTHER_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_unpublish",
|
||||
arguments: { collection: "post", id: CONTENT_ID },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(handlers.handleContentUnpublish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// resolvedId: slug -> ULID resolution before handler calls
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("resolvedId passthrough", () => {
|
||||
it("content_restore passes resolvedId (not slug) to handler", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_restore",
|
||||
arguments: { collection: "post", id: CONTENT_SLUG },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
// The mock returns item.id = CONTENT_ID. The tool should resolve
|
||||
// the slug to CONTENT_ID via extractContentId and pass that to the handler.
|
||||
expect(handlers.handleContentRestore).toHaveBeenCalledWith("post", CONTENT_ID);
|
||||
});
|
||||
|
||||
it("content_discard_draft passes resolvedId (not slug) to handler", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_discard_draft",
|
||||
arguments: { collection: "post", id: CONTENT_SLUG },
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentDiscardDraft).toHaveBeenCalledWith("post", CONTENT_ID);
|
||||
});
|
||||
|
||||
it("content_update passes resolvedId (not slug) to handler", async () => {
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_SLUG,
|
||||
data: { title: "Updated" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(handlers.handleContentUpdate).toHaveBeenCalledWith(
|
||||
"post",
|
||||
CONTENT_ID,
|
||||
expect.objectContaining({ data: { title: "Updated" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// extractContentAuthorId: missing authorId
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("missing authorId handling", () => {
|
||||
it("returns clear error when content has no authorId", async () => {
|
||||
// Create handlers where content has no authorId (e.g. imported content)
|
||||
const handlers = createMockHandlers(AUTHOR_USER_ID);
|
||||
const contentWithoutAuthor = {
|
||||
id: CONTENT_ID,
|
||||
slug: "imported-post",
|
||||
status: "draft",
|
||||
title: "Imported",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
// no authorId
|
||||
};
|
||||
handlers.handleContentGet = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { item: contentWithoutAuthor },
|
||||
});
|
||||
|
||||
({ client, cleanup } = await setupMcpPair({
|
||||
userId: AUTHOR_USER_ID,
|
||||
userRole: Role.AUTHOR,
|
||||
handlers,
|
||||
}));
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "content_update",
|
||||
arguments: {
|
||||
collection: "post",
|
||||
id: CONTENT_ID,
|
||||
data: { title: "Should fail" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const text = (result.content as Array<{ text: string }>)[0]?.text ?? "";
|
||||
expect(text).toMatch(NO_AUTHOR_ID_RE);
|
||||
});
|
||||
});
|
||||
});
|
||||
423
packages/core/tests/unit/media/normalize.test.ts
Normal file
423
packages/core/tests/unit/media/normalize.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { normalizeMediaValue } from "../../../src/media/normalize.js";
|
||||
import type { MediaProvider, MediaProviderItem } from "../../../src/media/types.js";
|
||||
|
||||
function mockProvider(getResult: MediaProviderItem | null = null): MediaProvider {
|
||||
return {
|
||||
list: vi.fn().mockResolvedValue({ items: [], nextCursor: undefined }),
|
||||
get: vi.fn().mockResolvedValue(getResult),
|
||||
getEmbed: vi.fn().mockReturnValue({ type: "image", src: "/test" }),
|
||||
};
|
||||
}
|
||||
|
||||
function getProvider(
|
||||
providers: Record<string, MediaProvider>,
|
||||
): (id: string) => MediaProvider | undefined {
|
||||
return (id: string) => providers[id];
|
||||
}
|
||||
|
||||
describe("normalizeMediaValue", () => {
|
||||
it("returns null for null input", async () => {
|
||||
const result = await normalizeMediaValue(null, getProvider({}));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined input", async () => {
|
||||
const result = await normalizeMediaValue(undefined, getProvider({}));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("converts bare HTTP URL to external MediaValue", async () => {
|
||||
const result = await normalizeMediaValue("https://example.com/photo.jpg", getProvider({}));
|
||||
expect(result).toEqual({
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: "https://example.com/photo.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts bare HTTPS URL to external MediaValue", async () => {
|
||||
const result = await normalizeMediaValue("http://example.com/photo.jpg", getProvider({}));
|
||||
expect(result).toEqual({
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: "http://example.com/photo.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("converts bare internal media URL to full local MediaValue via provider", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
alt: "A photo",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
"/_emdash/api/media/file/01ABC.jpg",
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
expect(local.get).toHaveBeenCalledWith("01ABC.jpg");
|
||||
expect(result).toEqual({
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
alt: "A photo",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to external for internal URL when local provider unavailable", async () => {
|
||||
const result = await normalizeMediaValue(
|
||||
"/_emdash/api/media/file/01ABC.jpg",
|
||||
getProvider({}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: "/_emdash/api/media/file/01ABC.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to external for internal URL when provider.get returns null", async () => {
|
||||
const local = mockProvider(null);
|
||||
const result = await normalizeMediaValue(
|
||||
"/_emdash/api/media/file/01ABC.jpg",
|
||||
getProvider({ local }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: "/_emdash/api/media/file/01ABC.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("fills missing dimensions from local provider", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
alt: "My photo",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
},
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
expect(local.get).toHaveBeenCalledWith("01ABC");
|
||||
expect(result).toMatchObject({
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
alt: "My photo",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fills missing storageKey from local provider", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
expect(local.get).toHaveBeenCalledWith("01ABC");
|
||||
expect(result).toMatchObject({
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fills missing mimeType and filename from local provider", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
},
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
});
|
||||
});
|
||||
|
||||
it("fills dimensions from external provider", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "cf-abc123",
|
||||
filename: "hero.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
meta: { variants: ["public"] },
|
||||
};
|
||||
const cfImages = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "cloudflare-images",
|
||||
id: "cf-abc123",
|
||||
alt: "Hero banner",
|
||||
previewUrl: "https://imagedelivery.net/hash/cf-abc123/w=400",
|
||||
},
|
||||
getProvider({ "cloudflare-images": cfImages }),
|
||||
);
|
||||
|
||||
expect(cfImages.get).toHaveBeenCalledWith("cf-abc123");
|
||||
expect(result).toMatchObject({
|
||||
provider: "cloudflare-images",
|
||||
id: "cf-abc123",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
alt: "Hero banner",
|
||||
previewUrl: "https://imagedelivery.net/hash/cf-abc123/w=400",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call provider when dimensions already present", async () => {
|
||||
const cfImages = mockProvider(null);
|
||||
|
||||
const value = {
|
||||
provider: "cloudflare-images",
|
||||
id: "cf-abc123",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
filename: "hero.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
alt: "Hero banner",
|
||||
previewUrl: "https://imagedelivery.net/hash/cf-abc123/w=400",
|
||||
meta: { variants: ["public"] },
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({ "cloudflare-images": cfImages }));
|
||||
|
||||
expect(cfImages.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("preserves caller alt over provider alt", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
alt: "Provider alt text",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
alt: "User alt text",
|
||||
},
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
expect(result!.alt).toBe("User alt text");
|
||||
});
|
||||
|
||||
it("uses provider alt when caller alt is not set", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
alt: "Provider alt text",
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
},
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
expect(result!.alt).toBe("Provider alt text");
|
||||
});
|
||||
|
||||
it("returns value as-is for unknown provider", async () => {
|
||||
const value = {
|
||||
provider: "some-unknown-provider",
|
||||
id: "item-123",
|
||||
width: 800,
|
||||
height: 600,
|
||||
alt: "Some image",
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({}));
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("does not fail when provider.get returns null", async () => {
|
||||
const local = mockProvider(null);
|
||||
|
||||
const value = {
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
alt: "My photo",
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({ local }));
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("does not fail when provider has no get method", async () => {
|
||||
const local: MediaProvider = {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
getEmbed: vi.fn().mockReturnValue({ type: "image", src: "/test" }),
|
||||
// no get method
|
||||
};
|
||||
|
||||
const value = {
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
alt: "My photo",
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({ local }));
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("returns external value with src as-is (no dimension detection)", async () => {
|
||||
const value = {
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: "https://example.com/photo.jpg",
|
||||
alt: "A photo",
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({}));
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("does not call provider for external values without dimensions", async () => {
|
||||
const value = {
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: "https://example.com/photo.jpg",
|
||||
alt: "A photo",
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({}));
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
|
||||
it("strips src from local media values", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue(
|
||||
{
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
src: "/_emdash/api/media/file/01ABC.jpg",
|
||||
alt: "My photo",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
},
|
||||
getProvider({ local }),
|
||||
);
|
||||
|
||||
// src should be removed for local media - it's derived at display time
|
||||
expect(result!.src).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults provider to local when not specified", async () => {
|
||||
const providerItem: MediaProviderItem = {
|
||||
id: "01ABC",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
width: 1200,
|
||||
height: 800,
|
||||
meta: { storageKey: "01ABC.jpg" },
|
||||
};
|
||||
const local = mockProvider(providerItem);
|
||||
|
||||
const result = await normalizeMediaValue({ id: "01ABC" }, getProvider({ local }));
|
||||
|
||||
expect(result!.provider).toBe("local");
|
||||
expect(local.get).toHaveBeenCalledWith("01ABC");
|
||||
});
|
||||
|
||||
it("handles provider.get throwing gracefully", async () => {
|
||||
const local: MediaProvider = {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
get: vi.fn().mockRejectedValue(new Error("DB error")),
|
||||
getEmbed: vi.fn().mockReturnValue({ type: "image", src: "/test" }),
|
||||
};
|
||||
|
||||
const value = {
|
||||
provider: "local",
|
||||
id: "01ABC",
|
||||
alt: "My photo",
|
||||
};
|
||||
|
||||
const result = await normalizeMediaValue(value, getProvider({ local }));
|
||||
expect(result).toEqual(value);
|
||||
});
|
||||
});
|
||||
81
packages/core/tests/unit/media/placeholder.test.ts
Normal file
81
packages/core/tests/unit/media/placeholder.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { generatePlaceholder } from "../../../src/media/placeholder.js";
|
||||
|
||||
const CSS_RGB_PATTERN = /^rgb\(\d+,\s?\d+,\s?\d+\)$/;
|
||||
|
||||
/** Minimal 4x4 solid red JPEG */
|
||||
const JPEG_4x4 = Buffer.from(
|
||||
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** Minimal 4x4 solid red PNG */
|
||||
const PNG_4x4 = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAEAQMAAACTPww9AAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURf8AAP///0EdNBEAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gIcETMVn1ZhnwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wMi0yOFQxNzo1MToyMCswMDowMJE6EiQAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDItMjhUMTc6NTE6MjArMDA6MDDgZ6qYAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTAyLTI4VDE3OjUxOjIwKzAwOjAwt3KLRwAAAAtJREFUCNdjYIAAAAAIAAEvIN0xAAAAAElFTkSuQmCC",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** 100x100 solid blue JPEG (for downsampling test) */
|
||||
const JPEG_100x100 = Buffer.from(
|
||||
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCABkAGQDAREAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAYJ/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8Anu1TQ4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//2Q==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
describe("generatePlaceholder", () => {
|
||||
it("generates blurhash and dominantColor from a JPEG", async () => {
|
||||
const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/jpeg");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.blurhash).toBeTruthy();
|
||||
expect(typeof result!.blurhash).toBe("string");
|
||||
expect(result!.dominantColor).toBeTruthy();
|
||||
expect(typeof result!.dominantColor).toBe("string");
|
||||
});
|
||||
|
||||
it("generates blurhash and dominantColor from a PNG", async () => {
|
||||
const result = await generatePlaceholder(new Uint8Array(PNG_4x4), "image/png");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.blurhash).toBeTruthy();
|
||||
expect(result!.dominantColor).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns a valid CSS color string for dominantColor", async () => {
|
||||
const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/jpeg");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Should be rgb() format from rgbColorToCssString
|
||||
expect(result!.dominantColor).toMatch(CSS_RGB_PATTERN);
|
||||
});
|
||||
|
||||
it("returns null for non-image MIME types", async () => {
|
||||
const buffer = new Uint8Array([0, 1, 2, 3]);
|
||||
const result = await generatePlaceholder(buffer, "application/pdf");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unsupported image types", async () => {
|
||||
const buffer = new Uint8Array([0, 1, 2, 3]);
|
||||
const result = await generatePlaceholder(buffer, "image/svg+xml");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for corrupt image data", async () => {
|
||||
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]);
|
||||
const result = await generatePlaceholder(buffer, "image/jpeg");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("handles larger images by downsampling", async () => {
|
||||
const result = await generatePlaceholder(new Uint8Array(JPEG_100x100), "image/jpeg");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.blurhash).toBeTruthy();
|
||||
// Blurhash string length should be reasonable (not huge from 100x100)
|
||||
expect(result!.blurhash.length).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
341
packages/core/tests/unit/menus/menus.test.ts
Normal file
341
packages/core/tests/unit/menus/menus.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createDatabase } from "../../../src/database/connection.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { getMenuWithDb, getMenusWithDb } from "../../../src/menus/index.js";
|
||||
|
||||
describe("Navigation Menus", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh in-memory database for each test
|
||||
db = createDatabase({ url: ":memory:" });
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
describe("migration", () => {
|
||||
it("should create _emdash_menus table", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const menusTable = tables.find((t) => t.name === "_emdash_menus");
|
||||
expect(menusTable).toBeDefined();
|
||||
|
||||
const columns = menusTable!.columns.map((c) => c.name);
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("name");
|
||||
expect(columns).toContain("label");
|
||||
expect(columns).toContain("created_at");
|
||||
expect(columns).toContain("updated_at");
|
||||
});
|
||||
|
||||
it("should create _emdash_menu_items table", async () => {
|
||||
const tables = await db.introspection.getTables();
|
||||
const itemsTable = tables.find((t) => t.name === "_emdash_menu_items");
|
||||
expect(itemsTable).toBeDefined();
|
||||
|
||||
const columns = itemsTable!.columns.map((c) => c.name);
|
||||
expect(columns).toContain("id");
|
||||
expect(columns).toContain("menu_id");
|
||||
expect(columns).toContain("parent_id");
|
||||
expect(columns).toContain("sort_order");
|
||||
expect(columns).toContain("type");
|
||||
expect(columns).toContain("reference_collection");
|
||||
expect(columns).toContain("reference_id");
|
||||
expect(columns).toContain("custom_url");
|
||||
expect(columns).toContain("label");
|
||||
expect(columns).toContain("target");
|
||||
expect(columns).toContain("css_classes");
|
||||
});
|
||||
|
||||
it("should enforce unique constraint on menu name", async () => {
|
||||
const id1 = ulid();
|
||||
const id2 = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: id1,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await expect(
|
||||
db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: id2,
|
||||
name: "primary",
|
||||
label: "Primary Again",
|
||||
})
|
||||
.execute(),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should cascade delete menu items when menu is deleted", async () => {
|
||||
const menuId = ulid();
|
||||
const itemId = ulid();
|
||||
|
||||
// Create menu
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: menuId,
|
||||
name: "test-menu",
|
||||
label: "Test Menu",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create menu item
|
||||
await db
|
||||
.insertInto("_emdash_menu_items")
|
||||
.values({
|
||||
id: itemId,
|
||||
menu_id: menuId,
|
||||
sort_order: 0,
|
||||
type: "custom",
|
||||
custom_url: "https://example.com",
|
||||
label: "Test Link",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Delete menu
|
||||
await db.deleteFrom("_emdash_menus").where("id", "=", menuId).execute();
|
||||
|
||||
// Verify item was deleted
|
||||
const items = await db
|
||||
.selectFrom("_emdash_menu_items")
|
||||
.where("menu_id", "=", menuId)
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMenus", () => {
|
||||
it("should return empty array when no menus exist", async () => {
|
||||
const menus = await getMenusWithDb(db);
|
||||
expect(menus).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all menus ordered by name", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values([
|
||||
{ id: ulid(), name: "footer", label: "Footer Links" },
|
||||
{ id: ulid(), name: "primary", label: "Primary Navigation" },
|
||||
{ id: ulid(), name: "social", label: "Social Links" },
|
||||
])
|
||||
.execute();
|
||||
|
||||
const menus = await getMenusWithDb(db);
|
||||
expect(menus).toHaveLength(3);
|
||||
expect(menus[0].name).toBe("footer");
|
||||
expect(menus[1].name).toBe("primary");
|
||||
expect(menus[2].name).toBe("social");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMenu", () => {
|
||||
it("should return null for non-existent menu", async () => {
|
||||
const menu = await getMenuWithDb("nonexistent", db);
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it("should return menu with empty items array", async () => {
|
||||
const menuId = ulid();
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: menuId,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const menu = await getMenuWithDb("primary", db);
|
||||
expect(menu).toMatchObject({
|
||||
id: menuId,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve custom URLs correctly", async () => {
|
||||
const menuId = ulid();
|
||||
const itemId = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: menuId,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menu_items")
|
||||
.values({
|
||||
id: itemId,
|
||||
menu_id: menuId,
|
||||
sort_order: 0,
|
||||
type: "custom",
|
||||
custom_url: "https://github.com",
|
||||
label: "GitHub",
|
||||
target: "_blank",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const menu = await getMenuWithDb("primary", db);
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu!.items).toHaveLength(1);
|
||||
expect(menu!.items[0]).toMatchObject({
|
||||
id: itemId,
|
||||
label: "GitHub",
|
||||
url: "https://github.com",
|
||||
target: "_blank",
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip items with deleted content references", async () => {
|
||||
const menuId = ulid();
|
||||
const itemId = ulid();
|
||||
|
||||
// Create menu with item referencing non-existent content
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: menuId,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menu_items")
|
||||
.values({
|
||||
id: itemId,
|
||||
menu_id: menuId,
|
||||
sort_order: 0,
|
||||
type: "page",
|
||||
reference_collection: "pages",
|
||||
reference_id: "nonexistent",
|
||||
label: "Deleted Page",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const menu = await getMenuWithDb("primary", db);
|
||||
expect(menu).not.toBeNull();
|
||||
// Item should be filtered out because the page doesn't exist
|
||||
expect(menu!.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should build nested tree structure", async () => {
|
||||
const menuId = ulid();
|
||||
const parentId = ulid();
|
||||
const childId = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: menuId,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create parent item
|
||||
await db
|
||||
.insertInto("_emdash_menu_items")
|
||||
.values({
|
||||
id: parentId,
|
||||
menu_id: menuId,
|
||||
sort_order: 0,
|
||||
type: "custom",
|
||||
custom_url: "/about",
|
||||
label: "About",
|
||||
})
|
||||
.execute();
|
||||
|
||||
// Create child item
|
||||
await db
|
||||
.insertInto("_emdash_menu_items")
|
||||
.values({
|
||||
id: childId,
|
||||
menu_id: menuId,
|
||||
parent_id: parentId,
|
||||
sort_order: 0,
|
||||
type: "custom",
|
||||
custom_url: "/about/team",
|
||||
label: "Team",
|
||||
})
|
||||
.execute();
|
||||
|
||||
const menu = await getMenuWithDb("primary", db);
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu!.items).toHaveLength(1);
|
||||
expect(menu!.items[0].label).toBe("About");
|
||||
expect(menu!.items[0].children).toHaveLength(1);
|
||||
expect(menu!.items[0].children[0].label).toBe("Team");
|
||||
});
|
||||
|
||||
it("should order items by sort_order", async () => {
|
||||
const menuId = ulid();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menus")
|
||||
.values({
|
||||
id: menuId,
|
||||
name: "primary",
|
||||
label: "Primary Navigation",
|
||||
})
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("_emdash_menu_items")
|
||||
.values([
|
||||
{
|
||||
id: ulid(),
|
||||
menu_id: menuId,
|
||||
sort_order: 2,
|
||||
type: "custom",
|
||||
custom_url: "/contact",
|
||||
label: "Contact",
|
||||
},
|
||||
{
|
||||
id: ulid(),
|
||||
menu_id: menuId,
|
||||
sort_order: 0,
|
||||
type: "custom",
|
||||
custom_url: "/home",
|
||||
label: "Home",
|
||||
},
|
||||
{
|
||||
id: ulid(),
|
||||
menu_id: menuId,
|
||||
sort_order: 1,
|
||||
type: "custom",
|
||||
custom_url: "/about",
|
||||
label: "About",
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const menu = await getMenuWithDb("primary", db);
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu!.items).toHaveLength(3);
|
||||
expect(menu!.items[0].label).toBe("Home");
|
||||
expect(menu!.items[1].label).toBe("About");
|
||||
expect(menu!.items[2].label).toBe("Contact");
|
||||
});
|
||||
});
|
||||
});
|
||||
419
packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts
Normal file
419
packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* adaptSandboxEntry() Tests
|
||||
*
|
||||
* Tests the in-process adapter that converts standard-format plugins
|
||||
* ({ hooks, routes }) into ResolvedPlugin instances compatible with HookPipeline.
|
||||
*
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js";
|
||||
import { adaptSandboxEntry } from "../../../src/plugins/adapt-sandbox-entry.js";
|
||||
import type { StandardPluginDefinition, StandardHookHandler } from "../../../src/plugins/types.js";
|
||||
|
||||
/** Create a properly typed mock hook handler */
|
||||
function mockHandler(): StandardHookHandler {
|
||||
return vi.fn(async () => {}) as unknown as StandardHookHandler;
|
||||
}
|
||||
|
||||
function createDescriptor(overrides?: Partial<PluginDescriptor>): PluginDescriptor {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@test/plugin",
|
||||
format: "standard",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("adaptSandboxEntry", () => {
|
||||
describe("basic adaptation", () => {
|
||||
it("produces a ResolvedPlugin with correct id and version", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {},
|
||||
routes: {},
|
||||
};
|
||||
const descriptor = createDescriptor({ id: "my-plugin", version: "2.1.0" });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.id).toBe("my-plugin");
|
||||
expect(result.version).toBe("2.1.0");
|
||||
});
|
||||
|
||||
it("adapts an empty definition", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks).toEqual({});
|
||||
expect(result.routes).toEqual({});
|
||||
expect(result.capabilities).toEqual([]);
|
||||
expect(result.allowedHosts).toEqual([]);
|
||||
expect(result.storage).toEqual({});
|
||||
});
|
||||
|
||||
it("carries capabilities from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["read:content", "network:fetch"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toEqual(["read:content", "network:fetch"]);
|
||||
});
|
||||
|
||||
it("carries allowedHosts from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
allowedHosts: ["api.example.com", "*.cdn.com"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.allowedHosts).toEqual(["api.example.com", "*.cdn.com"]);
|
||||
});
|
||||
|
||||
it("carries storage config from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
storage: {
|
||||
events: { indexes: ["timestamp", "type"] },
|
||||
logs: { indexes: ["level"] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.storage).toEqual({
|
||||
events: { indexes: ["timestamp", "type"] },
|
||||
logs: { indexes: ["level"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries admin pages from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
adminPages: [{ path: "/settings", label: "Settings", icon: "gear" }],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.admin.pages).toEqual([{ path: "/settings", label: "Settings", icon: "gear" }]);
|
||||
});
|
||||
|
||||
it("carries admin widgets from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.admin.widgets).toEqual([{ id: "status", title: "Status", size: "half" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook adaptation", () => {
|
||||
it("resolves a bare function hook with defaults", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:afterSave": handler,
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const hook = result.hooks["content:afterSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(100);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
expect(hook!.exclusive).toBe(false);
|
||||
expect(hook!.pluginId).toBe("test-plugin");
|
||||
});
|
||||
|
||||
it("resolves a config object hook with custom settings", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler,
|
||||
priority: 1,
|
||||
timeout: 10000,
|
||||
dependencies: ["other-plugin"],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const hook = result.hooks["content:beforeSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(1);
|
||||
expect(hook!.timeout).toBe(10000);
|
||||
expect(hook!.dependencies).toEqual(["other-plugin"]);
|
||||
expect(hook!.errorPolicy).toBe("continue");
|
||||
});
|
||||
|
||||
it("resolves multiple hooks", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:beforeSave": mockHandler(),
|
||||
"content:afterSave": { handler: mockHandler(), priority: 200 },
|
||||
"content:afterDelete": mockHandler(),
|
||||
"media:afterUpload": mockHandler(),
|
||||
"plugin:install": mockHandler(),
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks["content:beforeSave"]).toBeDefined();
|
||||
expect(result.hooks["content:afterSave"]).toBeDefined();
|
||||
expect(result.hooks["content:afterDelete"]).toBeDefined();
|
||||
expect(result.hooks["media:afterUpload"]).toBeDefined();
|
||||
expect(result.hooks["plugin:install"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets pluginId on all hooks from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:beforeSave": mockHandler(),
|
||||
"content:afterSave": { handler: mockHandler() },
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor({ id: "my-plugin" });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks["content:beforeSave"]!.pluginId).toBe("my-plugin");
|
||||
expect(result.hooks["content:afterSave"]!.pluginId).toBe("my-plugin");
|
||||
});
|
||||
|
||||
it("resolves exclusive hooks", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"email:deliver": {
|
||||
handler,
|
||||
exclusive: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks["email:deliver"]!.exclusive).toBe(true);
|
||||
});
|
||||
|
||||
it("throws on unknown hook names", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"unknown:hook": mockHandler(),
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
expect(() => adaptSandboxEntry(def, descriptor)).toThrow("unknown hook");
|
||||
});
|
||||
|
||||
it("applies default config for partial config objects", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler,
|
||||
priority: 200,
|
||||
// timeout, dependencies, errorPolicy, exclusive use defaults
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const hook = result.hooks["content:afterSave"];
|
||||
expect(hook!.priority).toBe(200);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
expect(hook!.exclusive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route adaptation", () => {
|
||||
it("wraps standard two-arg route handler into single-arg RouteContext handler", async () => {
|
||||
const standardHandler = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const def: StandardPluginDefinition = {
|
||||
routes: {
|
||||
status: {
|
||||
handler: standardHandler,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.routes.status).toBeDefined();
|
||||
|
||||
// Simulate calling the adapted handler with a RouteContext-like object
|
||||
const mockCtx = {
|
||||
input: { foo: "bar" },
|
||||
request: new Request("http://localhost/test"),
|
||||
requestMeta: { ip: null, userAgent: null, referer: null, geo: null },
|
||||
plugin: { id: "test-plugin", version: "1.0.0" },
|
||||
kv: {} as any,
|
||||
storage: {} as any,
|
||||
log: {} as any,
|
||||
site: { name: "", url: "", locale: "en" },
|
||||
url: (p: string) => p,
|
||||
};
|
||||
|
||||
await result.routes.status.handler(mockCtx as any);
|
||||
|
||||
// Verify the standard handler was called with (routeCtx, pluginCtx)
|
||||
expect(standardHandler).toHaveBeenCalledTimes(1);
|
||||
const [routeCtx, pluginCtx] = standardHandler.mock.calls[0];
|
||||
expect(routeCtx.input).toEqual({ foo: "bar" });
|
||||
expect(routeCtx.request).toBeDefined();
|
||||
expect(routeCtx.requestMeta).toBeDefined();
|
||||
// pluginCtx should be the stripped PluginContext (without route-specific fields)
|
||||
expect(pluginCtx.plugin.id).toBe("test-plugin");
|
||||
expect(pluginCtx.kv).toBeDefined();
|
||||
expect(pluginCtx.log).toBeDefined();
|
||||
// Route-specific fields should NOT leak into pluginCtx
|
||||
expect(pluginCtx).not.toHaveProperty("input");
|
||||
expect(pluginCtx).not.toHaveProperty("request");
|
||||
expect(pluginCtx).not.toHaveProperty("requestMeta");
|
||||
});
|
||||
|
||||
it("preserves public flag on routes", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
routes: {
|
||||
webhook: {
|
||||
handler: vi.fn(),
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.routes.webhook.public).toBe(true);
|
||||
});
|
||||
|
||||
it("adapts multiple routes", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
routes: {
|
||||
status: { handler: vi.fn() },
|
||||
sync: { handler: vi.fn() },
|
||||
"admin/settings": { handler: vi.fn() },
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(Object.keys(result.routes)).toEqual(["status", "sync", "admin/settings"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability normalization", () => {
|
||||
it("normalizes write:content to include read:content", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({ capabilities: ["write:content"] });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toContain("write:content");
|
||||
expect(result.capabilities).toContain("read:content");
|
||||
});
|
||||
|
||||
it("normalizes write:media to include read:media", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({ capabilities: ["write:media"] });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toContain("write:media");
|
||||
expect(result.capabilities).toContain("read:media");
|
||||
});
|
||||
|
||||
it("normalizes network:fetch:any to include network:fetch", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({ capabilities: ["network:fetch:any"] });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toContain("network:fetch:any");
|
||||
expect(result.capabilities).toContain("network:fetch");
|
||||
});
|
||||
|
||||
it("does not duplicate implied capabilities", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["read:content", "write:content"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const readCount = result.capabilities.filter((c) => c === "read:content").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
|
||||
it("throws on invalid capability", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["invalid:capability"],
|
||||
});
|
||||
|
||||
expect(() => adaptSandboxEntry(def, descriptor)).toThrow("Invalid capability");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration with HookPipeline", () => {
|
||||
it("produces hooks compatible with HookPipeline registration", () => {
|
||||
// HookPipeline stores hooks as ResolvedHook<unknown> internally.
|
||||
// The adapted hooks must have the expected shape.
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler,
|
||||
priority: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
// Verify the hook shape matches what HookPipeline expects
|
||||
const hook = result.hooks["content:afterSave"]!;
|
||||
expect(typeof hook.handler).toBe("function");
|
||||
expect(typeof hook.priority).toBe("number");
|
||||
expect(typeof hook.timeout).toBe("number");
|
||||
expect(Array.isArray(hook.dependencies)).toBe(true);
|
||||
expect(typeof hook.errorPolicy).toBe("string");
|
||||
expect(typeof hook.exclusive).toBe("boolean");
|
||||
expect(typeof hook.pluginId).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
435
packages/core/tests/unit/plugins/define-plugin.test.ts
Normal file
435
packages/core/tests/unit/plugins/define-plugin.test.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* definePlugin() Tests
|
||||
*
|
||||
* Tests the plugin definition helper for:
|
||||
* - ID validation (simple and scoped formats)
|
||||
* - Version validation (semver)
|
||||
* - Capability validation and normalization
|
||||
* - Hook resolution (function vs config object)
|
||||
* - Default value handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { definePlugin } from "../../../src/plugins/define-plugin.js";
|
||||
|
||||
// Error message patterns for test assertions
|
||||
const INVALID_PLUGIN_ID_PATTERN = /Invalid plugin id/;
|
||||
const INVALID_PLUGIN_VERSION_PATTERN = /Invalid plugin version/;
|
||||
const INVALID_CAPABILITY_PATTERN = /Invalid capability/;
|
||||
|
||||
describe("definePlugin", () => {
|
||||
describe("ID validation", () => {
|
||||
it("accepts valid simple ID", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("my-plugin");
|
||||
});
|
||||
|
||||
it("accepts valid simple ID with numbers", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "plugin-v2",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("plugin-v2");
|
||||
});
|
||||
|
||||
it("accepts valid scoped ID", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "@emdashcms/seo-plugin",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("@emdashcms/seo-plugin");
|
||||
});
|
||||
|
||||
it("accepts scoped ID with numbers", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "@my-org/plugin-v2",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("@my-org/plugin-v2");
|
||||
});
|
||||
|
||||
it("rejects ID with uppercase letters", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "MyPlugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects ID with underscores", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "my_plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects ID with spaces", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "my plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects empty ID", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects invalid scoped ID (missing name)", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "@my-org/",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects invalid scoped ID (missing scope)", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "@/my-plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version validation", () => {
|
||||
it("accepts valid semver", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("accepts semver with prerelease", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0-beta.1",
|
||||
});
|
||||
|
||||
expect(plugin.version).toBe("1.0.0-beta.1");
|
||||
});
|
||||
|
||||
it("accepts semver with build metadata", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0+build.123",
|
||||
});
|
||||
|
||||
expect(plugin.version).toBe("1.0.0+build.123");
|
||||
});
|
||||
|
||||
it("rejects invalid version format", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "test",
|
||||
version: "1.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_VERSION_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects non-numeric version", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "test",
|
||||
version: "latest",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_VERSION_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability validation", () => {
|
||||
it("accepts valid capabilities", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:content", "write:content", "network:fetch"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("read:content");
|
||||
expect(plugin.capabilities).toContain("write:content");
|
||||
expect(plugin.capabilities).toContain("network:fetch");
|
||||
});
|
||||
|
||||
it("accepts read:media and write:media", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:media", "write:media"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("read:media");
|
||||
expect(plugin.capabilities).toContain("write:media");
|
||||
});
|
||||
|
||||
it("rejects invalid capability", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["invalid:capability" as any],
|
||||
}),
|
||||
).toThrow(INVALID_CAPABILITY_PATTERN);
|
||||
});
|
||||
|
||||
it("normalizes write:content to include read:content", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["write:content"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("write:content");
|
||||
expect(plugin.capabilities).toContain("read:content");
|
||||
});
|
||||
|
||||
it("normalizes write:media to include read:media", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["write:media"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("write:media");
|
||||
expect(plugin.capabilities).toContain("read:media");
|
||||
});
|
||||
|
||||
it("does not duplicate read when already present", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["read:content", "write:content"],
|
||||
});
|
||||
|
||||
const readCount = plugin.capabilities.filter((c) => c === "read:content").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to empty capabilities", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook resolution", () => {
|
||||
it("resolves function shorthand to full config", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": handler,
|
||||
},
|
||||
});
|
||||
|
||||
const hook = plugin.hooks["content:beforeSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(100);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
expect(hook!.pluginId).toBe("test");
|
||||
});
|
||||
|
||||
it("resolves full config object", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler,
|
||||
priority: 50,
|
||||
timeout: 10000,
|
||||
dependencies: ["other-plugin"],
|
||||
errorPolicy: "continue",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hook = plugin.hooks["content:beforeSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(50);
|
||||
expect(hook!.timeout).toBe(10000);
|
||||
expect(hook!.dependencies).toEqual(["other-plugin"]);
|
||||
expect(hook!.errorPolicy).toBe("continue");
|
||||
});
|
||||
|
||||
it("applies defaults to partial config", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler,
|
||||
priority: 200,
|
||||
// timeout, dependencies, errorPolicy use defaults
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hook = plugin.hooks["content:afterSave"];
|
||||
expect(hook!.priority).toBe(200);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
});
|
||||
|
||||
it("resolves multiple hooks", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": vi.fn(),
|
||||
"content:afterSave": vi.fn(),
|
||||
"plugin:install": vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.hooks["content:beforeSave"]).toBeDefined();
|
||||
expect(plugin.hooks["content:afterSave"]).toBeDefined();
|
||||
expect(plugin.hooks["plugin:install"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets pluginId on all resolved hooks", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": vi.fn(),
|
||||
"media:afterUpload": { handler: vi.fn(), priority: 50 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.hooks["content:beforeSave"]!.pluginId).toBe("my-plugin");
|
||||
expect(plugin.hooks["media:afterUpload"]!.pluginId).toBe("my-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default values", () => {
|
||||
it("defaults allowedHosts to empty array", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.allowedHosts).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults storage to empty object", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.storage).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults hooks to empty object", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.hooks).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults routes to empty object", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.routes).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves provided allowedHosts", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
allowedHosts: ["api.example.com", "*.cdn.com"],
|
||||
});
|
||||
|
||||
expect(plugin.allowedHosts).toEqual(["api.example.com", "*.cdn.com"]);
|
||||
});
|
||||
|
||||
it("preserves provided storage config", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
storage: {
|
||||
items: { indexes: ["type", "status"] },
|
||||
cache: { indexes: ["key"] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.storage).toEqual({
|
||||
items: { indexes: ["type", "status"] },
|
||||
cache: { indexes: ["key"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes passthrough", () => {
|
||||
it("preserves route definitions", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
routes: {
|
||||
sync: { handler },
|
||||
webhook: { handler, input: {} as any },
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.routes.sync).toBeDefined();
|
||||
expect(plugin.routes.sync.handler).toBe(handler);
|
||||
expect(plugin.routes.webhook).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin passthrough", () => {
|
||||
it("preserves admin config", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
admin: {
|
||||
entry: "@test/plugin/admin",
|
||||
pages: [{ id: "settings", title: "Settings" }],
|
||||
widgets: [{ id: "stats", title: "Stats", area: "dashboard" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.admin.entry).toBe("@test/plugin/admin");
|
||||
expect(plugin.admin.pages).toHaveLength(1);
|
||||
expect(plugin.admin.widgets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
1312
packages/core/tests/unit/plugins/email-pipeline.test.ts
Normal file
1312
packages/core/tests/unit/plugins/email-pipeline.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
761
packages/core/tests/unit/plugins/exclusive-hooks.test.ts
Normal file
761
packages/core/tests/unit/plugins/exclusive-hooks.test.ts
Normal file
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* Exclusive Hooks Tests
|
||||
*
|
||||
* Tests the exclusive hook system:
|
||||
* - HookPipeline: registration/tracking, selection, invokeExclusiveHook
|
||||
* - PluginManager.resolveExclusiveHooks(): single provider auto-select,
|
||||
* multi-provider no auto-select, stale selection clearing, preferred hints,
|
||||
* admin override beats preferred
|
||||
* - Lifecycle: activate → auto-select, deactivate → clears stale selection
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { extractManifest } from "../../../src/cli/commands/bundle-utils.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import { HookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js";
|
||||
import { PluginManager } from "../../../src/plugins/manager.js";
|
||||
import { normalizeManifestHook } from "../../../src/plugins/manifest-schema.js";
|
||||
import type {
|
||||
ResolvedPlugin,
|
||||
ResolvedHook,
|
||||
PluginDefinition,
|
||||
ContentBeforeSaveHandler,
|
||||
ContentAfterSaveHandler,
|
||||
} from "../../../src/plugins/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — ResolvedPlugin (for HookPipeline tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["write:content", "read:content"],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: {
|
||||
pages: [],
|
||||
widgets: [],
|
||||
},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestHook<T>(
|
||||
pluginId: string,
|
||||
handler: T,
|
||||
overrides: Partial<ResolvedHook<T>> = {},
|
||||
): ResolvedHook<T> {
|
||||
return {
|
||||
pluginId,
|
||||
handler,
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — PluginDefinition (for PluginManager tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestDefinition(overrides: Partial<PluginDefinition> = {}): PluginDefinition {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["write:content", "read:content"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HookPipeline — exclusive behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HookPipeline — exclusive hooks", () => {
|
||||
it("tracks exclusive hook names during registration", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "email-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("email-provider", vi.fn(), {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.isExclusiveHook("content:beforeSave")).toBe(true);
|
||||
expect(pipeline.isExclusiveHook("content:afterSave")).toBe(false);
|
||||
expect(pipeline.getRegisteredExclusiveHooks()).toContain("content:beforeSave");
|
||||
});
|
||||
|
||||
it("does not track non-exclusive hooks as exclusive", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "normal-plugin",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("normal-plugin", vi.fn(), {
|
||||
exclusive: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.isExclusiveHook("content:beforeSave")).toBe(false);
|
||||
expect(pipeline.getRegisteredExclusiveHooks()).not.toContain("content:beforeSave");
|
||||
});
|
||||
|
||||
it("returns providers for an exclusive hook", () => {
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "provider-b",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
|
||||
const providers = pipeline.getExclusiveHookProviders("content:beforeSave");
|
||||
expect(providers).toHaveLength(2);
|
||||
expect(providers.map((p) => p.pluginId)).toEqual(
|
||||
expect.arrayContaining(["provider-a", "provider-b"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("set/get/clear exclusive selection", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "email-ses",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("email-ses", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
||||
|
||||
pipeline.setExclusiveSelection("content:beforeSave", "email-ses");
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("email-ses");
|
||||
|
||||
pipeline.clearExclusiveSelection("content:beforeSave");
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook returns null when no selection", async () => {
|
||||
const handler = vi.fn().mockResolvedValue("result");
|
||||
const plugin = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-a", handler, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("content:beforeSave", { some: "event" });
|
||||
expect(result).toBeNull();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook dispatches only to selected provider", async () => {
|
||||
const handlerA = vi.fn().mockResolvedValue("result-a");
|
||||
const handlerB = vi.fn().mockResolvedValue("result-b");
|
||||
|
||||
const pluginA = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("provider-a", handlerA, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "provider-b",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("provider-b", handlerB, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
// Context factory needs a db for PluginContextFactory
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([pluginA, pluginB], { db });
|
||||
|
||||
pipeline.setExclusiveSelection("content:afterSave", "provider-b");
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("content:afterSave", { some: "event" });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pluginId).toBe("provider-b");
|
||||
expect(result!.result).toBe("result-b");
|
||||
|
||||
expect(handlerB).toHaveBeenCalledTimes(1);
|
||||
expect(handlerA).not.toHaveBeenCalled();
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook isolates errors — returns error result instead of throwing", async () => {
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("provider crashed")) as unknown as ContentAfterSaveHandler;
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "broken-provider",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("broken-provider", handler, {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
pipeline.setExclusiveSelection("content:afterSave", "broken-provider");
|
||||
|
||||
// Should NOT throw — error is isolated
|
||||
const result = await pipeline.invokeExclusiveHook("content:afterSave", {});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pluginId).toBe("broken-provider");
|
||||
expect(result!.error).toBeInstanceOf(Error);
|
||||
expect(result!.error!.message).toBe("provider crashed");
|
||||
expect(result!.result).toBeUndefined();
|
||||
expect(result!.duration).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook respects timeout", async () => {
|
||||
const handler = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 10_000);
|
||||
}),
|
||||
) as unknown as ContentAfterSaveHandler;
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "slow-provider",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("slow-provider", handler, {
|
||||
exclusive: true,
|
||||
timeout: 50,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
pipeline.setExclusiveSelection("content:afterSave", "slow-provider");
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("content:afterSave", {});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.error).toBeInstanceOf(Error);
|
||||
expect(result!.error!.message.toLowerCase()).toContain("timeout");
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("exclusive hooks with a selection are skipped in regular pipeline", async () => {
|
||||
const exclusiveHandler = vi.fn().mockResolvedValue(undefined);
|
||||
const normalHandler = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const exclusivePlugin = createTestPlugin({
|
||||
id: "exclusive-plugin",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("exclusive-plugin", exclusiveHandler, {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const normalPlugin = createTestPlugin({
|
||||
id: "normal-plugin",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("normal-plugin", normalHandler, {
|
||||
exclusive: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([exclusivePlugin, normalPlugin], { db });
|
||||
|
||||
// Set a selection — this means the exclusive hook should NOT run in the regular pipeline
|
||||
pipeline.setExclusiveSelection("content:afterSave", "exclusive-plugin");
|
||||
|
||||
await pipeline.runContentAfterSave({ title: "test" }, "posts", true);
|
||||
|
||||
// Normal hook should run
|
||||
expect(normalHandler).toHaveBeenCalledTimes(1);
|
||||
// Exclusive hook should NOT have run in the regular pipeline
|
||||
expect(exclusiveHandler).not.toHaveBeenCalled();
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("exclusive hooks without a selection DO run in regular pipeline", async () => {
|
||||
const exclusiveHandler = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "unselected-provider",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("unselected-provider", exclusiveHandler, {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
|
||||
// No selection set — exclusive hooks should still run in regular pipeline
|
||||
await pipeline.runContentAfterSave({ title: "test" }, "posts", true);
|
||||
|
||||
expect(exclusiveHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeManifestHook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("normalizeManifestHook", () => {
|
||||
it("converts a plain string to an object", () => {
|
||||
const result = normalizeManifestHook("content:beforeSave");
|
||||
expect(result).toEqual({ name: "content:beforeSave" });
|
||||
});
|
||||
|
||||
it("passes through an object unchanged", () => {
|
||||
const entry = { name: "content:beforeSave", exclusive: true, priority: 50 };
|
||||
const result = normalizeManifestHook(entry);
|
||||
expect(result).toEqual(entry);
|
||||
});
|
||||
|
||||
it("handles object with only name", () => {
|
||||
const result = normalizeManifestHook({ name: "media:afterUpload" });
|
||||
expect(result).toEqual({ name: "media:afterUpload" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractManifest — exclusive hook metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("extractManifest — exclusive hooks", () => {
|
||||
it("emits plain hook names for non-exclusive hooks with default settings", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "simple-plugin",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("simple-plugin", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toEqual(["content:beforeSave"]);
|
||||
});
|
||||
|
||||
it("emits structured entries for exclusive hooks", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "email-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("email-provider", vi.fn(), {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toEqual([{ name: "content:beforeSave", exclusive: true }]);
|
||||
});
|
||||
|
||||
it("emits structured entries for hooks with custom priority or timeout", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "custom-plugin",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("custom-plugin", vi.fn(), {
|
||||
priority: 50,
|
||||
timeout: 10000,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toEqual([{ name: "content:afterSave", priority: 50, timeout: 10000 }]);
|
||||
});
|
||||
|
||||
it("handles mixed exclusive and non-exclusive hooks", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "mixed-plugin",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("mixed-plugin", vi.fn(), { exclusive: true }),
|
||||
"content:afterSave": createTestHook("mixed-plugin", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toHaveLength(2);
|
||||
|
||||
// One should be structured (exclusive), one should be a plain string
|
||||
const structured = manifest.hooks.filter((h) => typeof h === "object");
|
||||
const plain = manifest.hooks.filter((h) => typeof h === "string");
|
||||
expect(structured).toHaveLength(1);
|
||||
expect(plain).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveExclusiveHooks (shared function)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveExclusiveHooks — shared function", () => {
|
||||
it("auto-selects single active provider", async () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "only-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("only-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: () => true,
|
||||
getOption: async (key) => store.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
store.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("only-provider");
|
||||
});
|
||||
|
||||
it("filters out inactive providers", async () => {
|
||||
const pluginA = createTestPlugin({
|
||||
id: "active-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("active-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "inactive-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("inactive-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pipeline = new HookPipeline([pluginA, pluginB]);
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: (id) => id === "active-provider",
|
||||
getOption: async (key) => store.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
store.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
// Only active-provider is active, so it should be auto-selected
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("active-provider");
|
||||
});
|
||||
|
||||
it("clears stale selection when selected provider is inactive", async () => {
|
||||
const pluginA = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "provider-b",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pipeline = new HookPipeline([pluginA, pluginB]);
|
||||
|
||||
// Simulate existing selection for provider-a which is now inactive
|
||||
const store = new Map<string, string>([
|
||||
["emdash:exclusive_hook:content:beforeSave", "provider-a"],
|
||||
]);
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: (id) => id === "provider-b", // provider-a is inactive
|
||||
getOption: async (key) => store.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
store.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
// provider-a was stale, cleared. provider-b is the only active one → auto-selected
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("provider-b");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginManager — resolveExclusiveHooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PluginManager — resolveExclusiveHooks", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqliteDb = new Database(":memory:");
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqliteDb }),
|
||||
});
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
it("auto-selects when only one provider for an exclusive hook", async () => {
|
||||
const handler = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "email-ses",
|
||||
hooks: {
|
||||
"content:beforeSave": { handler, exclusive: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await manager.activate("email-ses");
|
||||
|
||||
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
||||
expect(selection).toBe("email-ses");
|
||||
});
|
||||
|
||||
it("keeps auto-selected provider when a second provider activates", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
// provider-a is the only one — gets auto-selected
|
||||
await manager.activate("provider-a");
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
|
||||
// provider-b activates — existing valid selection is preserved
|
||||
await manager.activate("provider-b");
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
});
|
||||
|
||||
it("leaves unselected when multiple providers activate simultaneously", async () => {
|
||||
// If no one was auto-selected before the second provider, there's no
|
||||
// selection to keep. Test this by registering both before activating.
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
// Activate provider-a (auto-selects as sole provider)
|
||||
await manager.activate("provider-a");
|
||||
// Clear the auto-selection to simulate "no prior selection"
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", null);
|
||||
|
||||
// Now activate provider-b — both active, no existing selection
|
||||
await manager.activate("provider-b");
|
||||
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
||||
expect(selection).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale selection when selected plugin is deactivated", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.activate("provider-a");
|
||||
await manager.activate("provider-b");
|
||||
|
||||
// Manually set a selection
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", "provider-a");
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
|
||||
// Deactivate the selected plugin
|
||||
await manager.deactivate("provider-a");
|
||||
|
||||
// After deactivation, provider-b is the only one left → auto-selects
|
||||
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
||||
expect(selection).toBe("provider-b");
|
||||
});
|
||||
|
||||
it("uses preferred hints when no selection exists", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.activate("provider-a");
|
||||
await manager.activate("provider-b");
|
||||
|
||||
// Clear any auto-selection from the first activate
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", null);
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBeNull();
|
||||
|
||||
// Resolve with preferred hint
|
||||
const hints = new Map([["provider-b", ["content:beforeSave"]]]);
|
||||
await manager.resolveExclusiveHooks(hints);
|
||||
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-b");
|
||||
});
|
||||
|
||||
it("admin override (DB selection) beats preferred hints", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.activate("provider-a");
|
||||
await manager.activate("provider-b");
|
||||
|
||||
// Admin explicitly sets provider-a
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", "provider-a");
|
||||
|
||||
// Resolve with preferred hint for provider-b — admin choice should win
|
||||
const hints = new Map([["provider-b", ["content:beforeSave"]]]);
|
||||
await manager.resolveExclusiveHooks(hints);
|
||||
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
});
|
||||
|
||||
it("getExclusiveHooksInfo returns complete info", async () => {
|
||||
const handler = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
await manager.activate("provider-a");
|
||||
|
||||
const info = await manager.getExclusiveHooksInfo();
|
||||
expect(info).toHaveLength(1);
|
||||
expect(info[0]!.hookName).toBe("content:beforeSave");
|
||||
expect(info[0]!.providers).toHaveLength(1);
|
||||
expect(info[0]!.providers[0]!.pluginId).toBe("provider-a");
|
||||
expect(info[0]!.selectedPluginId).toBe("provider-a");
|
||||
});
|
||||
});
|
||||
187
packages/core/tests/unit/plugins/field-widgets.test.ts
Normal file
187
packages/core/tests/unit/plugins/field-widgets.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for the field widget plugin pipeline.
|
||||
*
|
||||
* Covers:
|
||||
* - Manifest schema validation for fieldWidgets
|
||||
* - definePlugin() with fieldWidgets
|
||||
* - FieldWidgetConfig type correctness
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pluginManifestSchema } from "../../../src/plugins/manifest-schema.js";
|
||||
|
||||
/** Minimal valid manifest */
|
||||
function makeManifest(admin: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pluginManifestSchema — fieldWidgets", () => {
|
||||
it("should accept manifest without fieldWidgets", () => {
|
||||
const result = pluginManifestSchema.safeParse(makeManifest());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept manifest with empty fieldWidgets array", () => {
|
||||
const result = pluginManifestSchema.safeParse(makeManifest({ fieldWidgets: [] }));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept a valid field widget definition", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "picker",
|
||||
label: "Color Picker",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept multiple field widget definitions", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "picker",
|
||||
label: "Color Picker",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
{
|
||||
name: "pricing",
|
||||
label: "Pricing Editor",
|
||||
fieldTypes: ["json"],
|
||||
elements: [{ type: "toggle", action_id: "enabled", label: "Enable" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept field widget with Block Kit elements", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "pricing",
|
||||
label: "Pricing",
|
||||
fieldTypes: ["json"],
|
||||
elements: [
|
||||
{ type: "toggle", action_id: "enabled", label: "Enable" },
|
||||
{ type: "text_input", action_id: "price", label: "Price" },
|
||||
{
|
||||
type: "select",
|
||||
action_id: "mode",
|
||||
label: "Mode",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept field widget with multiple field types", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "hex",
|
||||
label: "Hex Input",
|
||||
fieldTypes: ["string", "json"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject field widget with empty name", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "",
|
||||
label: "Test",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject field widget with empty label", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "test",
|
||||
label: "",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject field widget without name", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
label: "Test",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject field widget without fieldTypes", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "test",
|
||||
label: "Test",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept field widget with empty fieldTypes array", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "test",
|
||||
label: "Test",
|
||||
fieldTypes: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
663
packages/core/tests/unit/plugins/hooks.test.ts
Normal file
663
packages/core/tests/unit/plugins/hooks.test.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* HookPipeline Tests
|
||||
*
|
||||
* Tests the v2 hook pipeline for:
|
||||
* - Hook registration and sorting
|
||||
* - Hook execution with timeout
|
||||
* - Content hooks (beforeSave, afterSave, beforeDelete, afterDelete)
|
||||
* - Lifecycle hooks (install, activate, deactivate, uninstall)
|
||||
* - Error handling and error policies
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { HookPipeline, createHookPipeline } from "../../../src/plugins/hooks.js";
|
||||
import type { ResolvedPlugin, ResolvedHook } from "../../../src/plugins/types.js";
|
||||
|
||||
/**
|
||||
* Create a minimal resolved plugin for testing
|
||||
*/
|
||||
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: {
|
||||
pages: [],
|
||||
widgets: [],
|
||||
},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resolved hook with defaults
|
||||
*/
|
||||
function createTestHook<T>(
|
||||
pluginId: string,
|
||||
handler: T,
|
||||
overrides: Partial<ResolvedHook<T>> = {},
|
||||
): ResolvedHook<T> {
|
||||
return {
|
||||
pluginId,
|
||||
handler,
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HookPipeline", () => {
|
||||
describe("construction and registration", () => {
|
||||
it("creates empty pipeline with no plugins", () => {
|
||||
const pipeline = new HookPipeline([]);
|
||||
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(false);
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(0);
|
||||
});
|
||||
|
||||
it("registers hooks from plugins", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["write:content", "read:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("test", vi.fn()),
|
||||
"content:afterSave": createTestHook("test", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks registered hook names", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["write:content", "read:media"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("test", vi.fn()),
|
||||
"media:afterUpload": createTestHook("test", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
const registered = pipeline.getRegisteredHooks();
|
||||
|
||||
expect(registered).toContain("content:beforeSave");
|
||||
expect(registered).toContain("media:afterUpload");
|
||||
expect(registered).not.toContain("content:afterSave");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook sorting", () => {
|
||||
it("sorts hooks by priority (lower first)", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
const handler3 = vi.fn();
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-1", handler1, {
|
||||
priority: 200,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-2", handler2, {
|
||||
priority: 50,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin3 = createTestPlugin({
|
||||
id: "plugin-3",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-3", handler3, {
|
||||
priority: 100,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Create pipeline and manually verify order through execution
|
||||
const pipeline = new HookPipeline([plugin1, plugin2, plugin3]);
|
||||
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(3);
|
||||
});
|
||||
|
||||
it("respects dependencies when sorting", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-1", handler1, {
|
||||
priority: 50, // Lower priority but...
|
||||
dependencies: ["plugin-2"], // depends on plugin-2
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-2", handler2, {
|
||||
priority: 100, // Higher priority
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
|
||||
// plugin-2 should run before plugin-1 despite priority
|
||||
// because plugin-1 depends on plugin-2
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content:beforeSave", () => {
|
||||
it("runs hooks and returns modified content", async () => {
|
||||
const handler = vi.fn(async (event) => ({
|
||||
...event.content,
|
||||
modified: true,
|
||||
}));
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
// Need context factory for actual execution
|
||||
// Without it, getContext will throw
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
// For unit test without DB, we can verify the hook count
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("chains content through multiple hooks", async () => {
|
||||
const handler1 = vi.fn(async (event) => ({
|
||||
...event.content,
|
||||
step1: true,
|
||||
}));
|
||||
|
||||
const handler2 = vi.fn(async (event) => ({
|
||||
...event.content,
|
||||
step2: true,
|
||||
}));
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-1", handler1, {
|
||||
priority: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-2", handler2, {
|
||||
priority: 2,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content:beforeDelete", () => {
|
||||
it("registers beforeDelete hooks", () => {
|
||||
const handler = vi.fn(async () => true);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["read:content"],
|
||||
hooks: {
|
||||
"content:beforeDelete": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycle hooks", () => {
|
||||
it("registers plugin:install hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:install": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:install")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers plugin:activate hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:activate": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:activate")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers plugin:deactivate hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:deactivate": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:deactivate")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers plugin:uninstall hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:uninstall": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:uninstall")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media hooks", () => {
|
||||
it("registers media:beforeUpload hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["write:media"],
|
||||
hooks: {
|
||||
"media:beforeUpload": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:beforeUpload")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers media:afterUpload hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["read:media"],
|
||||
hooks: {
|
||||
"media:afterUpload": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:afterUpload")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHookPipeline helper", () => {
|
||||
it("creates a HookPipeline instance", () => {
|
||||
const plugins = [createTestPlugin({ id: "test" })];
|
||||
const pipeline = createHookPipeline(plugins);
|
||||
|
||||
expect(pipeline).toBeInstanceOf(HookPipeline);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Capability enforcement for non-email hooks
|
||||
// =========================================================================
|
||||
|
||||
describe("capability enforcement — content hooks", () => {
|
||||
it("skips content:beforeSave without write:content capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips content:beforeSave with only read:content (requires write:content)", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "read-only",
|
||||
capabilities: ["read:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("read-only", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers content:beforeSave with write:content capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["write:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips content:afterSave without read:content capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers content:afterSave with read:content capability (read-only notification)", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["read:content"],
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips content:beforeDelete without read:content capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:beforeDelete": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips content:afterDelete without read:content capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:afterDelete": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterDelete")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers all content hooks with write:content + read:content", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "writer",
|
||||
capabilities: ["write:content", "read:content"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("writer", vi.fn()),
|
||||
"content:afterSave": createTestHook("writer", vi.fn()),
|
||||
"content:beforeDelete": createTestHook("writer", vi.fn()),
|
||||
"content:afterDelete": createTestHook("writer", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterDelete")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — media hooks", () => {
|
||||
it("skips media:beforeUpload without write:media capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"media:beforeUpload": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:beforeUpload")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers media:beforeUpload with write:media capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["write:media"],
|
||||
hooks: {
|
||||
"media:beforeUpload": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:beforeUpload")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips media:afterUpload without read:media capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"media:afterUpload": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:afterUpload")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers media:afterUpload with read:media capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["read:media"],
|
||||
hooks: {
|
||||
"media:afterUpload": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:afterUpload")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — comment hooks", () => {
|
||||
it("skips comment:beforeCreate without read:users capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:beforeCreate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:beforeCreate")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers comment:beforeCreate with read:users capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["read:users"],
|
||||
hooks: {
|
||||
"comment:beforeCreate": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:beforeCreate")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips comment:moderate without read:users capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:moderate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:moderate")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips comment:afterCreate without read:users capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:afterCreate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:afterCreate")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips comment:afterModerate without read:users capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:afterModerate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:afterModerate")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — page:fragments", () => {
|
||||
it("skips page:fragments without page:inject capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("page:fragments")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers page:fragments with page:inject capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["page:inject"],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("page:fragments")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — hooks without requirements", () => {
|
||||
it("registers lifecycle hooks without any capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"plugin:install": createTestHook("no-cap", vi.fn()),
|
||||
"plugin:activate": createTestHook("no-cap", vi.fn()),
|
||||
"plugin:deactivate": createTestHook("no-cap", vi.fn()),
|
||||
"plugin:uninstall": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:install")).toBe(true);
|
||||
expect(pipeline.hasHooks("plugin:activate")).toBe(true);
|
||||
expect(pipeline.hasHooks("plugin:deactivate")).toBe(true);
|
||||
expect(pipeline.hasHooks("plugin:uninstall")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers cron hook without any capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
cron: createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("cron")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers page:metadata without any capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("page:metadata")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Tests that plugin HTTP functions strip credential headers on cross-origin redirects.
|
||||
*
|
||||
* Both createHttpAccess and createUnrestrictedHttpAccess manually follow redirects.
|
||||
* When a redirect crosses origins, Authorization/Cookie/Proxy-Authorization headers
|
||||
* must be stripped to prevent credential leakage to untrusted hosts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
import { createHttpAccess, createUnrestrictedHttpAccess } from "../../../src/plugins/context.js";
|
||||
|
||||
// Intercept globalThis.fetch so we can simulate redirect chains
|
||||
const mockFetch = vi.fn<typeof globalThis.fetch>();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
/** Build a minimal redirect response */
|
||||
function redirectResponse(location: string, status = 302): Response {
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: { Location: location },
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a 200 response */
|
||||
function okResponse(body = "ok"): Response {
|
||||
return new Response(body, { status: 200 });
|
||||
}
|
||||
|
||||
/** Extract the headers passed to the Nth fetch call */
|
||||
function headersOfCall(callIndex: number): Headers {
|
||||
const init = mockFetch.mock.calls[callIndex]?.[1] as RequestInit | undefined;
|
||||
return new Headers(init?.headers);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// createHttpAccess – host-restricted
|
||||
// =============================================================================
|
||||
|
||||
describe("createHttpAccess credential stripping", () => {
|
||||
const pluginId = "test-plugin";
|
||||
const allowedHosts = ["a.example.com", "b.example.com"];
|
||||
|
||||
it("preserves credentials on same-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://a.example.com/page2"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, allowedHosts);
|
||||
await http.fetch("https://a.example.com/page1", {
|
||||
headers: { Authorization: "Bearer secret", Cookie: "session=abc" },
|
||||
});
|
||||
|
||||
// Second call should still have credentials (same origin)
|
||||
const h = headersOfCall(1);
|
||||
expect(h.get("authorization")).toBe("Bearer secret");
|
||||
expect(h.get("cookie")).toBe("session=abc");
|
||||
});
|
||||
|
||||
it("strips credentials on cross-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://b.example.com/landing"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, allowedHosts);
|
||||
await http.fetch("https://a.example.com/start", {
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
Cookie: "session=abc",
|
||||
"Proxy-Authorization": "Basic creds",
|
||||
"X-Custom": "keep-me",
|
||||
},
|
||||
});
|
||||
|
||||
const h = headersOfCall(1);
|
||||
expect(h.get("authorization")).toBeNull();
|
||||
expect(h.get("cookie")).toBeNull();
|
||||
expect(h.get("proxy-authorization")).toBeNull();
|
||||
// Non-credential headers survive
|
||||
expect(h.get("x-custom")).toBe("keep-me");
|
||||
});
|
||||
|
||||
it("strips credentials only once even with multiple same-origin hops after cross-origin", async () => {
|
||||
// a.example.com -> b.example.com -> b.example.com/final
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://b.example.com/step1"))
|
||||
.mockResolvedValueOnce(redirectResponse("https://b.example.com/step2"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, allowedHosts);
|
||||
await http.fetch("https://a.example.com/start", {
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
});
|
||||
|
||||
// Call 0: original (has auth)
|
||||
expect(headersOfCall(0).get("authorization")).toBe("Bearer secret");
|
||||
// Call 1: after cross-origin hop (stripped)
|
||||
expect(headersOfCall(1).get("authorization")).toBeNull();
|
||||
// Call 2: same-origin hop on b (still stripped -- not re-added)
|
||||
expect(headersOfCall(2).get("authorization")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createUnrestrictedHttpAccess – SSRF-protected but no host list
|
||||
// =============================================================================
|
||||
|
||||
describe("createUnrestrictedHttpAccess credential stripping", () => {
|
||||
const pluginId = "unrestricted-plugin";
|
||||
|
||||
it("preserves credentials on same-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://api.example.com/v2"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createUnrestrictedHttpAccess(pluginId);
|
||||
await http.fetch("https://api.example.com/v1", {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
expect(headersOfCall(1).get("authorization")).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("strips credentials on cross-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://evil.example.com/steal"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createUnrestrictedHttpAccess(pluginId);
|
||||
await http.fetch("https://api.example.com/start", {
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
Cookie: "session=xyz",
|
||||
"Proxy-Authorization": "Basic pw",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const h = headersOfCall(1);
|
||||
expect(h.get("authorization")).toBeNull();
|
||||
expect(h.get("cookie")).toBeNull();
|
||||
expect(h.get("proxy-authorization")).toBeNull();
|
||||
expect(h.get("accept")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("handles redirect with no init gracefully", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://other.example.com/"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createUnrestrictedHttpAccess(pluginId);
|
||||
// No init at all -- should not throw
|
||||
await http.fetch("https://api.example.com/bare");
|
||||
|
||||
expect(headersOfCall(1).get("authorization")).toBeNull();
|
||||
});
|
||||
});
|
||||
426
packages/core/tests/unit/plugins/manager.test.ts
Normal file
426
packages/core/tests/unit/plugins/manager.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* PluginManager Tests
|
||||
*
|
||||
* Tests the central plugin orchestrator for:
|
||||
* - Plugin registration
|
||||
* - Lifecycle management (install, activate, deactivate, uninstall)
|
||||
* - Query methods
|
||||
* - Hook and route delegation
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import { PluginManager, createPluginManager } from "../../../src/plugins/manager.js";
|
||||
import type { PluginDefinition } from "../../../src/plugins/types.js";
|
||||
|
||||
// Test error message regex patterns
|
||||
const ALREADY_REGISTERED_REGEX = /already registered/;
|
||||
const DEACTIVATE_FIRST_REGEX = /Deactivate it first/;
|
||||
const NOT_FOUND_REGEX = /not found/;
|
||||
const ALREADY_INSTALLED_REGEX = /already installed/;
|
||||
|
||||
/**
|
||||
* Create a minimal plugin definition for testing
|
||||
*/
|
||||
function createTestDefinition(overrides: Partial<PluginDefinition> = {}): PluginDefinition {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginManager", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
let manager: PluginManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqliteDb = new Database(":memory:");
|
||||
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({
|
||||
database: sqliteDb,
|
||||
}),
|
||||
});
|
||||
|
||||
// Run migrations
|
||||
await runMigrations(db);
|
||||
|
||||
manager = new PluginManager({ db });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("registers a plugin definition", () => {
|
||||
const resolved = manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
expect(resolved.id).toBe("my-plugin");
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the resolved plugin", () => {
|
||||
const resolved = manager.register(
|
||||
createTestDefinition({
|
||||
id: "test",
|
||||
capabilities: ["write:content"],
|
||||
}),
|
||||
);
|
||||
|
||||
// write:content should add read:content
|
||||
expect(resolved.capabilities).toContain("write:content");
|
||||
expect(resolved.capabilities).toContain("read:content");
|
||||
});
|
||||
|
||||
it("throws on duplicate registration", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
expect(() => manager.register(createTestDefinition({ id: "my-plugin" }))).toThrow(
|
||||
ALREADY_REGISTERED_REGEX,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets initial state to registered", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("registered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerAll", () => {
|
||||
it("registers multiple plugins", () => {
|
||||
manager.registerAll([
|
||||
createTestDefinition({ id: "plugin-a" }),
|
||||
createTestDefinition({ id: "plugin-b" }),
|
||||
createTestDefinition({ id: "plugin-c" }),
|
||||
]);
|
||||
|
||||
expect(manager.hasPlugin("plugin-a")).toBe(true);
|
||||
expect(manager.hasPlugin("plugin-b")).toBe(true);
|
||||
expect(manager.hasPlugin("plugin-c")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregister", () => {
|
||||
it("returns false for non-existent plugin", () => {
|
||||
const result = manager.unregister("non-existent");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("unregisters a registered plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
const result = manager.unregister("my-plugin");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when trying to unregister active plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(() => manager.unregister("my-plugin")).toThrow(DEACTIVATE_FIRST_REGEX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.install("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("installs a registered plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
await manager.install("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("installed");
|
||||
});
|
||||
|
||||
it("throws if plugin is already installed", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.install("my-plugin");
|
||||
|
||||
await expect(manager.install("my-plugin")).rejects.toThrow(ALREADY_INSTALLED_REGEX);
|
||||
});
|
||||
|
||||
it("runs plugin:install hook", async () => {
|
||||
const installHandler = vi.fn();
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "my-plugin",
|
||||
hooks: {
|
||||
"plugin:install": installHandler,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.install("my-plugin");
|
||||
|
||||
// Hook should be registered but not called without context factory
|
||||
// In real usage, the hook would be called
|
||||
expect(manager.getPluginState("my-plugin")).toBe("installed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activate", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.activate("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("auto-installs if not installed", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("active");
|
||||
});
|
||||
|
||||
it("activates an installed plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.install("my-plugin");
|
||||
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns empty array if already active", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
const results = await manager.activate("my-plugin");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivate", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.deactivate("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("returns empty array if not active", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
const results = await manager.deactivate("my-plugin");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("deactivates an active plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
await manager.deactivate("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("inactive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstall", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.uninstall("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("deactivates before uninstalling if active", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
await manager.uninstall("my-plugin");
|
||||
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes plugin from manager", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.install("my-plugin");
|
||||
|
||||
await manager.uninstall("my-plugin");
|
||||
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlugin", () => {
|
||||
it("returns undefined for non-existent plugin", () => {
|
||||
expect(manager.getPlugin("non-existent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the resolved plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin", version: "2.0.0" }));
|
||||
|
||||
const plugin = manager.getPlugin("my-plugin");
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin!.id).toBe("my-plugin");
|
||||
expect(plugin!.version).toBe("2.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginState", () => {
|
||||
it("returns undefined for non-existent plugin", () => {
|
||||
expect(manager.getPluginState("non-existent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns current state", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
expect(manager.getPluginState("my-plugin")).toBe("registered");
|
||||
|
||||
await manager.install("my-plugin");
|
||||
expect(manager.getPluginState("my-plugin")).toBe("installed");
|
||||
|
||||
await manager.activate("my-plugin");
|
||||
expect(manager.getPluginState("my-plugin")).toBe("active");
|
||||
|
||||
await manager.deactivate("my-plugin");
|
||||
expect(manager.getPluginState("my-plugin")).toBe("inactive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllPlugins", () => {
|
||||
it("returns empty array initially", () => {
|
||||
expect(manager.getAllPlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all plugins with state", async () => {
|
||||
manager.register(createTestDefinition({ id: "plugin-a" }));
|
||||
manager.register(createTestDefinition({ id: "plugin-b" }));
|
||||
await manager.activate("plugin-b");
|
||||
|
||||
const all = manager.getAllPlugins();
|
||||
|
||||
expect(all).toHaveLength(2);
|
||||
|
||||
const pluginA = all.find((p) => p.plugin.id === "plugin-a");
|
||||
const pluginB = all.find((p) => p.plugin.id === "plugin-b");
|
||||
|
||||
expect(pluginA!.state).toBe("registered");
|
||||
expect(pluginB!.state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivePlugins", () => {
|
||||
it("returns empty array when no active plugins", () => {
|
||||
manager.register(createTestDefinition({ id: "plugin-a" }));
|
||||
|
||||
expect(manager.getActivePlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only active plugins", async () => {
|
||||
manager.register(createTestDefinition({ id: "plugin-a" }));
|
||||
manager.register(createTestDefinition({ id: "plugin-b" }));
|
||||
manager.register(createTestDefinition({ id: "plugin-c" }));
|
||||
|
||||
await manager.activate("plugin-a");
|
||||
await manager.activate("plugin-c");
|
||||
|
||||
const active = manager.getActivePlugins();
|
||||
|
||||
expect(active).toHaveLength(2);
|
||||
expect(active.map((p) => p.id).toSorted()).toEqual(["plugin-a", "plugin-c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPlugin", () => {
|
||||
it("returns false for non-existent plugin", () => {
|
||||
expect(manager.hasPlugin("non-existent")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for registered plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActive", () => {
|
||||
it("returns false for non-existent plugin", () => {
|
||||
expect(manager.isActive("non-existent")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for registered but not active plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
expect(manager.isActive("my-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for active plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(manager.isActive("my-plugin")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after deactivation", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
await manager.deactivate("my-plugin");
|
||||
|
||||
expect(manager.isActive("my-plugin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginRoutes", () => {
|
||||
it("returns routes for active plugin", async () => {
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "my-plugin",
|
||||
routes: {
|
||||
sync: { handler: vi.fn() },
|
||||
import: { handler: vi.fn() },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
const routes = manager.getPluginRoutes("my-plugin");
|
||||
|
||||
expect(routes).toContain("sync");
|
||||
expect(routes).toContain("import");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reinitialize", () => {
|
||||
it("can be called to force reinitialization", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
// Should not throw
|
||||
manager.reinitialize();
|
||||
|
||||
expect(manager.isActive("my-plugin")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPluginManager helper", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqliteDb = new Database(":memory:");
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqliteDb }),
|
||||
});
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
it("creates a PluginManager instance", () => {
|
||||
const manager = createPluginManager({ db });
|
||||
expect(manager).toBeInstanceOf(PluginManager);
|
||||
});
|
||||
});
|
||||
197
packages/core/tests/unit/plugins/manifest-schema.test.ts
Normal file
197
packages/core/tests/unit/plugins/manifest-schema.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
pluginManifestSchema,
|
||||
normalizeManifestRoute,
|
||||
} from "../../../src/plugins/manifest-schema.js";
|
||||
|
||||
/** Minimal valid manifest for testing — only storage fields vary */
|
||||
function makeManifest(storage: Record<string, { indexes: Array<string | string[]> }>) {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage,
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pluginManifestSchema — route entries", () => {
|
||||
it("should accept plain string routes", () => {
|
||||
const result = pluginManifestSchema.safeParse(makeManifest({}));
|
||||
// Baseline with empty routes is valid
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const withRoutes = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["webhook", "callback"],
|
||||
});
|
||||
expect(withRoutes.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept structured route objects", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "webhook", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept a mix of strings and objects", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["callback", { name: "webhook", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject route objects with empty name", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject route objects with missing name", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ public: true }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept route objects without public (defaults to private)", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "internal" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept route names with slashes and hyphens", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["auth/callback", "web-hook", { name: "api/v2/data", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject route names with path traversal", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["../../admin/settings"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject route names starting with special characters", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["/leading-slash"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject route object names with path traversal", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "../escape", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeManifestRoute", () => {
|
||||
it("should convert a plain string to { name } object", () => {
|
||||
expect(normalizeManifestRoute("webhook")).toEqual({ name: "webhook" });
|
||||
});
|
||||
|
||||
it("should pass through a structured object unchanged", () => {
|
||||
expect(normalizeManifestRoute({ name: "webhook", public: true })).toEqual({
|
||||
name: "webhook",
|
||||
public: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through an object without public", () => {
|
||||
expect(normalizeManifestRoute({ name: "internal" })).toEqual({ name: "internal" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginManifestSchema — storage index field names", () => {
|
||||
it("should accept valid simple index field names", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["status", "createdAt", "count"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid composite index field names", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: [["status", "createdAt"]] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject index field names containing SQL injection payloads", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["'); DROP TABLE users--"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject index field names with dots (JSON path traversal)", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["nested.field"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject index field names with hyphens", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["my-field"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject index field names starting with a number", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["1field"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty index field names", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: [""] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject malicious field names in composite indexes", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: [["status", "'); DROP TABLE--"]] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user