Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-terms-get-slug-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes `GET /_emdash/api/content/{collection}/{id}/terms/{taxonomy}` returning an empty list when the entry is addressed by its slug. Term assignments are stored under the canonical entry ID, and the POST handler already resolves a slug to that ID before writing; the GET handler now performs the same resolution before reading, so a slug-addressed request returns the assigned terms instead of an empty list.
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ export const GET: APIRoute = async ({ params, locals }) => {

try {
const repo = new TaxonomyRepository(emdash.db);
const terms = await repo.getTermsForEntry(collection, id, taxonomy);
// The URL `id` may be a slug. Term rows are keyed by the canonical
// content ULID — the POST handler resolves the slug and stores under
// that ULID, so the read must resolve it too. Without this, a request
// addressed by slug looks up assignments under the slug, finds none,
// and returns an empty list even though the term is assigned (#1045).
const existing = await emdash.handleContentGet(collection, id);
const canonicalId = existing.success ? (existing.data?.item.id ?? id) : id;
const terms = await repo.getTermsForEntry(collection, canonicalId, taxonomy);
Comment on lines +38 to +45

return apiSuccess({
terms: terms.map((t) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Slug resolution on the content-taxonomy association endpoint.
*
* `content_taxonomies` rows are keyed by the canonical content ULID. The POST
* handler resolves the URL `id` segment (which may be a slug) to that ULID via
* `handleContentGet` before writing. The GET handler must perform the same
* resolution before reading — otherwise a request addressed by slug looks up
* assignments under the slug, finds none, and returns an empty list even
* though the term is assigned.
*
* Regression test for #1045 (GET did not resolve slug -> canonical ULID).
*/

import type { APIContext } from "astro";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
GET as getTerms,
POST as postTerms,
} from "../../../src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].js";
import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js";
import type { Database } from "../../../src/database/types.js";
import { createTestRuntime, handlersFromRuntime } from "../../utils/mcp-runtime.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";

type Handlers = ReturnType<typeof handlersFromRuntime>;
type TermsBody = { data: { terms: Array<{ slug: string }> } };

// RoleLevel 50 = ADMIN — satisfies content:read and content:edit_any.
const ADMIN = { id: "user_admin", email: "admin@example.com", name: "Admin", role: 50 as const };

function buildContext(opts: {
emdash: Handlers;
params: { collection: string; id: string; taxonomy: string };
request: Request;
}): APIContext {
return {
params: opts.params,
url: new URL(opts.request.url),
request: opts.request,
locals: { emdash: opts.emdash, user: ADMIN },
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests
} as unknown as APIContext;
}

describe("content terms endpoint — slug resolution (#1045)", () => {
let db: Kysely<Database>;
let emdash: Handlers;

beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
emdash = handlersFromRuntime(createTestRuntime(db));
});

afterEach(async () => {
await teardownTestDatabase(db);
});

it("GET returns assigned terms when the entry is addressed by slug", async () => {
const taxRepo = new TaxonomyRepository(db);
const term = await taxRepo.create({ name: "tag", slug: "pakistan", label: "Pakistan" });

const created = await emdash.handleContentCreate("post", {
data: { title: "eSIM Pakistan" },
slug: "esim-pakistan",
});
expect(created.success).toBe(true);

const resolved = await emdash.handleContentGet("post", "esim-pakistan");
const postId = resolved.data?.item.id;
if (typeof postId !== "string") throw new Error("expected created post to have an id");

// Assign the term via POST addressed by slug. POST already resolves the
// slug to the ULID, so the row lands under `postId`.
const postRes = await postTerms(
buildContext({
emdash,
params: { collection: "post", id: "esim-pakistan", taxonomy: "tag" },
request: new Request("http://localhost/_emdash/api/content/post/esim-pakistan/terms/tag", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ termIds: [term.id] }),
}),
}),
);
expect(postRes.status).toBe(200);

// Control: GET by canonical ULID resolves trivially and returns the term.
const byId = await getTerms(
buildContext({
emdash,
params: { collection: "post", id: postId, taxonomy: "tag" },
request: new Request(`http://localhost/_emdash/api/content/post/${postId}/terms/tag`),
}),
);
expect(byId.status).toBe(200);
const byIdBody = (await byId.json()) as TermsBody;
expect(byIdBody.data.terms.map((t) => t.slug)).toEqual(["pakistan"]);

// Regression: GET by slug must resolve to the same ULID and return the term.
const bySlug = await getTerms(
buildContext({
emdash,
params: { collection: "post", id: "esim-pakistan", taxonomy: "tag" },
request: new Request("http://localhost/_emdash/api/content/post/esim-pakistan/terms/tag"),
}),
);
expect(bySlug.status).toBe(200);
const bySlugBody = (await bySlug.json()) as TermsBody;
expect(bySlugBody.data.terms.map((t) => t.slug)).toEqual(["pakistan"]);
});
});
Loading