Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

1058 lines
30 KiB
TypeScript

import type { Kysely } from "kysely";
import { sql } from "kysely";
import { ulid } from "ulidx";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createMenuItemBody, updateMenuItemBody } from "../../../src/api/schemas/menus.js";
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";
import { SchemaRegistry } from "../../../src/schema/registry.js";
import { sanitizeHref } from "../../../src/utils/url.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("strips the menu_id FK so DROP TABLE _emdash_menus is safe on D1 (#1021)", async () => {
// Migration 036 rebuilds _emdash_menu_items without the
// `ON DELETE CASCADE` FKs declared in migration 005. The cascade
// on `menu_id` fired on D1 when the i18n migration dropped the
// old _emdash_menus, wiping every menu item (#1021). The
// application deletes children explicitly anyway
// (see MenuRepository.delete), so the FK is no longer
// load-bearing.
const rows = await sql<{ table: string }>`
PRAGMA foreign_key_list(_emdash_menu_items)
`.execute(db);
expect(rows.rows).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 sanitize dangerous URLs from the database", async () => {
const menuId = ulid();
const itemId = ulid();
await db
.insertInto("_emdash_menus")
.values({ id: menuId, name: "primary", label: "Primary" })
.execute();
await db
.insertInto("_emdash_menu_items")
.values({
id: itemId,
menu_id: menuId,
sort_order: 0,
type: "custom",
custom_url: "javascript:alert(1)",
label: "XSS",
})
.execute();
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(1);
expect(menu!.items[0].url).toBe("#");
});
it("should sanitize data: URLs from the database", async () => {
const menuId = ulid();
const itemId = ulid();
await db
.insertInto("_emdash_menus")
.values({ id: menuId, name: "primary", label: "Primary" })
.execute();
await db
.insertInto("_emdash_menu_items")
.values({
id: itemId,
menu_id: menuId,
sort_order: 0,
type: "custom",
custom_url: "data:text/html,<script>alert(1)</script>",
label: "XSS",
})
.execute();
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(1);
expect(menu!.items[0].url).toBe("#");
});
it("should sanitize vbscript: URLs from the database", async () => {
const menuId = ulid();
const itemId = ulid();
await db
.insertInto("_emdash_menus")
.values({ id: menuId, name: "primary", label: "Primary" })
.execute();
await db
.insertInto("_emdash_menu_items")
.values({
id: itemId,
menu_id: menuId,
sort_order: 0,
type: "custom",
custom_url: "vbscript:MsgBox",
label: "XSS",
})
.execute();
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(1);
expect(menu!.items[0].url).toBe("#");
});
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");
});
});
describe("collection menu items", () => {
// The admin content picker stores entries from custom collections
// (anything that isn't pages/posts) as type "collection" with
// referenceCollection + referenceId. The resolver used to ignore the
// reference_id for this type and emit the archive URL, so picking
// "Widget Co" from a "projects" collection produced a menu link to
// /projects/ instead of the entry.
async function setupProjectsCollection(urlPattern?: string): Promise<void> {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "projects",
label: "Projects",
labelSingular: "Project",
urlPattern,
});
}
async function insertMenuWithCollectionItem(item: {
referenceCollection?: string | null;
referenceId?: string | null;
}): Promise<void> {
const menuId = ulid();
await db
.insertInto("_emdash_menus")
.values({ id: menuId, name: "primary", label: "Primary" })
.execute();
await db
.insertInto("_emdash_menu_items")
.values({
id: ulid(),
menu_id: menuId,
sort_order: 0,
type: "collection",
reference_collection: item.referenceCollection ?? null,
reference_id: item.referenceId ?? null,
label: "Projects",
})
.execute();
}
it("resolves entry references from the content picker to the entry URL", async () => {
await setupProjectsCollection();
await sql`
INSERT INTO ec_projects (id, slug, locale, translation_group)
VALUES ('proj-1', 'widget-co', 'en', 'group-proj-1')
`.execute(db);
await insertMenuWithCollectionItem({
referenceCollection: "projects",
referenceId: "group-proj-1",
});
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(1);
expect(menu!.items[0].url).toBe("/projects/widget-co");
});
it("applies the collection url_pattern to entry references", async () => {
await setupProjectsCollection("/work/{slug}");
await sql`
INSERT INTO ec_projects (id, slug, locale, translation_group)
VALUES ('proj-1', 'widget-co', 'en', 'group-proj-1')
`.execute(db);
await insertMenuWithCollectionItem({
referenceCollection: "projects",
referenceId: "group-proj-1",
});
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(1);
expect(menu!.items[0].url).toBe("/work/widget-co");
});
it("resolves items without a reference_id to the collection archive URL", async () => {
// Archive links (no entry reference) keep their root-relative
// /{collection}/ shape — the same URL shape as every other
// same-site item type.
await setupProjectsCollection();
await insertMenuWithCollectionItem({ referenceCollection: "projects" });
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(1);
expect(menu!.items[0].url).toBe("/projects/");
});
it("skips entry references whose content row no longer exists", async () => {
// Same contract as page/post items: an unresolvable reference
// hides the item instead of rendering a dead link.
await setupProjectsCollection();
await insertMenuWithCollectionItem({
referenceCollection: "projects",
referenceId: "group-gone",
});
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(0);
});
it("skips items with no reference_collection instead of linking /null/", async () => {
await insertMenuWithCollectionItem({});
const menu = await getMenuWithDb("primary", db);
expect(menu).not.toBeNull();
expect(menu!.items).toHaveLength(0);
});
});
describe("menu item URL validation", () => {
it("should reject javascript: URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "XSS",
customUrl: "javascript:alert(1)",
});
expect(result.success).toBe(false);
});
it("should reject data: URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "XSS",
customUrl: "data:text/html,<script>alert(1)</script>",
});
expect(result.success).toBe(false);
});
it("should reject vbscript: URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "XSS",
customUrl: "vbscript:MsgBox",
});
expect(result.success).toBe(false);
});
it("should allow https URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Link",
customUrl: "https://example.com",
});
expect(result.success).toBe(true);
});
it("should allow relative paths", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Link",
customUrl: "/about",
});
expect(result.success).toBe(true);
});
it("should allow fragment links", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Link",
customUrl: "#section",
});
expect(result.success).toBe(true);
});
it("should reject case-varied javascript: URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "XSS",
customUrl: "JAVASCRIPT:alert(1)",
});
expect(result.success).toBe(false);
});
it("should allow mailto URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Email",
customUrl: "mailto:user@example.com",
});
expect(result.success).toBe(true);
});
it("should reject javascript: in update schema", () => {
const result = updateMenuItemBody.safeParse({
customUrl: "javascript:alert(1)",
});
expect(result.success).toBe(false);
});
it("should allow tel: URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Call",
customUrl: "tel:+15551234567",
});
expect(result.success).toBe(true);
});
it("should reject empty string URLs", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Link",
customUrl: "",
});
expect(result.success).toBe(false);
});
it("should trim whitespace before validating", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Link",
customUrl: " https://example.com ",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.customUrl).toBe("https://example.com");
}
});
it("should reject whitespace-prefixed javascript: after trim", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "XSS",
customUrl: " javascript:alert(1)",
});
expect(result.success).toBe(false);
});
});
describe("sanitizeHref", () => {
it("should return # for null input", () => {
expect(sanitizeHref(null)).toBe("#");
});
it("should return # for undefined input", () => {
expect(sanitizeHref(undefined)).toBe("#");
});
});
describe("handleMenuSetItems", () => {
// The MCP boundary uses Zod with `.nonnegative()` so callers can't
// pass a negative `parentIndex` from there. Direct handler callers
// (REST routes, future programmatic users) bypass that guard, so
// the handler enforces the same constraint.
async function setupMenu(name: string, locale = "en"): Promise<string> {
const id = ulid();
await db.insertInto("_emdash_menus").values({ id, name, label: name, locale }).execute();
return id;
}
it("rejects negative parentIndex", async () => {
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
await setupMenu("main");
const result = await handleMenuSetItems(db, "main", [
{ label: "A", type: "custom", customUrl: "/a", parentIndex: -1 },
]);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("VALIDATION_ERROR");
expect(result.error?.message).toMatch(/parentIndex/);
});
it("rejects parentIndex >= current index (forward reference)", async () => {
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
await setupMenu("main");
const result = await handleMenuSetItems(db, "main", [
{ label: "A", type: "custom", customUrl: "/a" },
{ label: "B", type: "custom", customUrl: "/b", parentIndex: 5 },
]);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("VALIDATION_ERROR");
expect(result.error?.message).toMatch(/parentIndex/);
});
it("returns NOT_FOUND for missing menu and leaves unrelated items untouched", async () => {
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
// Seed a real menu with items so the rollback assertion has
// something to potentially clobber. A regression where the
// handler deleted ALL items before the existence check (the
// shape of the bug we want to guard against) would wipe these.
const otherMenuId = await setupMenu("real");
const otherItemId = ulid();
await db
.insertInto("_emdash_menu_items")
.values({
id: otherItemId,
menu_id: otherMenuId,
sort_order: 0,
type: "custom",
custom_url: "/x",
label: "X",
})
.execute();
const result = await handleMenuSetItems(db, "ghost", [
{ label: "A", type: "custom", customUrl: "/a" },
]);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("NOT_FOUND");
// Unrelated menu's item survives — confirms the transaction
// rolled back (or never started its destructive phase).
const items = await db.selectFrom("_emdash_menu_items").selectAll().execute();
expect(items).toHaveLength(1);
expect(items[0]?.id).toBe(otherItemId);
expect(items[0]?.menu_id).toBe(otherMenuId);
});
it("includes locale in NOT_FOUND when a locale-scoped lookup misses", async () => {
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
const result = await handleMenuSetItems(
db,
"main",
[{ label: "Accueil", type: "custom", customUrl: "/fr" }],
{ locale: "fr-fr" },
);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("NOT_FOUND");
expect(result.error?.message).toContain("fr-fr");
});
it("returns AMBIGUOUS_LOCALE when locale is omitted on a multi-locale install", async () => {
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
// Two menus with the same name in different locales — exactly the
// shape that used to silently let the wrong translation be rewritten.
await setupMenu("main", "en");
await setupMenu("main", "fr-fr");
const result = await handleMenuSetItems(db, "main", [
{ label: "Whatever", type: "custom", customUrl: "/" },
]);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
// Both locales surface in the message so callers can recover.
expect(result.error?.message).toContain("en");
expect(result.error?.message).toContain("fr-fr");
expect(result.error?.message).toMatch(/multiple locales/);
// Transaction must have rolled back — no items written to either menu.
const items = await db.selectFrom("_emdash_menu_items").selectAll().execute();
expect(items).toEqual([]);
});
it("targets the requested locale and tags inserted items with the menu locale", async () => {
const { handleMenuSetItems } = await import("../../../src/api/handlers/menus.js");
const enMenuId = await setupMenu("main", "en");
const frMenuId = await setupMenu("main", "fr-fr");
await db
.insertInto("_emdash_menu_items")
.values([
{
id: ulid(),
menu_id: enMenuId,
sort_order: 0,
type: "custom",
custom_url: "/en-home",
label: "Home",
locale: "en",
},
{
id: ulid(),
menu_id: frMenuId,
sort_order: 0,
type: "custom",
custom_url: "/fr-ancien",
label: "Ancien",
locale: "fr-fr",
},
])
.execute();
const result = await handleMenuSetItems(
db,
"main",
[
{ label: "Accueil", type: "custom", customUrl: "/fr" },
{ label: "Guides", type: "custom", customUrl: "/fr/guides", parentIndex: 0 },
],
{ locale: "fr-fr" },
);
expect(result.success).toBe(true);
// The EN menu must remain untouched; otherwise the locale-aware
// lookup regressed back to "first matching row wins".
const enItems = await db
.selectFrom("_emdash_menu_items")
.select(["label", "custom_url", "locale"])
.where("menu_id", "=", enMenuId)
.orderBy("sort_order", "asc")
.execute();
expect(enItems).toEqual([{ label: "Home", custom_url: "/en-home", locale: "en" }]);
const frItems = await db
.selectFrom("_emdash_menu_items")
.select(["label", "custom_url", "locale", "parent_id"])
.where("menu_id", "=", frMenuId)
.orderBy("sort_order", "asc")
.execute();
expect(frItems).toHaveLength(2);
expect(frItems[0]?.label).toBe("Accueil");
expect(frItems[0]?.custom_url).toBe("/fr");
expect(frItems[0]?.locale).toBe("fr-fr");
expect(frItems[0]?.parent_id).toBeNull();
expect(frItems[1]?.label).toBe("Guides");
expect(frItems[1]?.custom_url).toBe("/fr/guides");
expect(frItems[1]?.locale).toBe("fr-fr");
expect(frItems[1]?.parent_id).toBeTruthy();
});
});
describe("item handlers — multi-locale ambiguity", () => {
// Item-level handlers (create/update/delete/reorder) had the same
// `name`-only lookup as the menu-level handlers, so they used to
// silently target an arbitrary translation on multi-locale installs.
// These tests pin down the AMBIGUOUS_LOCALE behavior so a future
// regression to "first matching row wins" gets caught.
async function setupTwoLocales(name = "main") {
const enId = ulid();
const frId = ulid();
await db
.insertInto("_emdash_menus")
.values([
{ id: enId, name, label: name, locale: "en" },
{ id: frId, name, label: name, locale: "fr-fr" },
])
.execute();
return { enId, frId };
}
it("handleMenuItemCreate: AMBIGUOUS_LOCALE when locale omitted", async () => {
const { handleMenuItemCreate } = await import("../../../src/api/handlers/menus.js");
await setupTwoLocales();
const result = await handleMenuItemCreate(db, "main", {
type: "custom",
label: "Home",
customUrl: "/",
});
expect(result.success).toBe(false);
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
expect(result.error?.message).toContain("en");
expect(result.error?.message).toContain("fr-fr");
// No item written to either menu.
const items = await db.selectFrom("_emdash_menu_items").selectAll().execute();
expect(items).toEqual([]);
});
it("handleMenuItemUpdate: AMBIGUOUS_LOCALE when locale omitted", async () => {
const { handleMenuItemUpdate } = await import("../../../src/api/handlers/menus.js");
const { enId } = await setupTwoLocales();
// Seed an item on the EN menu so the update would otherwise resolve.
const itemId = ulid();
await db
.insertInto("_emdash_menu_items")
.values({
id: itemId,
menu_id: enId,
sort_order: 0,
type: "custom",
custom_url: "/",
label: "Home",
locale: "en",
})
.execute();
const result = await handleMenuItemUpdate(db, "main", itemId, { label: "New" });
expect(result.success).toBe(false);
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
// Label untouched.
const after = await db
.selectFrom("_emdash_menu_items")
.select("label")
.where("id", "=", itemId)
.executeTakeFirstOrThrow();
expect(after.label).toBe("Home");
});
it("handleMenuItemDelete: AMBIGUOUS_LOCALE when locale omitted", async () => {
const { handleMenuItemDelete } = await import("../../../src/api/handlers/menus.js");
const { enId } = await setupTwoLocales();
const itemId = ulid();
await db
.insertInto("_emdash_menu_items")
.values({
id: itemId,
menu_id: enId,
sort_order: 0,
type: "custom",
custom_url: "/",
label: "Home",
locale: "en",
})
.execute();
const result = await handleMenuItemDelete(db, "main", itemId);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
// Item still present.
const surviving = await db
.selectFrom("_emdash_menu_items")
.select("id")
.where("id", "=", itemId)
.executeTakeFirst();
expect(surviving).toBeDefined();
});
it("handleMenuItemReorder: AMBIGUOUS_LOCALE when locale omitted", async () => {
const { handleMenuItemReorder } = await import("../../../src/api/handlers/menus.js");
const { enId } = await setupTwoLocales();
const itemId = ulid();
await db
.insertInto("_emdash_menu_items")
.values({
id: itemId,
menu_id: enId,
sort_order: 0,
type: "custom",
custom_url: "/",
label: "Home",
locale: "en",
})
.execute();
const result = await handleMenuItemReorder(db, "main", [
{ id: itemId, parentId: null, sortOrder: 99 },
]);
expect(result.success).toBe(false);
expect(result.error?.code).toBe("AMBIGUOUS_LOCALE");
// sort_order untouched (not 99).
const after = await db
.selectFrom("_emdash_menu_items")
.select("sort_order")
.where("id", "=", itemId)
.executeTakeFirstOrThrow();
expect(after.sort_order).toBe(0);
});
it("handleMenuItemCreate: with locale, succeeds and tags item locale", async () => {
// Sanity check that the disambiguated path still works.
const { handleMenuItemCreate } = await import("../../../src/api/handlers/menus.js");
await setupTwoLocales();
const result = await handleMenuItemCreate(
db,
"main",
{ type: "custom", label: "Accueil", customUrl: "/fr" },
{ locale: "fr-fr" },
);
expect(result.success).toBe(true);
expect(result.data?.locale).toBe("fr-fr");
});
});
describe("handleMenuCreate (translationOf)", () => {
it("rejects translationOf without locale (handler-level guard)", async () => {
const { handleMenuCreate } = await import("../../../src/api/handlers/menus.js");
// Seed a source menu so the call would otherwise be valid.
const sourceId = ulid();
await db
.insertInto("_emdash_menus")
.values({ id: sourceId, name: "primary", label: "Primary", locale: "en" })
.execute();
const result = await handleMenuCreate(db, {
name: "primary",
label: "Principal",
translationOf: sourceId,
// locale intentionally omitted
});
// Previously this guard lived only at the MCP boundary, so REST/SDK
// callers could clone into the default locale by accident. The
// handler now refuses and the MCP layer relies on this check.
expect(result.success).toBe(false);
expect(result.error?.code).toBe("VALIDATION_ERROR");
expect(result.error?.message).toMatch(/locale/i);
expect(result.error?.message).toMatch(/translationOf/i);
// No row created.
const created = await db
.selectFrom("_emdash_menus")
.selectAll()
.where("name", "=", "primary")
.execute();
expect(created).toHaveLength(1);
expect(created[0]?.id).toBe(sourceId);
});
it("clones items inheriting the source's translation_group", async () => {
const { handleMenuCreate } = await import("../../../src/api/handlers/menus.js");
const sourceId = ulid();
await db
.insertInto("_emdash_menus")
.values({
id: sourceId,
name: "primary",
label: "Primary",
locale: "en",
translation_group: sourceId,
})
.execute();
const sourceItemId = ulid();
await db
.insertInto("_emdash_menu_items")
.values({
id: sourceItemId,
menu_id: sourceId,
sort_order: 0,
type: "custom",
custom_url: "/",
label: "Home",
locale: "en",
translation_group: sourceItemId,
})
.execute();
const result = await handleMenuCreate(db, {
name: "primary",
label: "Principal",
locale: "es",
translationOf: sourceId,
});
expect(result.success).toBe(true);
const cloned = await db
.selectFrom("_emdash_menu_items")
.selectAll()
.where("locale", "=", "es")
.executeTakeFirstOrThrow();
// Cloned item inherits source's translation_group so EN/ES rows
// identify as the same logical nav entry across translations.
expect(cloned.id).not.toBe(sourceItemId);
expect(cloned.translation_group).toBe(sourceItemId);
});
});
});