Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
772 lines
28 KiB
TypeScript
772 lines
28 KiB
TypeScript
/**
|
|
* MCP taxonomy tools — comprehensive integration tests.
|
|
*
|
|
* Covers:
|
|
* - taxonomy_list
|
|
* - taxonomy_list_terms
|
|
* - taxonomy_create_term
|
|
*
|
|
* Plus regression coverage for:
|
|
* - bug #7 (orphan taxonomy collection inconsistency)
|
|
* - bug #13 (no delete/update term tool — gap test)
|
|
*/
|
|
|
|
import { Role } from "@emdash-cms/auth";
|
|
import type { Kysely } from "kysely";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { handleTaxonomyCreate } from "../../../src/api/handlers/taxonomies.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import {
|
|
connectMcpHarness,
|
|
extractJson,
|
|
extractText,
|
|
type McpHarness,
|
|
} from "../../utils/mcp-runtime.js";
|
|
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
const ADMIN_ID = "user_admin";
|
|
const AUTHOR_ID = "user_author";
|
|
const SUBSCRIBER_ID = "user_subscriber";
|
|
|
|
async function setupTaxonomy(
|
|
db: Kysely<Database>,
|
|
input: { name: string; label: string; hierarchical?: boolean; collections?: string[] },
|
|
): Promise<void> {
|
|
const result = await handleTaxonomyCreate(db, input);
|
|
if (!result.success) {
|
|
throw new Error(`Failed to set up taxonomy: ${result.error?.message}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// taxonomy_list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("taxonomy_list", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("returns only the seeded defaults when no extra taxonomies are added", async () => {
|
|
// Migration 006 seeds two default taxonomies: 'category' (hierarchical)
|
|
// and 'tag' (flat), both linked to the 'posts' collection. A fresh
|
|
// install always has these.
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list",
|
|
arguments: {},
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
const { taxonomies } = extractJson<{
|
|
taxonomies: Array<{ name: string }>;
|
|
}>(result);
|
|
const names = taxonomies.map((t) => t.name).toSorted();
|
|
expect(names).toEqual(["category", "tag"]);
|
|
});
|
|
|
|
it("lists user-created taxonomies alongside the defaults", async () => {
|
|
const registry = new SchemaRegistry(db);
|
|
await registry.createCollection({ slug: "post", label: "Posts" });
|
|
// Use names that don't collide with the seeded `category` / `tag`.
|
|
await setupTaxonomy(db, {
|
|
name: "section",
|
|
label: "Sections",
|
|
hierarchical: true,
|
|
collections: ["post"],
|
|
});
|
|
await setupTaxonomy(db, {
|
|
name: "topic",
|
|
label: "Topics",
|
|
collections: ["post"],
|
|
});
|
|
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list",
|
|
arguments: {},
|
|
});
|
|
const { taxonomies } = extractJson<{
|
|
taxonomies: Array<{ name: string; hierarchical?: boolean; collections?: string[] }>;
|
|
}>(result);
|
|
const names = taxonomies.map((t) => t.name).toSorted();
|
|
expect(names).toEqual(["category", "section", "tag", "topic"]);
|
|
|
|
const section = taxonomies.find((t) => t.name === "section");
|
|
expect(section?.hierarchical).toBe(true);
|
|
expect(section?.collections).toEqual(["post"]);
|
|
});
|
|
|
|
it("any logged-in user (SUBSCRIBER) can read taxonomies", async () => {
|
|
harness = await connectMcpHarness({ db, userId: SUBSCRIBER_ID, userRole: Role.SUBSCRIBER });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list",
|
|
arguments: {},
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
});
|
|
|
|
it("bug #7: orphaned collection slugs are filtered from taxonomy_list output", async () => {
|
|
// The seed taxonomies (category, tag) both reference 'posts' — a
|
|
// collection that doesn't exist in this test DB (no auto-seed). After
|
|
// the bug #7 fix, `taxonomy_list` filters those orphans out. We don't
|
|
// need to manufacture an orphan; the seed already gives us one.
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
|
|
const taxResult = await harness.client.callTool({
|
|
name: "taxonomy_list",
|
|
arguments: {},
|
|
});
|
|
const { taxonomies } = extractJson<{
|
|
taxonomies: Array<{ name: string; collections?: string[] }>;
|
|
}>(taxResult);
|
|
|
|
// Each seeded taxonomy referenced 'posts'. After filtering, that
|
|
// orphan slug is gone — the array should be empty for both seeds.
|
|
for (const t of taxonomies) {
|
|
expect(t.collections).not.toContain("posts");
|
|
}
|
|
|
|
// And schema_list_collections agrees: there is no 'posts' collection.
|
|
const collResult = await harness.client.callTool({
|
|
name: "schema_list_collections",
|
|
arguments: {},
|
|
});
|
|
const { items } = extractJson<{ items: Array<{ slug: string }> }>(collResult);
|
|
expect(items.find((c) => c.slug === "posts")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// taxonomy_list_terms
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("taxonomy_list_terms", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
await setupTaxonomy(db, { name: "categories", label: "Categories", hierarchical: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("returns empty list when taxonomy has no terms", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories" },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
const { items } = extractJson<{ items: unknown[] }>(result);
|
|
expect(items).toEqual([]);
|
|
});
|
|
|
|
it("returns terms after creation", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "tech", label: "Tech" },
|
|
});
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "design", label: "Design" },
|
|
});
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories" },
|
|
});
|
|
const { items } = extractJson<{
|
|
items: Array<{ slug: string; label: string; parentId: string | null }>;
|
|
}>(result);
|
|
const slugs = items.map((t) => t.slug).toSorted();
|
|
expect(slugs).toEqual(["design", "tech"]);
|
|
});
|
|
|
|
it("returns clear error for missing taxonomy name", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "nonexistent" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i);
|
|
expect(extractText(result)).toContain("nonexistent");
|
|
});
|
|
|
|
it("paginates with limit + cursor", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
// Insert 5 terms — labels chosen so alphabetical ordering is predictable
|
|
for (const label of ["alpha", "bravo", "charlie", "delta", "echo"]) {
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: label, label },
|
|
});
|
|
}
|
|
|
|
const page1 = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories", limit: 2 },
|
|
});
|
|
const p1 = extractJson<{ items: Array<{ slug: string; id: string }>; nextCursor?: string }>(
|
|
page1,
|
|
);
|
|
expect(p1.items).toHaveLength(2);
|
|
expect(p1.nextCursor).toBeTruthy();
|
|
|
|
const page2 = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories", limit: 2, cursor: p1.nextCursor },
|
|
});
|
|
const p2 = extractJson<{ items: Array<{ slug: string }>; nextCursor?: string }>(page2);
|
|
expect(p2.items).toHaveLength(2);
|
|
|
|
// No overlap
|
|
const p1Slugs = p1.items.map((i) => i.slug);
|
|
for (const t of p2.items) expect(p1Slugs).not.toContain(t.slug);
|
|
});
|
|
|
|
it("paginates correctly when multiple terms share the same label", async () => {
|
|
// Keyset pagination over (label, id) needs a stable id tiebreaker
|
|
// at the SQL layer or tied-label rows can swap order between calls
|
|
// — producing duplicates or skipped items. Three terms share
|
|
// label "shared"; pagination must walk through them in a stable
|
|
// order with no duplicates and no gaps.
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const slugs = ["shared-1", "shared-2", "shared-3", "unique-a"];
|
|
for (const slug of slugs) {
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: {
|
|
taxonomy: "categories",
|
|
slug,
|
|
label: slug.startsWith("shared") ? "shared" : slug,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Walk one item at a time so every cursor transition exercises the
|
|
// (label, id) keyset.
|
|
const collected: string[] = [];
|
|
let cursor: string | undefined;
|
|
// Hard cap to prevent the test hanging if pagination loops.
|
|
for (let i = 0; i < 10; i++) {
|
|
const page = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories", limit: 1, ...(cursor ? { cursor } : {}) },
|
|
});
|
|
const data = extractJson<{
|
|
items: Array<{ slug: string; id: string }>;
|
|
nextCursor?: string;
|
|
}>(page);
|
|
if (data.items.length === 0) break;
|
|
for (const item of data.items) collected.push(item.slug);
|
|
if (!data.nextCursor) break;
|
|
cursor = data.nextCursor;
|
|
}
|
|
|
|
// Each slug appears exactly once. Order doesn't matter for this
|
|
// assertion — just no duplicates and no missing entries.
|
|
expect(collected.toSorted()).toEqual(slugs.toSorted());
|
|
});
|
|
|
|
it("survives concurrent deletion of the cursor-term", async () => {
|
|
// The base64 keyset cursor encodes a (label, id) position rather
|
|
// than a row reference, so deleting the cursor-term between pages
|
|
// must not error — the next page just continues from the next
|
|
// position in sort order.
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
for (const slug of ["alpha", "bravo", "charlie", "delta"]) {
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug, label: slug },
|
|
});
|
|
}
|
|
|
|
const page1 = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories", limit: 2 },
|
|
});
|
|
const p1 = extractJson<{
|
|
items: Array<{ slug: string }>;
|
|
nextCursor?: string;
|
|
}>(page1);
|
|
expect(p1.items.map((i) => i.slug)).toEqual(["alpha", "bravo"]);
|
|
expect(p1.nextCursor).toBeTruthy();
|
|
|
|
// Delete the cursor-term ('bravo') out of band.
|
|
const { TaxonomyRepository } = await import("../../../src/database/repositories/taxonomy.js");
|
|
const repo = new TaxonomyRepository(db);
|
|
const bravo = await repo.findBySlug("categories", "bravo");
|
|
if (!bravo) throw new Error("bravo missing — fixture broken");
|
|
await db.deleteFrom("taxonomies").where("id", "=", bravo.id).execute();
|
|
|
|
// Page 2 must still work and return the items strictly after the
|
|
// cursor's position. Pre-fix the cursor stored 'bravo's id and
|
|
// findIndex would have returned -1 → INVALID_CURSOR. Post-fix the
|
|
// cursor stores ('bravo', '<bravo-id>') and the keyset comparison
|
|
// finds the first term with (label, id) > ('bravo', '<bravo-id>')
|
|
// — that's 'charlie'.
|
|
const page2 = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories", limit: 2, cursor: p1.nextCursor },
|
|
});
|
|
expect(page2.isError, extractText(page2)).toBeFalsy();
|
|
const p2 = extractJson<{ items: Array<{ slug: string }> }>(page2);
|
|
expect(p2.items.map((i) => i.slug)).toEqual(["charlie", "delta"]);
|
|
});
|
|
|
|
it("malformed cursor returns INVALID_CURSOR", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "t1", label: "T1" },
|
|
});
|
|
|
|
// taxonomy_list_terms uses a base64 keyset cursor over (label, id).
|
|
// A completely bogus value fails decodeCursor and surfaces as a
|
|
// structured INVALID_CURSOR error.
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories", cursor: "garbage_cursor_xyz" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
const meta = (result as { _meta?: { code?: string } })._meta;
|
|
expect(meta?.code).toBe("INVALID_CURSOR");
|
|
});
|
|
|
|
it("any logged-in user (SUBSCRIBER) can read terms", async () => {
|
|
harness = await connectMcpHarness({ db, userId: SUBSCRIBER_ID, userRole: Role.SUBSCRIBER });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "categories" },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// taxonomy_create_term
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("taxonomy_create_term", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
await setupTaxonomy(db, { name: "categories", label: "Categories", hierarchical: true });
|
|
await setupTaxonomy(db, { name: "tags", label: "Tags" });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("creates a term with minimal arguments", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "tech", label: "Tech" },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
const { term } = extractJson<{ term: { slug: string; label: string } }>(result);
|
|
expect(term.slug).toBe("tech");
|
|
expect(term.label).toBe("Tech");
|
|
});
|
|
|
|
it("creates a child term with parentId", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const parent = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "tech", label: "Tech" },
|
|
});
|
|
const parentId = extractJson<{ term: { id: string } }>(parent).term.id;
|
|
|
|
const child = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: {
|
|
taxonomy: "categories",
|
|
slug: "ai",
|
|
label: "AI",
|
|
parentId,
|
|
},
|
|
});
|
|
expect(child.isError, extractText(child)).toBeFalsy();
|
|
const { term } = extractJson<{ term: { parentId: string | null } }>(child);
|
|
expect(term.parentId).toBe(parentId);
|
|
});
|
|
|
|
it("rejects duplicate slug within the same taxonomy", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "tech", label: "Tech" },
|
|
});
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "tech", label: "Tech 2" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/exist|duplicate|conflict|unique|already/i);
|
|
});
|
|
|
|
it("allows same slug across different taxonomies", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const a = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "shared", label: "Shared" },
|
|
});
|
|
const b = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "shared", label: "Shared" },
|
|
});
|
|
expect(a.isError, extractText(a)).toBeFalsy();
|
|
expect(b.isError, extractText(b)).toBeFalsy();
|
|
});
|
|
|
|
it("rejects creating a term in a non-existent taxonomy", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "ghost", slug: "x", label: "X" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/\bNOT_FOUND\b|\bnot found\b/i);
|
|
expect(extractText(result)).toContain("ghost");
|
|
});
|
|
|
|
it("rejects parentId pointing to a different taxonomy", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const tag = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "stuff", label: "Stuff" },
|
|
});
|
|
const tagId = extractJson<{ term: { id: string } }>(tag).term.id;
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: {
|
|
taxonomy: "categories",
|
|
slug: "child",
|
|
label: "Child",
|
|
parentId: tagId,
|
|
},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
|
|
it("rejects parentId pointing to a non-existent term", async () => {
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: {
|
|
taxonomy: "categories",
|
|
slug: "orphan",
|
|
label: "Orphan",
|
|
parentId: "01NEVEREXISTED",
|
|
},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
|
|
it("requires EDITOR role (AUTHOR is blocked)", async () => {
|
|
harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR });
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "categories", slug: "x", label: "X" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bug #13 / F2 / F3 / F12 — happy paths for taxonomy_update_term and
|
|
// taxonomy_delete_term, plus parent validation, cycle detection, and
|
|
// empty-string rejection.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("taxonomy_update_term (bug #13 / F2 / F12)", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
async function createTerm(
|
|
taxonomy: string,
|
|
slug: string,
|
|
label: string,
|
|
parentId?: string,
|
|
): Promise<string> {
|
|
const args: Record<string, unknown> = { taxonomy, slug, label };
|
|
if (parentId) args.parentId = parentId;
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: args,
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
const { term } = extractJson<{ term: { id: string } }>(result);
|
|
return term.id;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
await setupTaxonomy(db, { name: "tags", label: "Tags" });
|
|
await setupTaxonomy(db, { name: "sections", label: "Sections" });
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("MCP exposes taxonomy_update_term and taxonomy_delete_term", async () => {
|
|
const tools = await harness.client.listTools();
|
|
const names = tools.tools.map((t) => t.name);
|
|
expect(names).toContain("taxonomy_update_term");
|
|
expect(names).toContain("taxonomy_delete_term");
|
|
});
|
|
|
|
it("renames the slug when the new slug is free", async () => {
|
|
await createTerm("tags", "old-slug", "Original");
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "old-slug", slug: "new-slug" },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
const { term } = extractJson<{ term: { slug: string } }>(result);
|
|
expect(term.slug).toBe("new-slug");
|
|
});
|
|
|
|
it("changes the label", async () => {
|
|
await createTerm("tags", "x", "Old Label");
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "x", label: "New Label" },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
const { term } = extractJson<{ term: { label: string } }>(result);
|
|
expect(term.label).toBe("New Label");
|
|
});
|
|
|
|
it("reparents a term and detaches via parentId: null", async () => {
|
|
const parentId = await createTerm("tags", "parent", "Parent");
|
|
await createTerm("tags", "child", "Child");
|
|
|
|
const reparent = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "child", parentId },
|
|
});
|
|
expect(reparent.isError, extractText(reparent)).toBeFalsy();
|
|
const reparented = extractJson<{ term: { parentId: string | null } }>(reparent);
|
|
expect(reparented.term.parentId).toBe(parentId);
|
|
|
|
const detach = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "child", parentId: null },
|
|
});
|
|
expect(detach.isError, extractText(detach)).toBeFalsy();
|
|
const detached = extractJson<{ term: { parentId: string | null } }>(detach);
|
|
expect(detached.term.parentId).toBeNull();
|
|
});
|
|
|
|
it("rejects parents from a different taxonomy", async () => {
|
|
const sectionId = await createTerm("sections", "news", "News");
|
|
await createTerm("tags", "alpha", "Alpha");
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "alpha", parentId: sectionId },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/VALIDATION_ERROR/);
|
|
});
|
|
|
|
it("rejects self-parent", async () => {
|
|
const id = await createTerm("tags", "loop", "Loop");
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "loop", parentId: id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/own parent|VALIDATION_ERROR/i);
|
|
});
|
|
|
|
it("rejects a 2-cycle (descendant becoming ancestor)", async () => {
|
|
// A is parent of B. Now try to make B the parent of A — that's a cycle.
|
|
const aId = await createTerm("tags", "a", "A");
|
|
const bId = await createTerm("tags", "b", "B", aId);
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_update_term",
|
|
arguments: { taxonomy: "tags", termSlug: "a", parentId: bId },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/cycle|VALIDATION_ERROR/i);
|
|
});
|
|
|
|
it("rejects empty-string parentId on create", async () => {
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "x", label: "X", parentId: "" },
|
|
});
|
|
// Either returns a validation error, or treats it as no-parent.
|
|
// We choose strict: empty string is normalized to undefined so it
|
|
// succeeds with parentId === null (no parent attached). That's the
|
|
// behavior we documented.
|
|
if (result.isError) {
|
|
expect(extractText(result)).toMatch(/VALIDATION_ERROR/);
|
|
} else {
|
|
const { term } = extractJson<{ term: { parentId: string | null } }>(result);
|
|
expect(term.parentId).toBeNull();
|
|
}
|
|
});
|
|
|
|
// ----- MAX_DEPTH boundary -----
|
|
// validateParentTerm walks up the parent chain bounded by MAX_DEPTH=100
|
|
// to prevent a pathological pre-existing cycle from hanging the
|
|
// validator. The boundary is "more than 100 ancestors": exactly-100 is
|
|
// accepted, 101+ is rejected.
|
|
|
|
it("accepts a chain of exactly MAX_DEPTH (100) ancestors", async () => {
|
|
const { TaxonomyRepository } = await import("../../../src/database/repositories/taxonomy.js");
|
|
const repo = new TaxonomyRepository(db);
|
|
// Build root → 1 → 2 → ... → 100. 101 terms total. The deepest
|
|
// term has 100 ancestors; setting it as parent of a new term means
|
|
// validateParentTerm walks 100 hops up before exhausting the chain.
|
|
let parentId: string | undefined;
|
|
const ids: string[] = [];
|
|
for (let i = 0; i < 101; i++) {
|
|
const term = await repo.create({
|
|
name: "tags",
|
|
slug: `chain-${i}`,
|
|
label: `Chain ${i}`,
|
|
parentId,
|
|
});
|
|
ids.push(term.id);
|
|
parentId = term.id;
|
|
}
|
|
const deepest = ids.at(-1);
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "leaf", label: "Leaf", parentId: deepest },
|
|
});
|
|
// New term's parent is the 100-deep tail. Walking up from there
|
|
// reaches the root after exactly 100 hops; cursor becomes null,
|
|
// the depth-exceeded check does NOT fire.
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
});
|
|
|
|
it("rejects a chain that exceeds MAX_DEPTH", async () => {
|
|
const { TaxonomyRepository } = await import("../../../src/database/repositories/taxonomy.js");
|
|
const repo = new TaxonomyRepository(db);
|
|
// Build a 102-term chain. The deepest term has 101 ancestors —
|
|
// one more than MAX_DEPTH allows.
|
|
let parentId: string | undefined;
|
|
const ids: string[] = [];
|
|
for (let i = 0; i < 102; i++) {
|
|
const term = await repo.create({
|
|
name: "tags",
|
|
slug: `chain-${i}`,
|
|
label: `Chain ${i}`,
|
|
parentId,
|
|
});
|
|
ids.push(term.id);
|
|
parentId = term.id;
|
|
}
|
|
const deepest = ids.at(-1);
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "leaf", label: "Leaf", parentId: deepest },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/maximum depth/i);
|
|
const meta = (result as { _meta?: { code?: string } })._meta;
|
|
expect(meta?.code).toBe("VALIDATION_ERROR");
|
|
});
|
|
});
|
|
|
|
describe("taxonomy_delete_term (bug #13 / F12)", () => {
|
|
let db: Kysely<Database>;
|
|
let harness: McpHarness;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
await setupTaxonomy(db, { name: "tags", label: "Tags" });
|
|
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (harness) await harness.cleanup();
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("rejects deletion when children exist (matches handler behavior)", async () => {
|
|
const parent = await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "parent", label: "Parent" },
|
|
});
|
|
const { term } = extractJson<{ term: { id: string } }>(parent);
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "child", label: "Child", parentId: term.id },
|
|
});
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_delete_term",
|
|
arguments: { taxonomy: "tags", termSlug: "parent" },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(extractText(result)).toMatch(/VALIDATION_ERROR|children/i);
|
|
});
|
|
|
|
it("deletes a leaf term and the row is actually gone", async () => {
|
|
await harness.client.callTool({
|
|
name: "taxonomy_create_term",
|
|
arguments: { taxonomy: "tags", slug: "leaf", label: "Leaf" },
|
|
});
|
|
|
|
// Pre-condition: the term is listable.
|
|
const before = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "tags" },
|
|
});
|
|
const beforeSlugs = extractJson<{ items: Array<{ slug: string }> }>(before).items.map(
|
|
(t) => t.slug,
|
|
);
|
|
expect(beforeSlugs).toContain("leaf");
|
|
|
|
const result = await harness.client.callTool({
|
|
name: "taxonomy_delete_term",
|
|
arguments: { taxonomy: "tags", termSlug: "leaf" },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
|
|
// Post-condition: the term is no longer listable. A regression where
|
|
// the handler returns success: true without actually deleting the row
|
|
// fails this assertion.
|
|
const after = await harness.client.callTool({
|
|
name: "taxonomy_list_terms",
|
|
arguments: { taxonomy: "tags" },
|
|
});
|
|
const afterSlugs = extractJson<{ items: Array<{ slug: string }> }>(after).items.map(
|
|
(t) => t.slug,
|
|
);
|
|
expect(afterSlugs).not.toContain("leaf");
|
|
});
|
|
});
|