feat(notifications) : notify guide publications and admin hearts; simplify Stygian navigation
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
comment: {} as Record<string, unknown>, author: {} as Record<string, unknown> | null,
|
||||
publicGuide: true, notifications: [] as Record<string, unknown>[],
|
||||
notify: vi.fn(), changed: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: state.notify }));
|
||||
vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: state.changed }));
|
||||
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: vi.fn() }));
|
||||
vi.mock("@/lib/audit-log", () => ({ auditActor: vi.fn(), writeAuditLog: vi.fn() }));
|
||||
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: vi.fn(), publicMediaUrl: vi.fn() }));
|
||||
vi.mock("@/db", () => ({ getDb: () => database }));
|
||||
|
||||
import { comments, guides, users } from "@/db/schema";
|
||||
import { mutateComment } from "./repository";
|
||||
|
||||
const id = "72df08ab-50dd-4cbd-9a69-70949d34cf9f";
|
||||
const guideId = "72df08ab-50dd-4cbd-9a69-70949d34cf9e";
|
||||
const admin = { id: "admin", admin: true };
|
||||
const database = {
|
||||
transaction: async (task: (tx: unknown) => unknown) => task(database),
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
let table: unknown;
|
||||
const rows = () => {
|
||||
if (table === comments) return fields.comment
|
||||
? [{ comment: state.comment, thread: { id: "thread", guideId } }]
|
||||
: [{ hidden: false }];
|
||||
if (table === guides) return [{ id: guideId, name: "Amber", slug: "amber", public: state.publicGuide, trashedAt: null }];
|
||||
if (table === users) return state.author ? [state.author] : [];
|
||||
return [];
|
||||
};
|
||||
const query = {
|
||||
from(value: unknown) { table = value; return query; },
|
||||
innerJoin() { return query; }, where() { return query; },
|
||||
limit: async () => rows(), for: async () => rows(),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
update: () => {
|
||||
const query = { set: () => query, where: async () => undefined };
|
||||
return query;
|
||||
},
|
||||
insert: () => ({ values: (value: Record<string, unknown>) => ({ onConflictDoNothing: () => ({ returning: async () => {
|
||||
if (state.notifications.some(old => old.eventKey === value.eventKey)) return [];
|
||||
state.notifications.push(value);
|
||||
return [{ userId: value.userId }];
|
||||
} }) }) }),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.comment = { id, authorId: "reader", rootId: null, hidden: false, deletedAt: null, heartedById: null };
|
||||
state.author = { id: "reader", email: "[email protected]", role: "user", emailVerified: true, banned: false };
|
||||
state.publicGuide = true; state.notifications.length = 0; state.notify.mockReset(); state.changed.mockReset();
|
||||
});
|
||||
|
||||
describe("admin heart notifications", () => {
|
||||
it("notifies the comment author and refreshes their bell after saving", async () => {
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
expect(state.notifications).toEqual([expect.objectContaining({ userId: "reader", kind: "comment_heart",
|
||||
title: "คุณได้รับหัวใจจากแอดมิน", url: `/amber/comment#comment-${id}`, commentId: id })]);
|
||||
expect(state.notify).toHaveBeenCalledWith("reader");
|
||||
});
|
||||
|
||||
it("links a reply to its exact thread", async () => {
|
||||
state.comment.rootId = "root-id";
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
expect(state.notifications[0].url).toBe(`/amber/comment?reply=${id}#comment-root-id`);
|
||||
});
|
||||
|
||||
it.each(["removed", "already hearted", "self", "banned", "missing", "private guide"])("skips notifications for %s", async (scenario) => {
|
||||
if (scenario === "already hearted") state.comment.heartedById = "other-admin";
|
||||
if (scenario === "self") state.comment.authorId = admin.id;
|
||||
if (scenario === "banned") state.author!.banned = true;
|
||||
if (scenario === "missing") state.author = null;
|
||||
if (scenario === "private guide") state.publicGuide = false;
|
||||
await mutateComment(id, admin, "heart", scenario !== "removed");
|
||||
expect(state.notifications).toHaveLength(0);
|
||||
expect(state.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not duplicate a notification when the heart is added again", async () => {
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
await mutateComment(id, admin, "heart", false);
|
||||
await mutateComment(id, admin, "heart", true);
|
||||
expect(state.notifications).toHaveLength(1);
|
||||
expect(state.notify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects hearts from regular users", async () => {
|
||||
await expect(mutateComment(id, { id: "reader", admin: false }, "heart", true)).rejects.toMatchObject({ status: 403 });
|
||||
expect(state.notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,24 @@ export async function mutateComment(id: string, viewer: CommentViewer, action: "
|
||||
} else if (action === "heart") {
|
||||
requireCommentAdmin(viewer);
|
||||
await tx.update(comments).set({ heartedById: value ? viewer.id : null }).where(eq(comments.id, id));
|
||||
if (value && !c.heartedById && c.authorId !== viewer.id) {
|
||||
const [author] = await tx.select({ id: users.id, email: users.email, role: users.role, emailVerified: users.emailVerified, banned: users.banned })
|
||||
.from(users).where(eq(users.id, c.authorId)).limit(1);
|
||||
if (!author || author.banned) return;
|
||||
let destination;
|
||||
try {
|
||||
({ destination } = await authorizeComment(id, { id: author.id, admin: isAuthorizedAdmin(author) }, tx));
|
||||
} catch (cause) {
|
||||
if (cause instanceof HttpError && cause.status === 404) return;
|
||||
throw cause;
|
||||
}
|
||||
const url = `${destination.href}${c.rootId ? `${destination.href.includes("?") ? "&" : "?"}reply=${id}` : ""}#comment-${c.rootId ?? id}`;
|
||||
notificationUsers = await tx.insert(notifications).values({
|
||||
userId: author.id, eventKey: `comment:${id}:heart`, kind: "comment_heart",
|
||||
title: "คุณได้รับหัวใจจากแอดมิน", body: `แอดมินให้หัวใจความคิดเห็นของคุณใน ${destination.name}`,
|
||||
url, commentId: id,
|
||||
}).onConflictDoNothing({ target: [notifications.userId, notifications.eventKey] }).returning({ userId: notifications.userId });
|
||||
}
|
||||
} else if (value === 0) {
|
||||
await tx.delete(commentReactions).where(and(eq(commentReactions.commentId, id), eq(commentReactions.userId, viewer.id)));
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user