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 { getWidgetComponents } from "../../../src/widgets/components.js"; import type { WidgetType } from "../../../src/widgets/types.js"; // Regex patterns for widget validation const WIDGET_ID_FORMAT_REGEX = /^[a-z]+:[a-z-]+$/; describe("Widget System", () => { let db: Kysely; beforeEach(async () => { db = createDatabase({ url: ":memory:" }); await runMigrations(db); }); afterEach(async () => { await db.destroy(); }); describe("migration", () => { it("should create _emdash_widget_areas table", async () => { const tables = await db.introspection.getTables(); const areasTable = tables.find((t) => t.name === "_emdash_widget_areas"); expect(areasTable).toBeDefined(); const columns = areasTable!.columns.map((c) => c.name); expect(columns).toContain("id"); expect(columns).toContain("name"); expect(columns).toContain("label"); expect(columns).toContain("description"); }); it("should create _emdash_widgets table", async () => { const tables = await db.introspection.getTables(); const widgetsTable = tables.find((t) => t.name === "_emdash_widgets"); expect(widgetsTable).toBeDefined(); const columns = widgetsTable!.columns.map((c) => c.name); expect(columns).toContain("id"); expect(columns).toContain("area_id"); expect(columns).toContain("sort_order"); expect(columns).toContain("type"); expect(columns).toContain("title"); expect(columns).toContain("content"); expect(columns).toContain("menu_name"); expect(columns).toContain("component_id"); expect(columns).toContain("component_props"); }); it("should enforce unique constraint on widget area name", async () => { const id1 = ulid(); const id2 = ulid(); await db .insertInto("_emdash_widget_areas") .values({ id: id1, name: "sidebar", label: "Sidebar", description: null, }) .execute(); await expect( db .insertInto("_emdash_widget_areas") .values({ id: id2, name: "sidebar", label: "Sidebar Again", description: null, }) .execute(), ).rejects.toThrow(); }); it("should cascade delete widgets when area is deleted", async () => { const areaId = ulid(); const widgetId = ulid(); // Create area await db .insertInto("_emdash_widget_areas") .values({ id: areaId, name: "sidebar", label: "Sidebar", description: null, }) .execute(); // Create widget await db .insertInto("_emdash_widgets") .values({ id: widgetId, area_id: areaId, sort_order: 0, type: "content" as WidgetType, title: "Test Widget", content: null, menu_name: null, component_id: null, component_props: null, }) .execute(); // Delete area await db.deleteFrom("_emdash_widget_areas").where("id", "=", areaId).execute(); // Verify widget was deleted const widgets = await db .selectFrom("_emdash_widgets") .where("area_id", "=", areaId) .selectAll() .execute(); expect(widgets).toHaveLength(0); }); }); describe("widget areas", () => { it("should create a widget area", async () => { const id = ulid(); await db .insertInto("_emdash_widget_areas") .values({ id, name: "sidebar", label: "Sidebar", description: "The main sidebar", }) .execute(); const area = await db .selectFrom("_emdash_widget_areas") .selectAll() .where("id", "=", id) .executeTakeFirst(); expect(area).not.toBeNull(); expect(area?.name).toBe("sidebar"); expect(area?.label).toBe("Sidebar"); expect(area?.description).toBe("The main sidebar"); }); it("should query all widget areas", async () => { await db .insertInto("_emdash_widget_areas") .values([ { id: ulid(), name: "sidebar", label: "Sidebar", description: null }, { id: ulid(), name: "footer", label: "Footer", description: null }, { id: ulid(), name: "header", label: "Header Widgets", description: null, }, ]) .execute(); const areas = await db.selectFrom("_emdash_widget_areas").selectAll().execute(); expect(areas).toHaveLength(3); }); it("should query widget area by name", async () => { await db .insertInto("_emdash_widget_areas") .values({ id: ulid(), name: "sidebar", label: "Sidebar", description: "Primary sidebar", }) .execute(); const area = await db .selectFrom("_emdash_widget_areas") .selectAll() .where("name", "=", "sidebar") .executeTakeFirst(); expect(area).not.toBeNull(); expect(area?.label).toBe("Sidebar"); }); }); describe("widgets", () => { let areaId: string; beforeEach(async () => { areaId = ulid(); await db .insertInto("_emdash_widget_areas") .values({ id: areaId, name: "sidebar", label: "Sidebar", description: null, }) .execute(); }); describe("content widgets", () => { it("should create a content widget", async () => { const id = ulid(); const content = [{ _type: "block", children: [{ _type: "span", text: "Hello" }] }]; await db .insertInto("_emdash_widgets") .values({ id, area_id: areaId, sort_order: 0, type: "content" as WidgetType, title: "Welcome", content: JSON.stringify(content), menu_name: null, component_id: null, component_props: null, }) .execute(); const widget = await db .selectFrom("_emdash_widgets") .selectAll() .where("id", "=", id) .executeTakeFirst(); expect(widget).not.toBeNull(); expect(widget?.type).toBe("content"); expect(widget?.title).toBe("Welcome"); expect(JSON.parse(widget!.content!)).toEqual(content); }); }); describe("menu widgets", () => { it("should create a menu widget", async () => { const id = ulid(); await db .insertInto("_emdash_widgets") .values({ id, area_id: areaId, sort_order: 0, type: "menu" as WidgetType, title: "Navigation", content: null, menu_name: "sidebar-nav", component_id: null, component_props: null, }) .execute(); const widget = await db .selectFrom("_emdash_widgets") .selectAll() .where("id", "=", id) .executeTakeFirst(); expect(widget).not.toBeNull(); expect(widget?.type).toBe("menu"); expect(widget?.menu_name).toBe("sidebar-nav"); }); }); describe("component widgets", () => { it("should create a component widget", async () => { const id = ulid(); const props = { count: 5, showDate: true }; await db .insertInto("_emdash_widgets") .values({ id, area_id: areaId, sort_order: 0, type: "component" as WidgetType, title: "Recent Posts", content: null, menu_name: null, component_id: "core:recent-posts", component_props: JSON.stringify(props), }) .execute(); const widget = await db .selectFrom("_emdash_widgets") .selectAll() .where("id", "=", id) .executeTakeFirst(); expect(widget).not.toBeNull(); expect(widget?.type).toBe("component"); expect(widget?.component_id).toBe("core:recent-posts"); expect(JSON.parse(widget!.component_props!)).toEqual(props); }); }); describe("ordering", () => { it("should order widgets by sort_order", async () => { await db .insertInto("_emdash_widgets") .values([ { id: ulid(), area_id: areaId, sort_order: 2, type: "content" as WidgetType, title: "Third", content: null, menu_name: null, component_id: null, component_props: null, }, { id: ulid(), area_id: areaId, sort_order: 0, type: "content" as WidgetType, title: "First", content: null, menu_name: null, component_id: null, component_props: null, }, { id: ulid(), area_id: areaId, sort_order: 1, type: "content" as WidgetType, title: "Second", content: null, menu_name: null, component_id: null, component_props: null, }, ]) .execute(); const widgets = await db .selectFrom("_emdash_widgets") .selectAll() .where("area_id", "=", areaId) .orderBy("sort_order", "asc") .execute(); expect(widgets).toHaveLength(3); expect(widgets[0].title).toBe("First"); expect(widgets[1].title).toBe("Second"); expect(widgets[2].title).toBe("Third"); }); it("should update sort_order for reordering", async () => { const ids = [ulid(), ulid(), ulid()]; await db .insertInto("_emdash_widgets") .values([ { id: ids[0], area_id: areaId, sort_order: 0, type: "content" as WidgetType, title: "A", content: null, menu_name: null, component_id: null, component_props: null, }, { id: ids[1], area_id: areaId, sort_order: 1, type: "content" as WidgetType, title: "B", content: null, menu_name: null, component_id: null, component_props: null, }, { id: ids[2], area_id: areaId, sort_order: 2, type: "content" as WidgetType, title: "C", content: null, menu_name: null, component_id: null, component_props: null, }, ]) .execute(); // Reorder: C (was 2) -> 0, A (was 0) -> 1, B (was 1) -> 2 const newOrder = [ids[2], ids[0], ids[1]]; for (let i = 0; i < newOrder.length; i++) { await db .updateTable("_emdash_widgets") .set({ sort_order: i }) .where("id", "=", newOrder[i]) .execute(); } const widgets = await db .selectFrom("_emdash_widgets") .selectAll() .where("area_id", "=", areaId) .orderBy("sort_order", "asc") .execute(); expect(widgets[0].title).toBe("C"); expect(widgets[1].title).toBe("A"); expect(widgets[2].title).toBe("B"); }); }); describe("update and delete", () => { it("should update widget properties", async () => { const id = ulid(); await db .insertInto("_emdash_widgets") .values({ id, area_id: areaId, sort_order: 0, type: "content" as WidgetType, title: "Original", content: JSON.stringify([{ _type: "block", children: [] }]), menu_name: null, component_id: null, component_props: null, }) .execute(); const newContent = [{ _type: "block", children: [{ _type: "span", text: "Updated" }] }]; await db .updateTable("_emdash_widgets") .set({ title: "Updated Title", content: JSON.stringify(newContent), }) .where("id", "=", id) .execute(); const widget = await db .selectFrom("_emdash_widgets") .selectAll() .where("id", "=", id) .executeTakeFirst(); expect(widget?.title).toBe("Updated Title"); expect(JSON.parse(widget!.content!)).toEqual(newContent); }); it("should delete a widget", async () => { const id = ulid(); await db .insertInto("_emdash_widgets") .values({ id, area_id: areaId, sort_order: 0, type: "content" as WidgetType, title: "To Delete", content: null, menu_name: null, component_id: null, component_props: null, }) .execute(); await db.deleteFrom("_emdash_widgets").where("id", "=", id).execute(); const widget = await db .selectFrom("_emdash_widgets") .selectAll() .where("id", "=", id) .executeTakeFirst(); expect(widget).toBeUndefined(); }); }); }); describe("widget components registry", () => { it("should return core widget components", () => { const components = getWidgetComponents(); expect(components.length).toBeGreaterThan(0); const recentPosts = components.find((c) => c.id === "core:recent-posts"); expect(recentPosts).toBeDefined(); expect(recentPosts?.label).toBe("Recent Posts"); expect(recentPosts?.props).toHaveProperty("count"); expect(recentPosts?.props).toHaveProperty("showThumbnails"); expect(recentPosts?.props).toHaveProperty("showDate"); }); it("should include categories component", () => { const components = getWidgetComponents(); const categories = components.find((c) => c.id === "core:categories"); expect(categories).toBeDefined(); expect(categories?.props).toHaveProperty("showCount"); expect(categories?.props).toHaveProperty("hierarchical"); }); it("should include tags component", () => { const components = getWidgetComponents(); const tags = components.find((c) => c.id === "core:tags"); expect(tags).toBeDefined(); expect(tags?.props).toHaveProperty("showCount"); expect(tags?.props).toHaveProperty("limit"); }); it("should include search component", () => { const components = getWidgetComponents(); const search = components.find((c) => c.id === "core:search"); expect(search).toBeDefined(); expect(search?.props).toHaveProperty("placeholder"); }); it("should include archives component", () => { const components = getWidgetComponents(); const archives = components.find((c) => c.id === "core:archives"); expect(archives).toBeDefined(); expect(archives?.props).toHaveProperty("type"); expect(archives?.props).toHaveProperty("limit"); expect(archives?.props.type.options).toEqual([ { value: "monthly", label: "Monthly" }, { value: "yearly", label: "Yearly" }, ]); }); it("should have valid prop definitions", () => { const components = getWidgetComponents(); for (const component of components) { expect(component.id).toMatch(WIDGET_ID_FORMAT_REGEX); expect(component.label).toBeTruthy(); for (const [_key, prop] of Object.entries(component.props)) { expect(["string", "number", "boolean", "select"]).toContain(prop.type); expect(prop.label).toBeTruthy(); if (prop.type === "select") { expect(prop.options).toBeDefined(); expect(Array.isArray(prop.options)).toBe(true); } } } }); }); });