Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
// Mock loader.getDb so the runtime taxonomy functions read from our test db.
|
||||
vi.mock("../../../src/loader.js", () => ({
|
||||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from "../../../src/loader.js";
|
||||
import { runWithContext } from "../../../src/request-context.js";
|
||||
import {
|
||||
getAllTermsForEntries,
|
||||
getEntryTerms,
|
||||
invalidateTermCache,
|
||||
} from "../../../src/taxonomies/index.js";
|
||||
|
||||
describe("getAllTermsForEntries", () => {
|
||||
let db: Kysely<Database>;
|
||||
let taxRepo: TaxonomyRepository;
|
||||
let contentRepo: ContentRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
taxRepo = new TaxonomyRepository(db);
|
||||
contentRepo = new ContentRepository(db);
|
||||
vi.mocked(getDb).mockResolvedValue(db);
|
||||
invalidateTermCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
invalidateTermCache();
|
||||
await teardownTestDatabase(db);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty map for empty entry list", async () => {
|
||||
const result = await getAllTermsForEntries("post", []);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("short-circuits without querying when no assignments exist", async () => {
|
||||
// Create terms but attach to nothing
|
||||
await taxRepo.create({ name: "tag", slug: "a", label: "A" });
|
||||
|
||||
const post = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p1",
|
||||
data: { title: "P1" },
|
||||
});
|
||||
|
||||
const result = await getAllTermsForEntries("post", [post.id]);
|
||||
|
||||
// Still returns the entry id, but with empty object
|
||||
expect(result.get(post.id)).toEqual({});
|
||||
});
|
||||
|
||||
it("hydrates terms grouped by taxonomy name in a single query", async () => {
|
||||
const tag1 = await taxRepo.create({ name: "tag", slug: "web", label: "Web" });
|
||||
const tag2 = await taxRepo.create({ name: "tag", slug: "ai", label: "AI" });
|
||||
const cat = await taxRepo.create({ name: "category", slug: "news", label: "News" });
|
||||
|
||||
const p1 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p1",
|
||||
data: { title: "P1" },
|
||||
});
|
||||
const p2 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p2",
|
||||
data: { title: "P2" },
|
||||
});
|
||||
|
||||
await taxRepo.attachToEntry("post", p1.id, tag1.id);
|
||||
await taxRepo.attachToEntry("post", p1.id, tag2.id);
|
||||
await taxRepo.attachToEntry("post", p1.id, cat.id);
|
||||
await taxRepo.attachToEntry("post", p2.id, tag1.id);
|
||||
|
||||
// Invalidate the hasAnyTermAssignments probe cached from earlier tests
|
||||
invalidateTermCache();
|
||||
|
||||
const result = await getAllTermsForEntries("post", [p1.id, p2.id]);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
|
||||
const p1Terms = result.get(p1.id)!;
|
||||
expect(Object.keys(p1Terms).toSorted()).toEqual(["category", "tag"]);
|
||||
expect(p1Terms.tag.map((t) => t.slug).toSorted()).toEqual(["ai", "web"]);
|
||||
expect(p1Terms.category.map((t) => t.slug)).toEqual(["news"]);
|
||||
|
||||
const p2Terms = result.get(p2.id)!;
|
||||
expect(Object.keys(p2Terms)).toEqual(["tag"]);
|
||||
expect(p2Terms.tag.map((t) => t.slug)).toEqual(["web"]);
|
||||
});
|
||||
|
||||
it("scopes results to the requested collection", async () => {
|
||||
const tag = await taxRepo.create({ name: "tag", slug: "shared", label: "Shared" });
|
||||
|
||||
const post = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p",
|
||||
data: { title: "P" },
|
||||
});
|
||||
const page = await contentRepo.create({
|
||||
type: "page",
|
||||
slug: "pg",
|
||||
data: { title: "Pg" },
|
||||
});
|
||||
|
||||
await taxRepo.attachToEntry("post", post.id, tag.id);
|
||||
await taxRepo.attachToEntry("page", page.id, tag.id);
|
||||
|
||||
invalidateTermCache();
|
||||
|
||||
const postTerms = await getAllTermsForEntries("post", [post.id, page.id]);
|
||||
// Only `post.id` should have the tag in post scope; page.id is a no-op here
|
||||
// since its assignment is to the `page` collection.
|
||||
expect(postTerms.get(post.id)?.tag?.[0].slug).toBe("shared");
|
||||
expect(postTerms.get(page.id)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty arrays for entries with no terms", async () => {
|
||||
const tag = await taxRepo.create({ name: "tag", slug: "one", label: "One" });
|
||||
const p1 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p1",
|
||||
data: { title: "P1" },
|
||||
});
|
||||
const p2 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p2",
|
||||
data: { title: "P2" },
|
||||
});
|
||||
await taxRepo.attachToEntry("post", p1.id, tag.id);
|
||||
|
||||
invalidateTermCache();
|
||||
|
||||
const result = await getAllTermsForEntries("post", [p1.id, p2.id]);
|
||||
expect(result.get(p1.id)?.tag?.[0].slug).toBe("one");
|
||||
expect(result.get(p2.id)).toEqual({});
|
||||
});
|
||||
|
||||
it("primes the request cache so getEntryTerms doesn't re-query", async () => {
|
||||
// Extend the default "tag" taxonomy (seeded for `posts`) to also
|
||||
// apply to the `post` collection used in these tests. Without this,
|
||||
// the primer wouldn't seed the "tag" key for entries with no tags.
|
||||
await db
|
||||
.updateTable("_emdash_taxonomy_defs")
|
||||
.set({ collections: JSON.stringify(["posts", "post"]) })
|
||||
.where("name", "=", "tag")
|
||||
.execute();
|
||||
|
||||
const tag = await taxRepo.create({ name: "tag", slug: "web", label: "Web" });
|
||||
const p1 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p1",
|
||||
data: { title: "P1" },
|
||||
});
|
||||
const p2 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p2",
|
||||
data: { title: "P2" },
|
||||
});
|
||||
await taxRepo.attachToEntry("post", p1.id, tag.id);
|
||||
|
||||
invalidateTermCache();
|
||||
|
||||
const getDbSpy = vi.mocked(getDb);
|
||||
|
||||
await runWithContext({ editMode: false }, async () => {
|
||||
// Populate the cache via the batched API.
|
||||
await getAllTermsForEntries("post", [p1.id, p2.id]);
|
||||
|
||||
const callsAfterBatch = getDbSpy.mock.calls.length;
|
||||
|
||||
// These per-entry calls should hit the primed cache.
|
||||
const p1Tags = await getEntryTerms("post", p1.id, "tag");
|
||||
const p2Tags = await getEntryTerms("post", p2.id, "tag");
|
||||
const p1All = await getEntryTerms("post", p1.id); // the `*` key
|
||||
|
||||
// No additional DB calls should have happened for the primed keys.
|
||||
expect(getDbSpy.mock.calls.length).toBe(callsAfterBatch);
|
||||
|
||||
// And the values should match what the batch returned.
|
||||
expect(p1Tags.map((t) => t.slug)).toEqual(["web"]);
|
||||
expect(p2Tags).toEqual([]);
|
||||
expect(p1All.map((t) => t.slug)).toEqual(["web"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak primed entries across requests", async () => {
|
||||
await db
|
||||
.updateTable("_emdash_taxonomy_defs")
|
||||
.set({ collections: JSON.stringify(["posts", "post"]) })
|
||||
.where("name", "=", "tag")
|
||||
.execute();
|
||||
|
||||
const tag = await taxRepo.create({ name: "tag", slug: "web", label: "Web" });
|
||||
const p1 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "p1",
|
||||
data: { title: "P1" },
|
||||
});
|
||||
await taxRepo.attachToEntry("post", p1.id, tag.id);
|
||||
|
||||
invalidateTermCache();
|
||||
|
||||
await runWithContext({ editMode: false }, async () => {
|
||||
await getAllTermsForEntries("post", [p1.id]);
|
||||
});
|
||||
|
||||
const getDbSpy = vi.mocked(getDb);
|
||||
const callsBeforeSecondRequest = getDbSpy.mock.calls.length;
|
||||
|
||||
await runWithContext({ editMode: false }, async () => {
|
||||
// Different request context — the cache from the previous context
|
||||
// must not be visible here.
|
||||
await getEntryTerms("post", p1.id, "tag");
|
||||
});
|
||||
|
||||
// At least one DB call should have happened in the second request.
|
||||
expect(getDbSpy.mock.calls.length).toBeGreaterThan(callsBeforeSecondRequest);
|
||||
});
|
||||
});
|
||||
675
packages/core/tests/unit/taxonomies/taxonomies.test.ts
Normal file
675
packages/core/tests/unit/taxonomies/taxonomies.test.ts
Normal file
@@ -0,0 +1,675 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
||||
import { TaxonomyRepository } from "../../../src/database/repositories/taxonomy.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import {
|
||||
setupTestDatabase,
|
||||
setupTestDatabaseWithCollections,
|
||||
teardownTestDatabase,
|
||||
} from "../../utils/test-db.js";
|
||||
|
||||
describe("taxonomy manifest loading", () => {
|
||||
let db: Kysely<Database>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
it("should load seeded taxonomy definitions ordered by name", async () => {
|
||||
const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().orderBy("name").execute();
|
||||
|
||||
const taxonomies = rows.map((row) => ({
|
||||
name: row.name,
|
||||
label: row.label,
|
||||
labelSingular: row.label_singular ?? undefined,
|
||||
hierarchical: row.hierarchical === 1,
|
||||
collections: row.collections ? JSON.parse(row.collections) : [],
|
||||
}));
|
||||
|
||||
expect(taxonomies).toHaveLength(2);
|
||||
expect(taxonomies[0]).toMatchObject({
|
||||
name: "category",
|
||||
label: "Categories",
|
||||
hierarchical: true,
|
||||
collections: ["posts"],
|
||||
});
|
||||
expect(taxonomies[1]).toMatchObject({
|
||||
name: "tag",
|
||||
label: "Tags",
|
||||
hierarchical: false,
|
||||
collections: ["posts"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should include custom taxonomy definitions in manifest", async () => {
|
||||
await db
|
||||
.insertInto("_emdash_taxonomy_defs")
|
||||
.values({
|
||||
id: "taxdef_genre",
|
||||
name: "genre",
|
||||
label: "Genres",
|
||||
label_singular: "Genre",
|
||||
hierarchical: 1,
|
||||
collections: JSON.stringify(["posts", "pages"]),
|
||||
})
|
||||
.execute();
|
||||
|
||||
const rows = await db.selectFrom("_emdash_taxonomy_defs").selectAll().orderBy("name").execute();
|
||||
|
||||
const taxonomies = rows.map((row) => ({
|
||||
name: row.name,
|
||||
label: row.label,
|
||||
labelSingular: row.label_singular ?? undefined,
|
||||
hierarchical: row.hierarchical === 1,
|
||||
collections: row.collections ? JSON.parse(row.collections) : [],
|
||||
}));
|
||||
|
||||
expect(taxonomies).toHaveLength(3);
|
||||
expect(taxonomies[0].name).toBe("category");
|
||||
expect(taxonomies[1].name).toBe("genre");
|
||||
expect(taxonomies[2].name).toBe("tag");
|
||||
expect(taxonomies[1]).toMatchObject({
|
||||
label: "Genres",
|
||||
hierarchical: true,
|
||||
collections: ["posts", "pages"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaxonomyRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: TaxonomyRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
repo = new TaxonomyRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a taxonomy term", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "javascript",
|
||||
label: "JavaScript",
|
||||
});
|
||||
|
||||
expect(term.id).toBeDefined();
|
||||
expect(term.name).toBe("tags");
|
||||
expect(term.slug).toBe("javascript");
|
||||
expect(term.label).toBe("JavaScript");
|
||||
expect(term.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it("should create a term with parent", async () => {
|
||||
const parent = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
});
|
||||
|
||||
const child = await repo.create({
|
||||
name: "category",
|
||||
slug: "web",
|
||||
label: "Web Development",
|
||||
parentId: parent.id,
|
||||
});
|
||||
|
||||
expect(child.parentId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("should create a term with data", async () => {
|
||||
const term = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
data: { description: "All things tech", color: "#0066cc" },
|
||||
});
|
||||
|
||||
expect(term.data).toEqual({
|
||||
description: "All things tech",
|
||||
color: "#0066cc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findById", () => {
|
||||
it("should find term by ID", async () => {
|
||||
const created = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found?.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("should return null for non-existent ID", async () => {
|
||||
const found = await repo.findById("non-existent");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findBySlug", () => {
|
||||
it("should find term by name and slug", async () => {
|
||||
await repo.create({
|
||||
name: "tags",
|
||||
slug: "javascript",
|
||||
label: "JavaScript",
|
||||
});
|
||||
|
||||
const found = await repo.findBySlug("tags", "javascript");
|
||||
|
||||
expect(found).not.toBeNull();
|
||||
expect(found?.label).toBe("JavaScript");
|
||||
});
|
||||
|
||||
it("should not find term with wrong name", async () => {
|
||||
await repo.create({
|
||||
name: "tags",
|
||||
slug: "javascript",
|
||||
label: "JavaScript",
|
||||
});
|
||||
|
||||
// Same slug, different name
|
||||
const found = await repo.findBySlug("category", "javascript");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for non-existent slug", async () => {
|
||||
const found = await repo.findBySlug("tags", "non-existent");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should find all terms for a taxonomy", async () => {
|
||||
await repo.create({ name: "tags", slug: "js", label: "JavaScript" });
|
||||
await repo.create({ name: "tags", slug: "ts", label: "TypeScript" });
|
||||
await repo.create({ name: "category", slug: "tech", label: "Tech" });
|
||||
|
||||
const tags = await repo.findByName("tags");
|
||||
|
||||
expect(tags).toHaveLength(2);
|
||||
expect(tags.map((t) => t.slug)).toContain("js");
|
||||
expect(tags.map((t) => t.slug)).toContain("ts");
|
||||
});
|
||||
|
||||
it("should filter by parentId", async () => {
|
||||
const parent = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
});
|
||||
await repo.create({
|
||||
name: "category",
|
||||
slug: "web",
|
||||
label: "Web",
|
||||
parentId: parent.id,
|
||||
});
|
||||
await repo.create({
|
||||
name: "category",
|
||||
slug: "mobile",
|
||||
label: "Mobile",
|
||||
parentId: parent.id,
|
||||
});
|
||||
await repo.create({
|
||||
name: "category",
|
||||
slug: "design",
|
||||
label: "Design",
|
||||
});
|
||||
|
||||
const children = await repo.findByName("category", {
|
||||
parentId: parent.id,
|
||||
});
|
||||
expect(children).toHaveLength(2);
|
||||
|
||||
const roots = await repo.findByName("category", { parentId: null });
|
||||
expect(roots).toHaveLength(2); // tech and design
|
||||
});
|
||||
|
||||
it("should return terms ordered by label", async () => {
|
||||
await repo.create({ name: "tags", slug: "z", label: "Zebra" });
|
||||
await repo.create({ name: "tags", slug: "a", label: "Apple" });
|
||||
await repo.create({ name: "tags", slug: "m", label: "Mango" });
|
||||
|
||||
const tags = await repo.findByName("tags");
|
||||
|
||||
expect(tags[0].label).toBe("Apple");
|
||||
expect(tags[1].label).toBe("Mango");
|
||||
expect(tags[2].label).toBe("Zebra");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findChildren", () => {
|
||||
it("should find children of a term", async () => {
|
||||
const parent = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Technology",
|
||||
});
|
||||
await repo.create({
|
||||
name: "category",
|
||||
slug: "web",
|
||||
label: "Web",
|
||||
parentId: parent.id,
|
||||
});
|
||||
await repo.create({
|
||||
name: "category",
|
||||
slug: "mobile",
|
||||
label: "Mobile",
|
||||
parentId: parent.id,
|
||||
});
|
||||
|
||||
const children = await repo.findChildren(parent.id);
|
||||
|
||||
expect(children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return empty array for term with no children", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
const children = await repo.findChildren(term.id);
|
||||
expect(children).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update term label", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
|
||||
const updated = await repo.update(term.id, { label: "JS" });
|
||||
|
||||
expect(updated?.label).toBe("JS");
|
||||
expect(updated?.slug).toBe("js"); // unchanged
|
||||
});
|
||||
|
||||
it("should update term slug", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
|
||||
const updated = await repo.update(term.id, { slug: "javascript" });
|
||||
|
||||
expect(updated?.slug).toBe("javascript");
|
||||
});
|
||||
|
||||
it("should update parentId", async () => {
|
||||
const parent = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
});
|
||||
const orphan = await repo.create({
|
||||
name: "category",
|
||||
slug: "web",
|
||||
label: "Web",
|
||||
});
|
||||
|
||||
const updated = await repo.update(orphan.id, { parentId: parent.id });
|
||||
|
||||
expect(updated?.parentId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("should clear parentId when set to null", async () => {
|
||||
const parent = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
});
|
||||
const child = await repo.create({
|
||||
name: "category",
|
||||
slug: "web",
|
||||
label: "Web",
|
||||
parentId: parent.id,
|
||||
});
|
||||
|
||||
const updated = await repo.update(child.id, { parentId: null });
|
||||
|
||||
expect(updated?.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it("should update data", async () => {
|
||||
const term = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
data: { color: "blue" },
|
||||
});
|
||||
|
||||
const updated = await repo.update(term.id, {
|
||||
data: { color: "red", icon: "star" },
|
||||
});
|
||||
|
||||
expect(updated?.data).toEqual({ color: "red", icon: "star" });
|
||||
});
|
||||
|
||||
it("should return null for non-existent term", async () => {
|
||||
const updated = await repo.update("non-existent", { label: "Test" });
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete a term", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
const deleted = await repo.delete(term.id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(await repo.findById(term.id)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return false for non-existent term", async () => {
|
||||
const deleted = await repo.delete("non-existent");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("should remove content associations when deleted", async () => {
|
||||
// Setup: need a collection with content
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
repo = new TaxonomyRepository(db);
|
||||
const contentRepo = new ContentRepository(db);
|
||||
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
const content = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "test-post",
|
||||
data: { title: "Test" },
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", content.id, term.id);
|
||||
|
||||
// Verify attached
|
||||
const termsBefore = await repo.getTermsForEntry("post", content.id);
|
||||
expect(termsBefore).toHaveLength(1);
|
||||
|
||||
// Delete term
|
||||
await repo.delete(term.id);
|
||||
|
||||
// Verify association removed
|
||||
const termsAfter = await repo.getTermsForEntry("post", content.id);
|
||||
expect(termsAfter).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content-taxonomy junction", () => {
|
||||
let contentRepo: ContentRepository;
|
||||
let contentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Need collections for content
|
||||
db = await setupTestDatabaseWithCollections();
|
||||
repo = new TaxonomyRepository(db);
|
||||
contentRepo = new ContentRepository(db);
|
||||
|
||||
const content = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "test-post",
|
||||
data: { title: "Test Post" },
|
||||
});
|
||||
contentId = content.id;
|
||||
});
|
||||
|
||||
describe("attachToEntry", () => {
|
||||
it("should attach a term to content", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, term.id);
|
||||
|
||||
const terms = await repo.getTermsForEntry("post", contentId);
|
||||
expect(terms).toHaveLength(1);
|
||||
expect(terms[0].id).toBe(term.id);
|
||||
});
|
||||
|
||||
it("should be idempotent (no duplicate attachments)", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, term.id);
|
||||
await repo.attachToEntry("post", contentId, term.id);
|
||||
await repo.attachToEntry("post", contentId, term.id);
|
||||
|
||||
const terms = await repo.getTermsForEntry("post", contentId);
|
||||
expect(terms).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detachFromEntry", () => {
|
||||
it("should detach a term from content", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, term.id);
|
||||
await repo.detachFromEntry("post", contentId, term.id);
|
||||
|
||||
const terms = await repo.getTermsForEntry("post", contentId);
|
||||
expect(terms).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not throw when detaching non-attached term", async () => {
|
||||
const term = await repo.create({
|
||||
name: "tags",
|
||||
slug: "test",
|
||||
label: "Test",
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await expect(repo.detachFromEntry("post", contentId, term.id)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTermsForEntry", () => {
|
||||
it("should get all terms for an entry", async () => {
|
||||
const tag1 = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
const tag2 = await repo.create({
|
||||
name: "tags",
|
||||
slug: "ts",
|
||||
label: "TypeScript",
|
||||
});
|
||||
const cat = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, tag1.id);
|
||||
await repo.attachToEntry("post", contentId, tag2.id);
|
||||
await repo.attachToEntry("post", contentId, cat.id);
|
||||
|
||||
const allTerms = await repo.getTermsForEntry("post", contentId);
|
||||
expect(allTerms).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should filter by taxonomy name", async () => {
|
||||
const tag = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
const cat = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, tag.id);
|
||||
await repo.attachToEntry("post", contentId, cat.id);
|
||||
|
||||
const tags = await repo.getTermsForEntry("post", contentId, "tags");
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0].slug).toBe("js");
|
||||
|
||||
const categories = await repo.getTermsForEntry("post", contentId, "category");
|
||||
expect(categories).toHaveLength(1);
|
||||
expect(categories[0].slug).toBe("tech");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTermsForEntry", () => {
|
||||
it("should replace all terms for a taxonomy", async () => {
|
||||
const tag1 = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
const tag2 = await repo.create({
|
||||
name: "tags",
|
||||
slug: "ts",
|
||||
label: "TypeScript",
|
||||
});
|
||||
const tag3 = await repo.create({
|
||||
name: "tags",
|
||||
slug: "rust",
|
||||
label: "Rust",
|
||||
});
|
||||
|
||||
// Initial state: js and ts
|
||||
await repo.attachToEntry("post", contentId, tag1.id);
|
||||
await repo.attachToEntry("post", contentId, tag2.id);
|
||||
|
||||
// Set to: ts and rust (removes js, keeps ts, adds rust)
|
||||
await repo.setTermsForEntry("post", contentId, "tags", [tag2.id, tag3.id]);
|
||||
|
||||
const terms = await repo.getTermsForEntry("post", contentId, "tags");
|
||||
expect(terms).toHaveLength(2);
|
||||
expect(terms.map((t) => t.slug).toSorted()).toEqual(["rust", "ts"]);
|
||||
});
|
||||
|
||||
it("should not affect other taxonomies", async () => {
|
||||
const tag = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
const cat = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, tag.id);
|
||||
await repo.attachToEntry("post", contentId, cat.id);
|
||||
|
||||
// Clear tags but keep categories
|
||||
await repo.setTermsForEntry("post", contentId, "tags", []);
|
||||
|
||||
const tags = await repo.getTermsForEntry("post", contentId, "tags");
|
||||
expect(tags).toHaveLength(0);
|
||||
|
||||
const categories = await repo.getTermsForEntry("post", contentId, "category");
|
||||
expect(categories).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearEntryTerms", () => {
|
||||
it("should remove all terms from an entry", async () => {
|
||||
const tag = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
const cat = await repo.create({
|
||||
name: "category",
|
||||
slug: "tech",
|
||||
label: "Tech",
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, tag.id);
|
||||
await repo.attachToEntry("post", contentId, cat.id);
|
||||
|
||||
const count = await repo.clearEntryTerms("post", contentId);
|
||||
|
||||
expect(count).toBe(2);
|
||||
|
||||
const terms = await repo.getTermsForEntry("post", contentId);
|
||||
expect(terms).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countEntriesWithTerm", () => {
|
||||
it("should count entries with a term", async () => {
|
||||
const tag = await repo.create({
|
||||
name: "tags",
|
||||
slug: "js",
|
||||
label: "JavaScript",
|
||||
});
|
||||
|
||||
// Create more posts
|
||||
const post2 = await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "post-2",
|
||||
data: { title: "Post 2" },
|
||||
});
|
||||
await contentRepo.create({
|
||||
type: "post",
|
||||
slug: "post-3",
|
||||
data: { title: "Post 3" },
|
||||
});
|
||||
|
||||
await repo.attachToEntry("post", contentId, tag.id);
|
||||
await repo.attachToEntry("post", post2.id, tag.id);
|
||||
// post3 doesn't have the tag
|
||||
|
||||
const count = await repo.countEntriesWithTerm(tag.id);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 0 for unused term", async () => {
|
||||
const tag = await repo.create({
|
||||
name: "tags",
|
||||
slug: "unused",
|
||||
label: "Unused",
|
||||
});
|
||||
|
||||
const count = await repo.countEntriesWithTerm(tag.id);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user