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:
518
packages/core/tests/integration/comments/hooks.test.ts
Normal file
518
packages/core/tests/integration/comments/hooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user