fix(comments) : show notified threads in inbox and stop edit notifications
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
select: vi.fn(), rows: [] as unknown[][], notifications: vi.fn(), send: vi.fn(),
|
||||
authorize: vi.fn(), publish: vi.fn(), after: vi.fn(),
|
||||
}));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/db", () => ({ getDb: () => ({ select: state.select }) }));
|
||||
vi.mock("web-push", () => ({ default: { sendNotification: state.send, setVapidDetails: vi.fn() } }));
|
||||
vi.mock("@/lib/commission/push", () => ({ pushPublicKey: () => "public-key", validPushEndpoint: () => true }));
|
||||
vi.mock("@/lib/notifications/repository", () => ({ createNotifications: state.notifications }));
|
||||
vi.mock("./repository", () => ({
|
||||
authorizeComment: state.authorize, requireCommentViewer: async () => ({ id: "author", admin: false }), commentId: (id: string) => id,
|
||||
}));
|
||||
vi.mock("./publish", () => ({ publishComment: state.publish }));
|
||||
vi.mock("next/server", () => ({ after: state.after }));
|
||||
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => {} }));
|
||||
|
||||
import { sendCommentPush } from "./push";
|
||||
import { PATCH } from "@/app/api/comments/[id]/route";
|
||||
|
||||
const id = "00000000-0000-4000-8000-000000000001";
|
||||
const subscription = { endpoint: "https://fcm.googleapis.com/fcm/send/test", p256dh: "key", auth: "auth" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("BETTER_AUTH_URL", "https://guide.example.test");
|
||||
state.rows = [];
|
||||
state.select.mockImplementation(() => {
|
||||
const rows = state.rows.shift() ?? [];
|
||||
const query = { from: () => query, where: () => query, limit: () => Promise.resolve(rows), then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) };
|
||||
return query;
|
||||
});
|
||||
state.authorize.mockResolvedValue({ destination: { target: "guide:test", href: "/test/comment", name: "Test Guide", writable: true } });
|
||||
state.publish.mockResolvedValue({ id, version: 2 });
|
||||
state.notifications.mockResolvedValue(undefined);
|
||||
state.send.mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("comment notifications", () => {
|
||||
it.each([2, 3])("ignores an explicit edit revision %s before querying or sending", async (version) => {
|
||||
await sendCommentPush(id, version);
|
||||
expect(state.select).not.toHaveBeenCalled();
|
||||
expect(state.notifications).not.toHaveBeenCalled();
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, "parent-id"])("ignores an edited comment or reply when no revision is supplied (parent %s)", async (replyToId) => {
|
||||
state.rows = [[{ id, version: 2, hidden: false, deletedAt: null, replyToId }]];
|
||||
await sendCommentPush(id);
|
||||
expect(state.select).toHaveBeenCalledTimes(1);
|
||||
expect(state.authorize).not.toHaveBeenCalled();
|
||||
expect(state.notifications).not.toHaveBeenCalled();
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("still sends new-comment notifications (reply: %s)", async (reply) => {
|
||||
const recipient = { id: reply ? "reader" : "admin", admin: !reply };
|
||||
state.rows = [
|
||||
[{ id, version: 1, hidden: false, deletedAt: null, authorId: "author", replyToId: reply ? "parent-id" : null, rootId: reply ? "root-id" : null }],
|
||||
...(reply ? [[{ authorId: recipient.id, hidden: false, deletedAt: null }], [{ id: recipient.id, role: "user", banned: false }]] : [[{ id: recipient.id }]]),
|
||||
[{ name: "Author" }], [{ text: "Original comment" }], [subscription],
|
||||
];
|
||||
await sendCommentPush(id, 1);
|
||||
expect(state.notifications).toHaveBeenCalledWith([expect.objectContaining({ userId: recipient.id, adminOnly: !reply })], expect.objectContaining({
|
||||
kind: reply ? "comment_reply" : "comment_new", eventKey: `comment:${id}`, body: "Original comment",
|
||||
}));
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(state.send.mock.calls[0][1])).toMatchObject({ body: "Original comment" });
|
||||
});
|
||||
|
||||
it("still delivers a delayed initial notification when the comment has since been edited", async () => {
|
||||
state.rows = [
|
||||
[{ id, version: 2, hidden: false, deletedAt: null, authorId: "author", replyToId: null }],
|
||||
[{ id: "admin" }], [{ name: "Author" }], [{ text: "Original revision" }], [subscription],
|
||||
];
|
||||
await sendCommentPush(id, 1);
|
||||
expect(state.notifications).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ kind: "comment_new", body: "Original revision" }));
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("saves edits through PATCH without scheduling a notification", async () => {
|
||||
const response = await PATCH(new Request(`https://guide.example.test/api/comments/${id}`, {
|
||||
method: "PATCH", headers: { Origin: "https://guide.example.test" },
|
||||
}), { params: Promise.resolve({ id }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id, version: 2 });
|
||||
expect(state.publish).toHaveBeenCalled();
|
||||
expect(state.after).not.toHaveBeenCalled();
|
||||
expect(state.notifications).not.toHaveBeenCalled();
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,11 @@ import { createNotifications } from "@/lib/notifications/repository";
|
||||
import { authorizeComment } from "./repository";
|
||||
|
||||
export async function sendCommentPush(id: string, version?: number) {
|
||||
if (version !== undefined && version !== 1) return;
|
||||
const [reply] = await getDb().select().from(comments).where(eq(comments.id, id)).limit(1);
|
||||
if (!reply || reply.hidden || reply.deletedAt) return;
|
||||
const notificationVersion = version ?? reply.version;
|
||||
const edited = notificationVersion > 1;
|
||||
if (notificationVersion !== 1) return;
|
||||
let recipients: { id: string; admin: boolean }[];
|
||||
let url: string;
|
||||
let title: string;
|
||||
@@ -27,7 +28,7 @@ export async function sendCommentPush(id: string, version?: number) {
|
||||
} catch { return; }
|
||||
recipients = [{ id: recipient.id, admin: false }];
|
||||
url = `${destination.href}${destination.href.includes("?") ? "&" : "?"}reply=${id}#comment-${reply.rootId}`;
|
||||
title = edited ? "แก้ไขคำตอบกลับความคิดเห็นของคุณ" : "ตอบกลับความคิดเห็นของคุณ";
|
||||
title = "ตอบกลับความคิดเห็นของคุณ";
|
||||
} else {
|
||||
recipients = (await getDb().select({ id: users.id }).from(users)
|
||||
.where(and(eq(users.role, "admin"), eq(users.emailVerified, true), ne(users.id, reply.authorId), sql`${users.banned} is not true`)))
|
||||
@@ -39,7 +40,7 @@ export async function sendCommentPush(id: string, version?: number) {
|
||||
} catch { return; }
|
||||
if (!destination.writable) return;
|
||||
url = `/admin/comments?target=${encodeURIComponent(destination.target)}&comment=${id}`;
|
||||
title = edited ? `แก้ไขความคิดเห็นใน ${destination.name}` : `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
|
||||
title = `แสดงความคิดเห็นใหม่ใน ${destination.name}`;
|
||||
}
|
||||
const [author] = await getDb().select({ name: users.name }).from(users).where(eq(users.id, reply.authorId)).limit(1);
|
||||
const [revision] = await getDb().select({ text: commentRevisions.text }).from(commentRevisions)
|
||||
@@ -48,7 +49,7 @@ export async function sendCommentPush(id: string, version?: number) {
|
||||
const notificationTitle = `${author?.name ?? "ผู้ใช้"} ${title}`;
|
||||
const body = revision?.text.slice(0, 300) || "ส่งรูปภาพ";
|
||||
await createNotifications(recipients.map(recipient => ({ userId: recipient.id, adminOnly: recipient.admin, url })), {
|
||||
eventKey: edited ? `comment:${id}:edit:${notificationVersion}` : `comment:${id}`, kind: edited ? "comment_edit" : reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
|
||||
eventKey: `comment:${id}`, kind: reply.replyToId ? "comment_reply" : "comment_new", title: notificationTitle, body, commentId: id,
|
||||
});
|
||||
if (!pushPublicKey()) return;
|
||||
const subscriptions = await getDb().select().from(commentPushSubscriptions).where(inArray(commentPushSubscriptions.userId, recipients.map(recipient => recipient.id)));
|
||||
|
||||
Reference in New Issue
Block a user