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,518 @@
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultCommentModerate } from "../../../src/comments/moderator.js";
import {
createComment,
moderateComment,
type CommentHookRunner,
} from "../../../src/comments/service.js";
import type { Database } from "../../../src/database/types.js";
import { definePlugin } from "../../../src/plugins/define-plugin.js";
import { createHookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js";
import type {
CollectionCommentSettings,
CommentBeforeCreateEvent,
CommentModerateEvent,
ModerationDecision,
PluginContext,
} from "../../../src/plugins/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function defaultSettings(
overrides: Partial<CollectionCommentSettings> = {},
): CollectionCommentSettings {
return {
commentsEnabled: true,
commentsModeration: "first_time",
commentsClosedAfterDays: 90,
commentsAutoApproveUsers: true,
...overrides,
};
}
const defaultInput = {
collection: "post",
contentId: "content-1",
authorName: "Jane",
authorEmail: "jane@example.com",
body: "Great post!",
};
// ---------------------------------------------------------------------------
// Group 1: Service with mocked CommentHookRunner
// ---------------------------------------------------------------------------
describe("Comment Service with CommentHookRunner", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
function makeHookRunner(overrides: Partial<CommentHookRunner> = {}): CommentHookRunner {
return {
runBeforeCreate: vi.fn(async (event: CommentBeforeCreateEvent) => event),
runModerate: vi.fn(async () => ({
status: "approved" as const,
reason: "Test",
})),
fireAfterCreate: vi.fn(),
fireAfterModerate: vi.fn(),
...overrides,
};
}
it("creates comment with status from runModerate", async () => {
const hooks = makeHookRunner({
runModerate: vi.fn(async () => ({ status: "pending" as const, reason: "Held" })),
});
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
expect(result).not.toBeNull();
expect(result!.comment.status).toBe("pending");
expect(result!.decision.status).toBe("pending");
});
it("transforms comment data via beforeCreate", async () => {
const hooks = makeHookRunner({
runBeforeCreate: vi.fn(async (event: CommentBeforeCreateEvent) => ({
...event,
comment: { ...event.comment, body: "Modified body" },
})),
});
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
expect(result).not.toBeNull();
expect(result!.comment.body).toBe("Modified body");
});
it("returns null when beforeCreate returns false (rejected)", async () => {
const hooks = makeHookRunner({
runBeforeCreate: vi.fn(async () => false as const),
});
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
expect(result).toBeNull();
});
it("saves as spam when runModerate returns spam", async () => {
const hooks = makeHookRunner({
runModerate: vi.fn(async () => ({ status: "spam" as const, reason: "Spam detected" })),
});
const result = await createComment(db, defaultInput, defaultSettings(), hooks);
expect(result).not.toBeNull();
expect(result!.comment.status).toBe("spam");
});
it("fires fireAfterCreate with correct shape", async () => {
const hooks = makeHookRunner();
await createComment(db, defaultInput, defaultSettings(), hooks, {
id: "content-1",
collection: "post",
slug: "my-post",
title: "My Post",
});
expect(hooks.fireAfterCreate).toHaveBeenCalledOnce();
const event = (hooks.fireAfterCreate as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(event.comment.collection).toBe("post");
expect(event.comment.contentId).toBe("content-1");
expect(event.content.slug).toBe("my-post");
});
it("moderateComment updates status and fires fireAfterModerate", async () => {
const hooks = makeHookRunner();
const created = await createComment(db, defaultInput, defaultSettings(), hooks);
const updated = await moderateComment(
db,
created!.comment.id,
"spam",
{ id: "admin-1", name: "Admin" },
hooks,
);
expect(updated).not.toBeNull();
expect(updated!.status).toBe("spam");
expect(hooks.fireAfterModerate).toHaveBeenCalledOnce();
const event = (hooks.fireAfterModerate as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(event.previousStatus).toBe("approved");
expect(event.newStatus).toBe("spam");
expect(event.moderator.id).toBe("admin-1");
});
it("moderateComment returns null for non-existent id", async () => {
const hooks = makeHookRunner();
const result = await moderateComment(
db,
"nonexistent",
"approved",
{ id: "admin-1", name: "Admin" },
hooks,
);
expect(result).toBeNull();
expect(hooks.fireAfterModerate).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Group 2: Built-in moderator unit tests
// ---------------------------------------------------------------------------
describe("Built-in Default Comment Moderator", () => {
const ctx = {} as PluginContext;
function makeModerateEvent(overrides: Partial<CommentModerateEvent> = {}): CommentModerateEvent {
return {
comment: {
collection: "post",
contentId: "c1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: null,
body: "Hello",
ipHash: null,
userAgent: null,
},
metadata: {},
collectionSettings: defaultSettings(),
priorApprovedCount: 0,
...overrides,
};
}
it("auto-approves authenticated CMS users when configured", async () => {
const decision = await defaultCommentModerate(
makeModerateEvent({
comment: {
...makeModerateEvent().comment,
authorUserId: "user-1",
},
collectionSettings: defaultSettings({ commentsAutoApproveUsers: true }),
}),
ctx,
);
expect(decision.status).toBe("approved");
expect(decision.reason).toContain("Authenticated");
});
it("does not auto-approve when commentsAutoApproveUsers is false", async () => {
const decision = await defaultCommentModerate(
makeModerateEvent({
comment: {
...makeModerateEvent().comment,
authorUserId: "user-1",
},
collectionSettings: defaultSettings({
commentsAutoApproveUsers: false,
commentsModeration: "all",
}),
}),
ctx,
);
expect(decision.status).toBe("pending");
});
it("approves when moderation is 'none'", async () => {
const decision = await defaultCommentModerate(
makeModerateEvent({
collectionSettings: defaultSettings({ commentsModeration: "none" }),
}),
ctx,
);
expect(decision.status).toBe("approved");
expect(decision.reason).toContain("disabled");
});
it("approves returning commenter with first_time moderation", async () => {
const decision = await defaultCommentModerate(
makeModerateEvent({
collectionSettings: defaultSettings({ commentsModeration: "first_time" }),
priorApprovedCount: 3,
}),
ctx,
);
expect(decision.status).toBe("approved");
expect(decision.reason).toContain("Returning");
});
it("holds new commenter with first_time moderation", async () => {
const decision = await defaultCommentModerate(
makeModerateEvent({
collectionSettings: defaultSettings({ commentsModeration: "first_time" }),
priorApprovedCount: 0,
}),
ctx,
);
expect(decision.status).toBe("pending");
});
it("holds all comments when moderation is 'all'", async () => {
const decision = await defaultCommentModerate(
makeModerateEvent({
collectionSettings: defaultSettings({ commentsModeration: "all" }),
priorApprovedCount: 10,
}),
ctx,
);
expect(decision.status).toBe("pending");
});
});
// ---------------------------------------------------------------------------
// Group 3: Real HookPipeline integration
// ---------------------------------------------------------------------------
describe("Comment Hooks with HookPipeline", () => {
let pipelineDb: Kysely<Database>;
beforeEach(async () => {
pipelineDb = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(pipelineDb);
});
it("invokes comment:beforeCreate handler registered via definePlugin", async () => {
const spy = vi.fn(async (event: CommentBeforeCreateEvent) => ({
...event,
metadata: { ...event.metadata, enriched: true },
}));
const plugin = definePlugin({
id: "test-enricher",
version: "1.0.0",
capabilities: ["users:read"],
hooks: {
"comment:beforeCreate": spy,
},
});
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
const event: CommentBeforeCreateEvent = {
comment: {
collection: "post",
contentId: "c1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: null,
body: "Hello",
ipHash: null,
userAgent: null,
},
metadata: {},
};
const result = await pipeline.runCommentBeforeCreate(event);
expect(spy).toHaveBeenCalledOnce();
expect(result).not.toBe(false);
expect((result as CommentBeforeCreateEvent).metadata.enriched).toBe(true);
});
it("invokes exclusive comment:moderate plugin and returns decision", async () => {
const moderateHandler = vi.fn(async () => ({
status: "spam" as const,
reason: "Custom moderator",
}));
const plugin = definePlugin({
id: "test-moderator",
version: "1.0.0",
capabilities: ["users:read"],
hooks: {
"comment:moderate": {
exclusive: true,
handler: moderateHandler,
},
},
});
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
// Auto-select the sole provider
await resolveExclusiveHooks({
pipeline,
isActive: () => true,
getOption: async () => null,
setOption: async () => {},
deleteOption: async () => {},
});
const moderateEvent: CommentModerateEvent = {
comment: {
collection: "post",
contentId: "c1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: null,
body: "Buy cheap pills",
ipHash: null,
userAgent: null,
},
metadata: {},
collectionSettings: defaultSettings(),
priorApprovedCount: 0,
};
const result = await pipeline.invokeExclusiveHook("comment:moderate", moderateEvent);
expect(result).not.toBeNull();
expect((result!.result as ModerationDecision).status).toBe("spam");
expect(moderateHandler).toHaveBeenCalledOnce();
});
it("built-in moderator is auto-selected when sole provider", async () => {
const { DEFAULT_COMMENT_MODERATOR_PLUGIN_ID } =
await import("../../../src/comments/moderator.js");
const plugin = definePlugin({
id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
version: "0.0.0",
capabilities: ["users:read"],
hooks: {
"comment:moderate": {
exclusive: true,
handler: defaultCommentModerate,
},
},
});
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
await resolveExclusiveHooks({
pipeline,
isActive: () => true,
getOption: async () => null,
setOption: async () => {},
deleteOption: async () => {},
});
const selection = pipeline.getExclusiveSelection("comment:moderate");
expect(selection).toBe(DEFAULT_COMMENT_MODERATOR_PLUGIN_ID);
// Verify it actually works
const moderateEvent: CommentModerateEvent = {
comment: {
collection: "post",
contentId: "c1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: null,
body: "Hello",
ipHash: null,
userAgent: null,
},
metadata: {},
collectionSettings: defaultSettings({ commentsModeration: "none" }),
priorApprovedCount: 0,
};
const result = await pipeline.invokeExclusiveHook("comment:moderate", moderateEvent);
expect(result).not.toBeNull();
expect((result!.result as ModerationDecision).status).toBe("approved");
});
it("fires comment:afterCreate handlers", async () => {
const spy = vi.fn(async () => {});
const plugin = definePlugin({
id: "test-after-create",
version: "1.0.0",
capabilities: ["users:read"],
hooks: {
"comment:afterCreate": spy,
},
});
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
await pipeline.runCommentAfterCreate({
comment: {
id: "c1",
collection: "post",
contentId: "content-1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: null,
body: "Hello",
status: "approved",
moderationMetadata: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
metadata: {},
content: { id: "content-1", collection: "post", slug: "my-post" },
});
expect(spy).toHaveBeenCalledOnce();
});
it("fires comment:afterModerate handlers", async () => {
const spy = vi.fn(async () => {});
const plugin = definePlugin({
id: "test-after-moderate",
version: "1.0.0",
capabilities: ["users:read"],
hooks: {
"comment:afterModerate": spy,
},
});
const pipeline = createHookPipeline([plugin], { db: pipelineDb });
await pipeline.runCommentAfterModerate({
comment: {
id: "c1",
collection: "post",
contentId: "content-1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: null,
body: "Hello",
status: "approved",
moderationMetadata: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
previousStatus: "pending",
newStatus: "approved",
moderator: { id: "admin-1", name: "Admin" },
});
expect(spy).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,318 @@
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildCommentNotificationEmail,
lookupContentAuthor,
sendCommentNotification,
} from "../../../src/comments/notifications.js";
import type { Database } from "../../../src/database/types.js";
import type { EmailPipeline } from "../../../src/plugins/email.js";
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
describe("Comment Notifications", () => {
describe("buildCommentNotificationEmail", () => {
it("builds email with content title", () => {
const email = buildCommentNotificationEmail("author@example.com", {
commentAuthorName: "Jane",
commentBody: "Great post!",
contentTitle: "My Blog Post",
collection: "post",
adminBaseUrl: "https://example.com/_emdash",
});
expect(email.to).toBe("author@example.com");
expect(email.subject).toBe('New comment on "My Blog Post"');
expect(email.text).toContain("Jane");
expect(email.text).toContain("Great post!");
expect(email.text).toContain("/_emdash/admin/comments");
expect(email.html).toContain("Jane");
expect(email.html).toContain("Great post!");
});
it("falls back to collection name when no title", () => {
const email = buildCommentNotificationEmail("author@example.com", {
commentAuthorName: "Jane",
commentBody: "Nice!",
contentTitle: "",
collection: "post",
adminBaseUrl: "https://example.com/_emdash",
});
expect(email.subject).toBe('New comment on "post item"');
});
it("truncates long comment bodies", () => {
const longBody = "x".repeat(600);
const email = buildCommentNotificationEmail("author@example.com", {
commentAuthorName: "Jane",
commentBody: longBody,
contentTitle: "Post",
collection: "post",
adminBaseUrl: "https://example.com/_emdash",
});
expect(email.text).toContain("...");
expect(email.text).not.toContain("x".repeat(600));
});
it("escapes HTML in author name and body", () => {
const email = buildCommentNotificationEmail("author@example.com", {
commentAuthorName: '<script>alert("xss")</script>',
commentBody: "<img src=x onerror=alert(1)>",
contentTitle: "Post",
collection: "post",
adminBaseUrl: "https://example.com/_emdash",
});
expect(email.html).not.toContain("<script>");
expect(email.html).not.toContain("<img src=x");
expect(email.html).toContain("&lt;script&gt;");
});
it("strips CRLF from subject to prevent header injection", () => {
const email = buildCommentNotificationEmail("author@example.com", {
commentAuthorName: "Jane",
commentBody: "Nice!",
contentTitle: "Post\r\nBcc: attacker@evil.com",
collection: "post",
adminBaseUrl: "https://example.com/_emdash",
});
expect(email.subject).not.toContain("\r");
expect(email.subject).not.toContain("\n");
expect(email.subject).toContain("Post");
});
});
describe("sendCommentNotification", () => {
let mockEmail: EmailPipeline;
let sendSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
sendSpy = vi.fn().mockResolvedValue(undefined);
mockEmail = {
send: sendSpy,
isAvailable: () => true,
} as unknown as EmailPipeline;
});
it("sends notification for approved comments", async () => {
const sent = await sendCommentNotification({
email: mockEmail,
comment: {
authorName: "Jane",
authorEmail: "jane@example.com",
body: "Great post!",
status: "approved",
collection: "post",
},
contentTitle: "My Post",
contentAuthor: { email: "author@example.com", name: "Author" },
adminBaseUrl: "https://example.com/_emdash",
});
expect(sent).toBe(true);
expect(sendSpy).toHaveBeenCalledOnce();
const [message, source] = sendSpy.mock.calls[0]!;
expect(message.to).toBe("author@example.com");
expect(message.subject).toContain("My Post");
expect(source).toBe("emdash-comments");
});
it("skips pending comments", async () => {
const sent = await sendCommentNotification({
email: mockEmail,
comment: {
authorName: "Jane",
authorEmail: "jane@example.com",
body: "Great post!",
status: "pending",
collection: "post",
},
contentAuthor: { email: "author@example.com", name: "Author" },
adminBaseUrl: "https://example.com/_emdash",
});
expect(sent).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
it("skips when no content author", async () => {
const sent = await sendCommentNotification({
email: mockEmail,
comment: {
authorName: "Jane",
authorEmail: "jane@example.com",
body: "Great post!",
status: "approved",
collection: "post",
},
contentAuthor: undefined,
adminBaseUrl: "https://example.com/_emdash",
});
expect(sent).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
it("skips when email provider not available", async () => {
mockEmail = {
send: sendSpy,
isAvailable: () => false,
} as unknown as EmailPipeline;
const sent = await sendCommentNotification({
email: mockEmail,
comment: {
authorName: "Jane",
authorEmail: "jane@example.com",
body: "Great post!",
status: "approved",
collection: "post",
},
contentAuthor: { email: "author@example.com", name: "Author" },
adminBaseUrl: "https://example.com/_emdash",
});
expect(sent).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
it("skips when commenter is the content author", async () => {
const sent = await sendCommentNotification({
email: mockEmail,
comment: {
authorName: "Author",
authorEmail: "author@example.com",
body: "My own comment",
status: "approved",
collection: "post",
},
contentAuthor: { email: "author@example.com", name: "Author" },
adminBaseUrl: "https://example.com/_emdash",
});
expect(sent).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
it("compares emails case-insensitively for self-comment check", async () => {
const sent = await sendCommentNotification({
email: mockEmail,
comment: {
authorName: "Author",
authorEmail: "Author@Example.COM",
body: "My own comment",
status: "approved",
collection: "post",
},
contentAuthor: { email: "author@example.com", name: "Author" },
adminBaseUrl: "https://example.com/_emdash",
});
expect(sent).toBe(false);
expect(sendSpy).not.toHaveBeenCalled();
});
});
describe("lookupContentAuthor", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabaseWithCollections();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
it("returns null for non-existent content", async () => {
const result = await lookupContentAuthor(db, "post", "nonexistent");
expect(result).toBeNull();
});
it("returns slug and author for content with author", async () => {
await db
.insertInto("users")
.values({
id: "user1",
email: "author@example.com",
name: "Author Name",
role: 50,
email_verified: 1,
})
.execute();
await db
.insertInto("ec_post" as never)
.values({
id: "post1",
slug: "my-post",
status: "published",
author_id: "user1",
} as never)
.execute();
const result = await lookupContentAuthor(db, "post", "post1");
expect(result).not.toBeNull();
expect(result!.slug).toBe("my-post");
expect(result!.author).toEqual({
id: "user1",
email: "author@example.com",
name: "Author Name",
});
});
it("excludes author with unverified email", async () => {
await db
.insertInto("users")
.values({
id: "unverified1",
email: "unverified@example.com",
name: "Unverified",
role: 50,
email_verified: 0,
})
.execute();
await db
.insertInto("ec_post" as never)
.values({
id: "post3",
slug: "unverified-post",
status: "published",
author_id: "unverified1",
} as never)
.execute();
const result = await lookupContentAuthor(db, "post", "post3");
expect(result).not.toBeNull();
expect(result!.slug).toBe("unverified-post");
expect(result!.author).toBeUndefined();
});
it("rejects invalid collection names", async () => {
await expect(lookupContentAuthor(db, "'; DROP TABLE users; --", "post1")).rejects.toThrow(
"collection",
);
});
it("returns slug without author for content without author_id", async () => {
await db
.insertInto("ec_post" as never)
.values({
id: "post2",
slug: "orphan-post",
status: "published",
author_id: null,
} as never)
.execute();
const result = await lookupContentAuthor(db, "post", "post2");
expect(result).not.toBeNull();
expect(result!.slug).toBe("orphan-post");
expect(result!.author).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,412 @@
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { CommentRepository, type Comment } from "../../../src/database/repositories/comment.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
describe("CommentRepository", () => {
let db: Kysely<Database>;
let repo: CommentRepository;
beforeEach(async () => {
db = await setupTestDatabase();
repo = new CommentRepository(db);
});
afterEach(async () => {
await teardownTestDatabase(db);
});
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
function makeInput(overrides: Partial<Parameters<CommentRepository["create"]>[0]> = {}) {
return {
collection: "post",
contentId: "content-1",
authorName: "Jane",
authorEmail: "jane@example.com",
body: "Great post!",
...overrides,
};
}
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
describe("CRUD", () => {
it("creates a comment and returns it with id and timestamps", async () => {
const comment = await repo.create(makeInput());
expect(comment.id).toBeTruthy();
expect(comment.collection).toBe("post");
expect(comment.contentId).toBe("content-1");
expect(comment.authorName).toBe("Jane");
expect(comment.authorEmail).toBe("jane@example.com");
expect(comment.body).toBe("Great post!");
expect(comment.status).toBe("pending");
expect(comment.createdAt).toBeTruthy();
expect(comment.updatedAt).toBeTruthy();
expect(comment.parentId).toBeNull();
});
it("findById returns the comment", async () => {
const created = await repo.create(makeInput());
const found = await repo.findById(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.authorName).toBe("Jane");
});
it("findById returns null for non-existent id", async () => {
const found = await repo.findById("nonexistent");
expect(found).toBeNull();
});
it("findByContent returns matching comments", async () => {
await repo.create(makeInput());
await repo.create(makeInput({ body: "Second comment" }));
await repo.create(makeInput({ contentId: "other-content" }));
const result = await repo.findByContent("post", "content-1");
expect(result.items).toHaveLength(2);
expect(result.items.every((c) => c.contentId === "content-1")).toBe(true);
});
it("findByStatus filters by status", async () => {
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "pending" }));
await repo.create(makeInput({ status: "spam" }));
const result = await repo.findByStatus("approved");
expect(result.items).toHaveLength(1);
expect(result.items[0]!.status).toBe("approved");
});
});
// -------------------------------------------------------------------------
// Status transitions
// -------------------------------------------------------------------------
describe("Status transitions", () => {
it("updateStatus changes status", async () => {
const created = await repo.create(makeInput());
const updated = await repo.updateStatus(created.id, "approved");
expect(updated).not.toBeNull();
expect(updated!.status).toBe("approved");
expect(updated!.id).toBe(created.id);
});
it("bulkUpdateStatus returns count of updated rows", async () => {
const c1 = await repo.create(makeInput());
const c2 = await repo.create(makeInput({ body: "Second" }));
const count = await repo.bulkUpdateStatus([c1.id, c2.id], "approved");
expect(count).toBe(2);
const found1 = await repo.findById(c1.id);
const found2 = await repo.findById(c2.id);
expect(found1!.status).toBe("approved");
expect(found2!.status).toBe("approved");
});
it("bulkUpdateStatus returns 0 for empty array", async () => {
const count = await repo.bulkUpdateStatus([], "approved");
expect(count).toBe(0);
});
});
// -------------------------------------------------------------------------
// Deletion
// -------------------------------------------------------------------------
describe("Deletion", () => {
it("delete hard-deletes and returns true", async () => {
const created = await repo.create(makeInput());
const deleted = await repo.delete(created.id);
expect(deleted).toBe(true);
expect(await repo.findById(created.id)).toBeNull();
});
it("delete returns false for non-existent id", async () => {
const deleted = await repo.delete("nonexistent");
expect(deleted).toBe(false);
});
it("bulkDelete returns count", async () => {
const c1 = await repo.create(makeInput());
const c2 = await repo.create(makeInput({ body: "Second" }));
const count = await repo.bulkDelete([c1.id, c2.id]);
expect(count).toBe(2);
});
it("bulkDelete returns 0 for empty array", async () => {
const count = await repo.bulkDelete([]);
expect(count).toBe(0);
});
it("deleteByContent removes all comments for content", async () => {
await repo.create(makeInput());
await repo.create(makeInput({ body: "Second" }));
await repo.create(makeInput({ contentId: "other-content" }));
const count = await repo.deleteByContent("post", "content-1");
expect(count).toBe(2);
const remaining = await repo.findByContent("post", "content-1");
expect(remaining.items).toHaveLength(0);
const other = await repo.findByContent("post", "other-content");
expect(other.items).toHaveLength(1);
});
it("parent FK cascade deletes replies", async () => {
const parent = await repo.create(makeInput());
const reply = await repo.create(makeInput({ parentId: parent.id, body: "Reply" }));
await repo.delete(parent.id);
expect(await repo.findById(parent.id)).toBeNull();
expect(await repo.findById(reply.id)).toBeNull();
});
});
// -------------------------------------------------------------------------
// Counting
// -------------------------------------------------------------------------
describe("Counting", () => {
it("countByContent with and without status filter", async () => {
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "pending" }));
await repo.create(makeInput({ status: "approved" }));
const total = await repo.countByContent("post", "content-1");
expect(total).toBe(3);
const approved = await repo.countByContent("post", "content-1", "approved");
expect(approved).toBe(2);
const pending = await repo.countByContent("post", "content-1", "pending");
expect(pending).toBe(1);
});
it("countByStatus returns grouped counts", async () => {
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "pending" }));
await repo.create(makeInput({ status: "spam" }));
const counts = await repo.countByStatus();
expect(counts.approved).toBe(2);
expect(counts.pending).toBe(1);
expect(counts.spam).toBe(1);
expect(counts.trash).toBe(0);
});
it("countApprovedByEmail counts only approved comments", async () => {
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "pending" }));
const count = await repo.countApprovedByEmail("jane@example.com");
expect(count).toBe(2);
});
});
// -------------------------------------------------------------------------
// Cursor pagination
// -------------------------------------------------------------------------
describe("Cursor pagination", () => {
it("findByContent paginates with cursor", async () => {
// Create 5 comments
for (let i = 0; i < 5; i++) {
await repo.create(makeInput({ body: `Comment ${i}` }));
}
const page1 = await repo.findByContent("post", "content-1", { limit: 2 });
expect(page1.items).toHaveLength(2);
expect(page1.nextCursor).toBeTruthy();
const page2 = await repo.findByContent("post", "content-1", {
limit: 2,
cursor: page1.nextCursor,
});
expect(page2.items).toHaveLength(2);
expect(page2.nextCursor).toBeTruthy();
const page3 = await repo.findByContent("post", "content-1", {
limit: 2,
cursor: page2.nextCursor,
});
expect(page3.items).toHaveLength(1);
expect(page3.nextCursor).toBeUndefined();
// Ensure no duplicates across pages
const allIds = [...page1.items, ...page2.items, ...page3.items].map((c) => c.id);
expect(new Set(allIds).size).toBe(5);
});
it("findByStatus paginates with cursor", async () => {
for (let i = 0; i < 4; i++) {
await repo.create(makeInput({ status: "approved", body: `Comment ${i}` }));
}
const page1 = await repo.findByStatus("approved", { limit: 2 });
expect(page1.items).toHaveLength(2);
expect(page1.nextCursor).toBeTruthy();
const page2 = await repo.findByStatus("approved", {
limit: 2,
cursor: page1.nextCursor,
});
expect(page2.items).toHaveLength(2);
expect(page2.nextCursor).toBeUndefined();
});
});
// -------------------------------------------------------------------------
// Threading
// -------------------------------------------------------------------------
describe("Threading", () => {
it("assembleThreads produces 1-level nesting", () => {
const root: Comment = {
id: "root",
collection: "post",
contentId: "c1",
parentId: null,
authorName: "A",
authorEmail: "a@test.com",
authorUserId: null,
body: "Root",
status: "approved",
ipHash: null,
userAgent: null,
moderationMetadata: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const reply: Comment = {
...root,
id: "reply1",
parentId: "root",
body: "Reply",
};
const threads = CommentRepository.assembleThreads([root, reply]);
expect(threads).toHaveLength(1);
expect((threads[0] as Comment & { _replies?: Comment[] })._replies).toHaveLength(1);
});
it("toPublicComment strips private fields", () => {
const comment: Comment & { _replies?: Comment[] } = {
id: "c1",
collection: "post",
contentId: "content-1",
parentId: null,
authorName: "Jane",
authorEmail: "jane@example.com",
authorUserId: "user-1",
body: "Great!",
status: "approved",
ipHash: "abc123",
userAgent: "Mozilla/5.0",
moderationMetadata: { score: 0.9 },
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const pub = CommentRepository.toPublicComment(comment);
expect(pub.id).toBe("c1");
expect(pub.authorName).toBe("Jane");
expect(pub.isRegisteredUser).toBe(true);
expect(pub.body).toBe("Great!");
expect(pub.createdAt).toBe("2026-01-01T00:00:00.000Z");
// Private fields should not be present
expect("authorEmail" in pub).toBe(false);
expect("ipHash" in pub).toBe(false);
expect("userAgent" in pub).toBe(false);
expect("moderationMetadata" in pub).toBe(false);
expect("status" in pub).toBe(false);
});
});
// -------------------------------------------------------------------------
// Edge cases
// -------------------------------------------------------------------------
describe("Edge cases", () => {
it("returns empty results for non-existent content", async () => {
const result = await repo.findByContent("post", "nonexistent");
expect(result.items).toHaveLength(0);
expect(result.nextCursor).toBeUndefined();
});
it("moderationMetadata JSON round-trips correctly", async () => {
const metadata = {
aiScore: 0.95,
categories: ["safe"],
nested: { key: "value" },
};
const created = await repo.create(makeInput({ moderationMetadata: metadata }));
const found = await repo.findById(created.id);
expect(found!.moderationMetadata).toEqual(metadata);
});
it("moderationMetadata null round-trips", async () => {
const created = await repo.create(makeInput());
const found = await repo.findById(created.id);
expect(found!.moderationMetadata).toBeNull();
});
it("findByStatus with search filters by body", async () => {
await repo.create(makeInput({ status: "approved", body: "Hello world" }));
await repo.create(makeInput({ status: "approved", body: "Goodbye world" }));
const result = await repo.findByStatus("approved", { search: "Hello" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.body).toBe("Hello world");
});
it("findByStatus with search filters by author name", async () => {
await repo.create(makeInput({ status: "approved", authorName: "Alice" }));
await repo.create(makeInput({ status: "approved", authorName: "Bob" }));
const result = await repo.findByStatus("approved", { search: "Alice" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.authorName).toBe("Alice");
});
it("findByContent with status filter", async () => {
await repo.create(makeInput({ status: "approved" }));
await repo.create(makeInput({ status: "pending" }));
const result = await repo.findByContent("post", "content-1", { status: "approved" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.status).toBe("approved");
});
it("updateModerationMetadata updates the JSON field", async () => {
const created = await repo.create(makeInput());
await repo.updateModerationMetadata(created.id, { score: 0.5 });
const found = await repo.findById(created.id);
expect(found!.moderationMetadata).toEqual({ score: 0.5 });
});
});
});