Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,719 @@
import Database from "better-sqlite3";
import { Kysely, SqliteDialect, sql } from "kysely";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { runMigrations } from "../../../src/database/migrations/runner.js";
import type { Database as EmDashDatabase } from "../../../src/database/types.js";
import { SchemaRegistry, SchemaError } from "../../../src/schema/registry.js";
import { FTSManager } from "../../../src/search/fts-manager.js";
describe("SchemaRegistry", () => {
let db: Kysely<EmDashDatabase>;
let registry: SchemaRegistry;
beforeEach(async () => {
// Create in-memory database
const sqlite = new Database(":memory:");
db = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: sqlite }),
});
// Run migrations
await runMigrations(db);
// Create registry
registry = new SchemaRegistry(db);
});
afterEach(async () => {
await db.destroy();
});
describe("Collection Operations", () => {
it("should create a collection", async () => {
const collection = await registry.createCollection({
slug: "posts",
label: "Blog Posts",
labelSingular: "Post",
supports: ["drafts", "revisions"],
});
expect(collection.slug).toBe("posts");
expect(collection.label).toBe("Blog Posts");
expect(collection.labelSingular).toBe("Post");
expect(collection.supports).toEqual(["drafts", "revisions"]);
expect(collection.source).toBe("manual");
expect(collection.id).toBeDefined();
});
it("F14: defaults supports to ['drafts', 'revisions'] when undefined", async () => {
const collection = await registry.createCollection({
slug: "default_supports",
label: "Default Supports",
// supports omitted entirely
});
expect(collection.supports.toSorted()).toEqual(["drafts", "revisions"].toSorted());
});
it("F14: preserves explicit empty supports array (opt-out)", async () => {
const collection = await registry.createCollection({
slug: "no_supports",
label: "No Supports",
supports: [],
});
expect(collection.supports).toEqual([]);
});
it("should create the content table when creating a collection", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
});
// Verify table exists by inserting a row
const result = await db
.insertInto("ec_articles" as any)
.values({
id: "test-id",
slug: "test-slug",
status: "draft",
})
.execute();
expect(result).toBeDefined();
});
it("should list collections", async () => {
await registry.createCollection({ slug: "posts", label: "Posts" });
await registry.createCollection({ slug: "pages", label: "Pages" });
const collections = await registry.listCollections();
expect(collections).toHaveLength(2);
expect(collections.map((c) => c.slug)).toEqual(["pages", "posts"]); // sorted
});
it("should get a collection by slug", async () => {
await registry.createCollection({
slug: "products",
label: "Products",
description: "Store products",
});
const collection = await registry.getCollection("products");
expect(collection).not.toBeNull();
expect(collection?.slug).toBe("products");
expect(collection?.description).toBe("Store products");
});
it("should return null for non-existent collection", async () => {
const collection = await registry.getCollection("nonexistent");
expect(collection).toBeNull();
});
it("should update a collection", async () => {
await registry.createCollection({ slug: "posts", label: "Posts" });
const updated = await registry.updateCollection("posts", {
label: "Blog Posts",
description: "All blog posts",
supports: ["drafts"],
});
expect(updated.label).toBe("Blog Posts");
expect(updated.description).toBe("All blog posts");
expect(updated.supports).toEqual(["drafts"]);
});
it("should throw when updating non-existent collection", async () => {
await expect(registry.updateCollection("nonexistent", { label: "Test" })).rejects.toThrow(
SchemaError,
);
});
it("should delete a collection", async () => {
await registry.createCollection({ slug: "temp", label: "Temp" });
await registry.deleteCollection("temp");
const collection = await registry.getCollection("temp");
expect(collection).toBeNull();
});
it("should throw when creating duplicate collection", async () => {
await registry.createCollection({ slug: "posts", label: "Posts" });
await expect(registry.createCollection({ slug: "posts", label: "Posts 2" })).rejects.toThrow(
SchemaError,
);
});
it("should reject reserved collection slugs", async () => {
await expect(
registry.createCollection({ slug: "content", label: "Content" }),
).rejects.toThrow(SchemaError);
await expect(registry.createCollection({ slug: "users", label: "Users" })).rejects.toThrow(
SchemaError,
);
});
it("should validate collection slug format", async () => {
await expect(registry.createCollection({ slug: "My Posts", label: "Posts" })).rejects.toThrow(
SchemaError,
);
await expect(registry.createCollection({ slug: "123posts", label: "Posts" })).rejects.toThrow(
SchemaError,
);
await expect(
registry.createCollection({ slug: "posts-here", label: "Posts" }),
).rejects.toThrow(SchemaError);
});
});
describe("Field Operations", () => {
beforeEach(async () => {
await registry.createCollection({ slug: "posts", label: "Posts" });
});
it("should create a field", async () => {
const field = await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
required: true,
});
expect(field.slug).toBe("title");
expect(field.label).toBe("Title");
expect(field.type).toBe("string");
expect(field.columnType).toBe("TEXT");
expect(field.required).toBe(true);
});
it("should add column to content table when creating field", async () => {
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
// Verify column exists by inserting a row with the field
await db
.insertInto("ec_posts" as any)
.values({
id: "test-id",
title: "Test Title",
})
.execute();
const row = await db
.selectFrom("ec_posts" as any)
.selectAll()
.executeTakeFirst();
expect((row as any).title).toBe("Test Title");
});
it("should list fields for a collection", async () => {
const collection = await registry.getCollection("posts");
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
await registry.createField("posts", {
slug: "content",
label: "Content",
type: "portableText",
});
const fields = await registry.listFields(collection!.id);
expect(fields).toHaveLength(2);
expect(fields[0].slug).toBe("title");
expect(fields[1].slug).toBe("content");
});
it("should get a field by slug", async () => {
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
validation: { minLength: 1, maxLength: 100 },
});
const field = await registry.getField("posts", "title");
expect(field).not.toBeNull();
expect(field?.validation).toEqual({ minLength: 1, maxLength: 100 });
});
it("should update a field", async () => {
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
const updated = await registry.updateField("posts", "title", {
label: "Post Title",
required: true,
widget: "text",
});
expect(updated.label).toBe("Post Title");
expect(updated.required).toBe(true);
expect(updated.widget).toBe("text");
});
it("should delete a field", async () => {
await registry.createField("posts", {
slug: "temp_field",
label: "Temp",
type: "string",
});
await registry.deleteField("posts", "temp_field");
const field = await registry.getField("posts", "temp_field");
expect(field).toBeNull();
});
it("should reject reserved field slugs", async () => {
await expect(
registry.createField("posts", {
slug: "id",
label: "ID",
type: "string",
}),
).rejects.toThrow(SchemaError);
await expect(
registry.createField("posts", {
slug: "created_at",
label: "Created",
type: "datetime",
}),
).rejects.toThrow(SchemaError);
});
it("should map field types to correct column types", async () => {
const testCases: Array<{ type: any; slug: string; expected: string }> = [
{ type: "string", slug: "f_string", expected: "TEXT" },
{ type: "text", slug: "f_text", expected: "TEXT" },
{ type: "number", slug: "f_number", expected: "REAL" },
{ type: "integer", slug: "f_integer", expected: "INTEGER" },
{ type: "boolean", slug: "f_boolean", expected: "INTEGER" },
{ type: "datetime", slug: "f_datetime", expected: "TEXT" },
{ type: "portableText", slug: "f_portable", expected: "JSON" },
{ type: "json", slug: "f_json", expected: "JSON" },
{ type: "image", slug: "f_image", expected: "TEXT" },
{ type: "reference", slug: "f_reference", expected: "TEXT" },
];
for (const { type, slug, expected } of testCases) {
const field = await registry.createField("posts", {
slug,
label: type,
type,
});
expect(field.columnType).toBe(expected);
}
});
it("should reorder fields", async () => {
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
await registry.createField("posts", {
slug: "content",
label: "Content",
type: "portableText",
});
await registry.createField("posts", {
slug: "author",
label: "Author",
type: "reference",
});
await registry.reorderFields("posts", ["author", "title", "content"]);
const collection = await registry.getCollection("posts");
const fields = await registry.listFields(collection!.id);
expect(fields[0].slug).toBe("author");
expect(fields[1].slug).toBe("title");
expect(fields[2].slug).toBe("content");
});
});
describe("Collection with Fields", () => {
it("should get collection with all fields", async () => {
await registry.createCollection({ slug: "posts", label: "Posts" });
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
await registry.createField("posts", {
slug: "content",
label: "Content",
type: "portableText",
});
const collection = await registry.getCollectionWithFields("posts");
expect(collection).not.toBeNull();
expect(collection?.slug).toBe("posts");
expect(collection?.fields).toHaveLength(2);
expect(collection?.fields[0].slug).toBe("title");
expect(collection?.fields[1].slug).toBe("content");
});
it("should cascade delete fields when deleting collection", async () => {
await registry.createCollection({ slug: "temp", label: "Temp" });
await registry.createField("temp", {
slug: "field1",
label: "Field 1",
type: "string",
});
await registry.deleteCollection("temp");
// Fields should be gone (cascade delete)
const field = await registry.getField("temp", "field1");
expect(field).toBeNull();
});
});
describe("Search (FTS) Integration", () => {
let ftsManager: FTSManager;
beforeEach(() => {
ftsManager = new FTSManager(db);
});
it("does not auto-enable FTS when adding a searchable field", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
});
it("does not auto-enable FTS when adding search support to a collection", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["drafts"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
await registry.updateCollection("articles", { supports: ["drafts", "search"] });
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
});
it("disables FTS when search support is removed from a collection", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await ftsManager.enableSearch("articles");
expect(await ftsManager.ftsTableExists("articles")).toBe(true);
await registry.updateCollection("articles", { supports: ["drafts"] });
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
});
it("rebuilds FTS table to include a new searchable field when collection already has search enabled", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await ftsManager.enableSearch("articles");
expect(await ftsManager.ftsTableExists("articles")).toBe(true);
await registry.createField("articles", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
});
await expect(
sql`SELECT body FROM "_emdash_fts_articles" LIMIT 0`.execute(db),
).resolves.toBeDefined();
});
it("deletes a searchable field from a search-enabled collection without error", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await registry.createField("articles", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
});
await ftsManager.enableSearch("articles");
await expect(registry.deleteField("articles", "body")).resolves.toBeUndefined();
expect(await ftsManager.ftsTableExists("articles")).toBe(true);
await expect(
sql`SELECT title FROM "_emdash_fts_articles" LIMIT 0`.execute(db),
).resolves.toBeDefined();
});
it("drops FTS table when deleting a search-enabled collection", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await ftsManager.enableSearch("articles");
expect(await ftsManager.ftsTableExists("articles")).toBe(true);
await registry.deleteCollection("articles");
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
});
it("disables FTS when the last searchable field is deleted", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await ftsManager.enableSearch("articles");
expect(await ftsManager.ftsTableExists("articles")).toBe(true);
await registry.deleteField("articles", "title");
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
});
it("does not create FTS table when collection supports search but has no searchable fields", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: false,
});
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
});
it("preserves weights in config when search support is toggled off", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await ftsManager.enableSearch("articles", { weights: { title: 10 } });
const initialConfig = await ftsManager.getSearchConfig("articles");
expect(initialConfig?.weights).toEqual({ title: 10 });
await registry.updateCollection("articles", { supports: ["drafts"] });
expect(await ftsManager.ftsTableExists("articles")).toBe(false);
const finalConfig = await ftsManager.getSearchConfig("articles");
expect(finalConfig?.weights).toEqual({ title: 10 });
});
});
describe("atomicity: rollback on FTS sync failure", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("rolls back updateCollection when FTS disable fails", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
const ftsManager = new FTSManager(db);
await ftsManager.enableSearch("articles");
vi.spyOn(FTSManager.prototype, "disableSearch").mockRejectedValueOnce(
new Error("FTS sync sabotaged"),
);
await expect(
registry.updateCollection("articles", { supports: ["drafts"] }),
).rejects.toThrow();
const collection = await registry.getCollection("articles");
expect(collection?.supports).toContain("search");
});
it("rolls back updateField when FTS rebuild fails", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
const ftsManager = new FTSManager(db);
await ftsManager.enableSearch("articles");
vi.spyOn(FTSManager.prototype, "disableSearch").mockRejectedValueOnce(
new Error("FTS sync sabotaged"),
);
await expect(
registry.updateField("articles", "title", { searchable: false }),
).rejects.toThrow();
const field = await registry.getField("articles", "title");
expect(field?.searchable).toBe(true);
});
it("rolls back deleteField when FTS rebuild fails", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
await registry.createField("articles", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
});
const ftsManager = new FTSManager(db);
await ftsManager.enableSearch("articles");
vi.spyOn(FTSManager.prototype, "rebuildIndex").mockRejectedValueOnce(
new Error("FTS sync sabotaged"),
);
await expect(registry.deleteField("articles", "body")).rejects.toThrow();
const field = await registry.getField("articles", "body");
expect(field).not.toBeNull();
});
it("rolls back createField when FTS rebuild fails", async () => {
await registry.createCollection({
slug: "articles",
label: "Articles",
supports: ["search"],
});
await registry.createField("articles", {
slug: "title",
label: "Title",
type: "string",
searchable: true,
});
const ftsManager = new FTSManager(db);
await ftsManager.enableSearch("articles");
vi.spyOn(FTSManager.prototype, "rebuildIndex").mockRejectedValueOnce(
new Error("FTS sync sabotaged"),
);
await expect(
registry.createField("articles", {
slug: "body",
label: "Body",
type: "text",
searchable: true,
}),
).rejects.toThrow();
const field = await registry.getField("articles", "body");
expect(field).toBeNull();
});
});
});