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();
|
||||
});
|
||||
});
|
||||
318
packages/core/tests/integration/comments/notifications.test.ts
Normal file
318
packages/core/tests/integration/comments/notifications.test.ts
Normal 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("<script>");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
412
packages/core/tests/integration/comments/repository.test.ts
Normal file
412
packages/core/tests/integration/comments/repository.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user