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:
719
packages/core/tests/unit/schema/registry.test.ts
Normal file
719
packages/core/tests/unit/schema/registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
557
packages/core/tests/unit/schema/zod-generator.test.ts
Normal file
557
packages/core/tests/unit/schema/zod-generator.test.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import type { CollectionWithFields, Field } from "../../../src/schema/types.js";
|
||||
import {
|
||||
generateZodSchema,
|
||||
generateFieldSchema,
|
||||
validateContent,
|
||||
generateTypeScript,
|
||||
clearSchemaCache,
|
||||
} from "../../../src/schema/zod-generator.js";
|
||||
|
||||
describe("Zod Generator", () => {
|
||||
beforeEach(() => {
|
||||
clearSchemaCache();
|
||||
});
|
||||
|
||||
describe("generateFieldSchema", () => {
|
||||
it("should generate string schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse("Hello")).toBe("Hello");
|
||||
expect(() => schema.parse(123)).toThrow();
|
||||
});
|
||||
|
||||
it("should generate number schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "price",
|
||||
label: "Price",
|
||||
type: "number",
|
||||
columnType: "REAL",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(99.99)).toBe(99.99);
|
||||
expect(() => schema.parse("not a number")).toThrow();
|
||||
});
|
||||
|
||||
it("should generate integer schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "count",
|
||||
label: "Count",
|
||||
type: "integer",
|
||||
columnType: "INTEGER",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(42)).toBe(42);
|
||||
expect(() => schema.parse(3.14)).toThrow();
|
||||
});
|
||||
|
||||
it("should generate boolean schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "active",
|
||||
label: "Active",
|
||||
type: "boolean",
|
||||
columnType: "INTEGER",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(true)).toBe(true);
|
||||
expect(schema.parse(false)).toBe(false);
|
||||
expect(() => schema.parse("yes")).toThrow();
|
||||
});
|
||||
|
||||
it("should coerce stored 0/1 booleans to real booleans", () => {
|
||||
// Boolean fields map to `INTEGER` columns (`FIELD_TYPE_TO_COLUMN`
|
||||
// in `schema/types.ts`) and `serializeValue` in
|
||||
// `database/repositories/content.ts` writes booleans as 0/1.
|
||||
// `deserializeValue` never converts them back, so a GET → POST
|
||||
// round-trip on a boolean field fails validation (`z.boolean()`
|
||||
// rejects numbers) unless this schema accepts the integer shape.
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "active",
|
||||
label: "Active",
|
||||
type: "boolean",
|
||||
columnType: "INTEGER",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(0)).toBe(false);
|
||||
expect(schema.parse(1)).toBe(true);
|
||||
// Other numbers must still fail — only the integer 0/1 shape is accepted.
|
||||
expect(() => schema.parse(2)).toThrow();
|
||||
expect(() => schema.parse(-1)).toThrow();
|
||||
// Strings still fail.
|
||||
expect(() => schema.parse("0")).toThrow();
|
||||
expect(() => schema.parse("true")).toThrow();
|
||||
// BigInt from drivers that return 64-bit ints is unsupported (no
|
||||
// known driver currently does this for boolean columns); rejecting
|
||||
// is safer than a silent coercion that could hide a real bug.
|
||||
expect(() => schema.parse(BigInt(0))).toThrow();
|
||||
});
|
||||
|
||||
it("should preserve `.default(false)` chaining through the boolean preprocess", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "active",
|
||||
label: "Active",
|
||||
type: "boolean",
|
||||
columnType: "INTEGER",
|
||||
required: false,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
defaultValue: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
// default applies when the value is undefined.
|
||||
expect(schema.parse(undefined)).toBe(false);
|
||||
// Stored integer shape still coerces.
|
||||
expect(schema.parse(1)).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept stored 0/1 booleans in partial-mode validation", () => {
|
||||
// `validateContentData` in `api/handlers/validation.ts` calls
|
||||
// `schema.partial()` for updates. Confirm that partial mode keeps
|
||||
// the preprocess intact for the boolean field.
|
||||
const collection: CollectionWithFields = {
|
||||
id: "c1",
|
||||
slug: "posts",
|
||||
labelPlural: "Posts",
|
||||
labelSingular: "Post",
|
||||
updatedAt: new Date().toISOString(),
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "active",
|
||||
label: "Active",
|
||||
type: "boolean",
|
||||
columnType: "INTEGER",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const schema = generateZodSchema(collection).partial();
|
||||
expect(schema.parse({ active: 0 })).toEqual({ active: false });
|
||||
expect(schema.parse({ active: 1 })).toEqual({ active: true });
|
||||
expect(schema.parse({})).toEqual({});
|
||||
});
|
||||
|
||||
it("should generate url schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "website",
|
||||
label: "Website",
|
||||
type: "url",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse("https://example.com")).toBe("https://example.com");
|
||||
expect(schema.parse("http://localhost:3000/path")).toBe("http://localhost:3000/path");
|
||||
expect(() => schema.parse("not-a-url")).toThrow();
|
||||
expect(() => schema.parse(123)).toThrow();
|
||||
});
|
||||
|
||||
it("should generate select schema with options", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { options: ["draft", "published", "archived"] },
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse("draft")).toBe("draft");
|
||||
expect(() => schema.parse("invalid")).toThrow();
|
||||
});
|
||||
|
||||
it("should generate multiSelect schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "tags",
|
||||
label: "Tags",
|
||||
type: "multiSelect",
|
||||
columnType: "JSON",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { options: ["news", "featured", "popular"] },
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(["news", "featured"])).toEqual(["news", "featured"]);
|
||||
expect(() => schema.parse(["invalid"])).toThrow();
|
||||
});
|
||||
|
||||
it("should generate portableText schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
columnType: "JSON",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
const validContent = [{ _type: "block", _key: "abc", style: "normal" }];
|
||||
expect(schema.parse(validContent)).toEqual(validContent);
|
||||
});
|
||||
|
||||
it("should generate image schema", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "image",
|
||||
label: "Image",
|
||||
type: "image",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
const validImage = { id: "img123", alt: "A photo" };
|
||||
expect(schema.parse(validImage)).toMatchObject(validImage);
|
||||
});
|
||||
|
||||
it("should make field optional when required is false", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "subtitle",
|
||||
label: "Subtitle",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: false,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(undefined)).toBe(undefined);
|
||||
expect(schema.parse("Hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should apply default value", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "status",
|
||||
label: "Status",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: false,
|
||||
unique: false,
|
||||
defaultValue: "draft",
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(schema.parse(undefined)).toBe("draft");
|
||||
});
|
||||
|
||||
it("should apply string validation rules", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { minLength: 3, maxLength: 100 },
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(() => schema.parse("ab")).toThrow();
|
||||
expect(schema.parse("abc")).toBe("abc");
|
||||
});
|
||||
|
||||
it("should apply number validation rules", () => {
|
||||
const field: Field = {
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "price",
|
||||
label: "Price",
|
||||
type: "number",
|
||||
columnType: "REAL",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { min: 0, max: 1000 },
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const schema = generateFieldSchema(field);
|
||||
expect(() => schema.parse(-1)).toThrow();
|
||||
expect(() => schema.parse(1001)).toThrow();
|
||||
expect(schema.parse(500)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateZodSchema", () => {
|
||||
it("should generate schema for collection with multiple fields", () => {
|
||||
const collection: CollectionWithFields = {
|
||||
id: "c1",
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
supports: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
collectionId: "c1",
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
columnType: "JSON",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "f3",
|
||||
collectionId: "c1",
|
||||
slug: "views",
|
||||
label: "Views",
|
||||
type: "integer",
|
||||
columnType: "INTEGER",
|
||||
required: false,
|
||||
unique: false,
|
||||
defaultValue: 0,
|
||||
sortOrder: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const schema = generateZodSchema(collection);
|
||||
|
||||
const validData = {
|
||||
title: "Hello World",
|
||||
content: [{ _type: "block", _key: "abc" }],
|
||||
};
|
||||
|
||||
const result = schema.parse(validData);
|
||||
expect(result.title).toBe("Hello World");
|
||||
expect(result.views).toBe(0); // default applied
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateContent", () => {
|
||||
const collection: CollectionWithFields = {
|
||||
id: "c1",
|
||||
slug: "products",
|
||||
label: "Products",
|
||||
supports: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "name",
|
||||
label: "Name",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { minLength: 1 },
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
collectionId: "c1",
|
||||
slug: "price",
|
||||
label: "Price",
|
||||
type: "number",
|
||||
columnType: "REAL",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { min: 0 },
|
||||
sortOrder: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("should return success for valid data", () => {
|
||||
const result = validateContent(collection, {
|
||||
name: "Widget",
|
||||
price: 29.99,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should return errors for invalid data", () => {
|
||||
const result = validateContent(collection, {
|
||||
name: "",
|
||||
price: -10,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.errors.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTypeScript", () => {
|
||||
it("should generate TypeScript interface", () => {
|
||||
const collection: CollectionWithFields = {
|
||||
id: "c1",
|
||||
slug: "blog_posts",
|
||||
label: "Blog Posts",
|
||||
supports: ["drafts"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
collectionId: "c1",
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
collectionId: "c1",
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
columnType: "JSON",
|
||||
required: true,
|
||||
unique: false,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "f3",
|
||||
collectionId: "c1",
|
||||
slug: "featured",
|
||||
label: "Featured",
|
||||
type: "boolean",
|
||||
columnType: "INTEGER",
|
||||
required: false,
|
||||
unique: false,
|
||||
sortOrder: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "f4",
|
||||
collectionId: "c1",
|
||||
slug: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
columnType: "TEXT",
|
||||
required: true,
|
||||
unique: false,
|
||||
validation: { options: ["draft", "published"] },
|
||||
sortOrder: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const ts = generateTypeScript(collection);
|
||||
|
||||
expect(ts).toContain("export interface BlogPost");
|
||||
expect(ts).toContain("title: string;");
|
||||
expect(ts).toContain("content: PortableTextBlock[];");
|
||||
expect(ts).toContain("featured?: boolean;");
|
||||
expect(ts).toContain('status: "draft" | "published";');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user