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

281 lines
9.1 KiB
TypeScript

/**
* Menu handler + schema tests
*
* Covers the fixes shipped in this PR:
* - Input schemas reject unknown keys (snake_case payloads no longer pass silently)
* - `type` is validated against the allowed enum
* - `customUrl` actually persists when sent under the documented (camelCase) key
* - Handler responses are camelCase, aligning with the rest of the REST surface
*/
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
handleMenuCreate,
handleMenuGet,
handleMenuItemCreate,
handleMenuItemDelete,
handleMenuItemUpdate,
handleMenuSetItems,
} from "../../../src/api/handlers/menus.js";
import { createMenuItemBody, updateMenuItemBody } from "../../../src/api/schemas/menus.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
describe("menu schemas", () => {
describe("createMenuItemBody.strict()", () => {
it("rejects snake_case keys (custom_url, sort_order)", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Blog",
custom_url: "/blog",
sort_order: 3,
});
expect(result.success).toBe(false);
if (!result.success) {
// Zod 4 reports unknown keys with a single `unrecognized_keys`
// issue whose `keys` array lists the offending names.
const unknownKeys = result.error.issues
.filter((i) => i.code === "unrecognized_keys")
.flatMap((i) =>
"keys" in i && Array.isArray((i as { keys: unknown }).keys)
? (i as { keys: string[] }).keys
: [],
);
expect(unknownKeys).toEqual(expect.arrayContaining(["custom_url", "sort_order"]));
}
});
it("rejects the legacy `url` alias", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Blog",
url: "/blog",
});
expect(result.success).toBe(false);
});
it("rejects non-enum `type` values like 'link'", () => {
const result = createMenuItemBody.safeParse({
type: "link",
label: "Blog",
customUrl: "/blog",
});
expect(result.success).toBe(false);
if (!result.success) {
const typeIssue = result.error.issues.find((i) => i.path[0] === "type");
expect(typeIssue).toBeDefined();
}
});
it("accepts the documented camelCase payload", () => {
const result = createMenuItemBody.safeParse({
type: "custom",
label: "Blog",
customUrl: "/blog",
sortOrder: 3,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.customUrl).toBe("/blog");
expect(result.data.sortOrder).toBe(3);
}
});
});
describe("updateMenuItemBody.strict()", () => {
it("rejects snake_case keys on update too", () => {
const result = updateMenuItemBody.safeParse({ custom_url: "/x" });
expect(result.success).toBe(false);
});
});
});
describe("menu handlers — camelCase responses & customUrl persistence", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("handleMenuCreate returns a camelCase Menu", async () => {
const result = await handleMenuCreate(db, { name: "primary", label: "Primary" });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toMatchObject({
name: "primary",
label: "Primary",
locale: "en",
});
expect(result.data.createdAt).toEqual(expect.any(String));
expect(result.data.updatedAt).toEqual(expect.any(String));
expect(result.data.translationGroup).toBeDefined();
expect(result.data).not.toHaveProperty("created_at");
expect(result.data).not.toHaveProperty("updated_at");
expect(result.data).not.toHaveProperty("translation_group");
}
});
it("handleMenuItemCreate persists customUrl and returns camelCase", async () => {
const menuRes = await handleMenuCreate(db, { name: "primary", label: "Primary" });
expect(menuRes.success).toBe(true);
const itemRes = await handleMenuItemCreate(db, "primary", {
type: "custom",
label: "Blog",
customUrl: "/blog",
sortOrder: 0,
});
expect(itemRes.success).toBe(true);
if (itemRes.success) {
// camelCase response shape
expect(itemRes.data).toMatchObject({
type: "custom",
label: "Blog",
customUrl: "/blog",
sortOrder: 0,
});
expect(itemRes.data.menuId).toEqual(expect.any(String));
expect(itemRes.data.createdAt).toEqual(expect.any(String));
expect(itemRes.data).not.toHaveProperty("custom_url");
expect(itemRes.data).not.toHaveProperty("sort_order");
expect(itemRes.data).not.toHaveProperty("menu_id");
expect(itemRes.data).not.toHaveProperty("translation_group");
}
// And the persisted DB row still has the snake_case column populated
const getRes = await handleMenuGet(db, "primary");
expect(getRes.success).toBe(true);
if (getRes.success) {
expect(getRes.data.items).toHaveLength(1);
expect(getRes.data.items[0]).toMatchObject({
label: "Blog",
customUrl: "/blog",
type: "custom",
});
}
});
it("handleMenuItemUpdate returns camelCase and supports customUrl edits", async () => {
await handleMenuCreate(db, { name: "primary", label: "Primary" });
const created = await handleMenuItemCreate(db, "primary", {
type: "custom",
label: "Blog",
customUrl: "/blog",
});
expect(created.success).toBe(true);
if (!created.success) return;
const updated = await handleMenuItemUpdate(db, "primary", created.data.id, {
customUrl: "/new-blog",
});
expect(updated.success).toBe(true);
if (updated.success) {
expect(updated.data.customUrl).toBe("/new-blog");
expect(updated.data).not.toHaveProperty("custom_url");
}
});
it("handleMenuItemDelete removes the item and returns { deleted: true }", async () => {
await handleMenuCreate(db, { name: "primary", label: "Primary" });
const created = await handleMenuItemCreate(db, "primary", {
type: "custom",
label: "Blog",
customUrl: "/blog",
});
expect(created.success).toBe(true);
if (!created.success) return;
const del = await handleMenuItemDelete(db, "primary", created.data.id);
expect(del.success).toBe(true);
if (del.success) expect(del.data.deleted).toBe(true);
const after = await handleMenuGet(db, "primary");
expect(after.success).toBe(true);
if (after.success) expect(after.data.items).toHaveLength(0);
});
it("handleMenuGet returns camelCase menu and items", async () => {
await handleMenuCreate(db, { name: "primary", label: "Primary" });
await handleMenuItemCreate(db, "primary", {
type: "custom",
label: "Blog",
customUrl: "/blog",
});
const res = await handleMenuGet(db, "primary");
expect(res.success).toBe(true);
if (res.success) {
expect(res.data.createdAt).toEqual(expect.any(String));
expect(res.data.translationGroup).toBeDefined();
expect(res.data).not.toHaveProperty("created_at");
expect(res.data.items).toHaveLength(1);
expect(res.data.items[0].customUrl).toBe("/blog");
expect(res.data.items[0]).not.toHaveProperty("custom_url");
}
});
});
describe("handleMenuSetItems — concurrent delete race", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("returns NOT_FOUND when the menu vanishes between resolve and write", async () => {
// The original handler's in-transaction `notFoundSentinel` guarded
// this exact race: a concurrent menu_delete between resolveMenu and
// the setItems INSERTs would otherwise orphan items on D1.
const createRes = await handleMenuCreate(db, { name: "primary", label: "Primary" });
expect(createRes.success).toBe(true);
if (!createRes.success) return;
const menuId = createRes.data.id;
// Simulate the concurrent delete that lands between the handler's
// resolveMenu lookup and the repository's transaction. We delete
// directly through Kysely (handlers can't be paused mid-flight).
await db.deleteFrom("_emdash_menu_items").where("menu_id", "=", menuId).execute();
await db.deleteFrom("_emdash_menus").where("id", "=", menuId).execute();
const result = await handleMenuSetItems(db, "primary", [
{ label: "Orphan?", type: "custom", customUrl: "/orphan" },
]);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_FOUND");
expect(result.error.message).toContain("primary");
}
// Nothing was inserted — no orphans.
const orphans = await db
.selectFrom("_emdash_menu_items")
.selectAll()
.where("menu_id", "=", menuId)
.execute();
expect(orphans).toHaveLength(0);
});
});
describe("menu items path-style route", () => {
it("exposes a [name]/items/[id].ts route file", async () => {
// The presence of the file (path-style) is what makes the documented
// REST-style DELETE /items/:id reachable; without it Astro returns the
// SSR 404 HTML page. We import dynamically to assert the module exists
// and exports the expected HTTP verbs.
const mod = await import("../../../src/astro/routes/api/menus/[name]/items/[id].js");
expect(typeof mod.PUT).toBe("function");
expect(typeof mod.DELETE).toBe("function");
expect(mod.prerender).toBe(false);
});
});