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:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -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);
});
});

View 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);
});
});
});
});