664 lines
49 KiB
TypeScript
664 lines
49 KiB
TypeScript
import { randomInt, randomUUID } from "node:crypto";
|
|
import { eq, inArray } from "drizzle-orm";
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { after } from "next/server";
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() }));
|
|
vi.mock("next/server", () => ({ after: vi.fn() }));
|
|
const pushTransport = vi.hoisted(() => ({ sendNotification: vi.fn(), setVapidDetails: vi.fn() }));
|
|
vi.mock("web-push", () => ({ default: pushTransport }));
|
|
const notify = vi.hoisted(() => vi.fn());
|
|
vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: notify }));
|
|
const storage = vi.hoisted(() => ({ write: vi.fn(), delete: vi.fn() }));
|
|
const rateLimit = vi.hoisted(() => vi.fn());
|
|
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: rateLimit }));
|
|
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set: async () => "OK", eval: async () => 1 }) }));
|
|
const session = vi.hoisted(() => ({ get: vi.fn() }));
|
|
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => storage, publicMediaUrl: (key: string) => `https://storage.example.test/${key}` }));
|
|
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: session.get }));
|
|
const { getDb, closeDb } = await import("@/db");
|
|
const schema = await import("@/db/schema");
|
|
const { publishComment } = await import("@/lib/comments/publish");
|
|
const { authorizeComment, commentHistory, commentImage, getCommentViewer, listComments, mutateComment } = await import("@/lib/comments/repository");
|
|
|
|
const commentsRoute = await import("@/app/api/comments/route");
|
|
const itemRoute = await import("@/app/api/comments/[id]/route");
|
|
const actionRoute = await import("@/app/api/comments/[id]/[action]/route");
|
|
const inboxRoute = await import("@/app/api/admin/comments/route");
|
|
const { sendCommentPush } = await import("@/lib/comments/push");
|
|
const { notifyNotificationChange } = await import("@/lib/notifications/events");
|
|
const { createNotifications, listNotifications, readNotifications } = await import("@/lib/notifications/repository");
|
|
const { sendCommissionMessagePush } = await import("@/lib/commission/push");
|
|
const { unreadCommentCounts, markCommentsRead } = await import("@/lib/comments/unread");
|
|
const subscriptionRoute = await import("@/app/api/comments/push-subscription/route");
|
|
const imageRoute = await import("@/app/api/comments/images/[id]/route");
|
|
const databaseUrl = process.env.DATABASE_INTEGRATION_URL;
|
|
const describeDatabase = databaseUrl ? describe : describe.skip;
|
|
describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () => {
|
|
const suffix = randomUUID();
|
|
const author = { id: `comment-user-${suffix}`, admin: false };
|
|
const other = { id: `comment-other-${suffix}`, admin: false };
|
|
const admin = { id: `comment-admin-${suffix}`, admin: true };
|
|
const guideId = randomUUID(), otherGuideId = randomUUID(), privateGuideId = randomUUID(), mediaId = randomUUID();
|
|
const scheduleId = randomInt(80_000_000, 81_000_000);
|
|
const target = `guide:${guideId}`;
|
|
const originalUrl = process.env.DATABASE_URL;
|
|
const originalOrigin = process.env.BETTER_AUTH_URL;
|
|
const pushEnvironment = Object.fromEntries(["WEB_PUSH_PUBLIC_KEY", "WEB_PUSH_PRIVATE_KEY", "WEB_PUSH_SUBJECT"].map((key) => [key, process.env[key]]));
|
|
const subscription = { endpoint: `https://fcm.googleapis.com/fcm/send/${suffix}`, keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) } };
|
|
const saveSubscription = (userId: string, endpoint = subscription.endpoint) => getDb().insert(schema.commentPushSubscriptions).values({ userId, endpoint, ...subscription.keys });
|
|
let png: Buffer;
|
|
function request(text: string, options: { reply?: string; version?: number; keep?: string[]; images?: Uint8Array[] } = {}, url = "https://guide.example.test/api/comments") {
|
|
const body = new FormData(); body.set("text", text);
|
|
if (options.reply) body.set("replyToId", options.reply);
|
|
if (options.version) body.set("version", String(options.version));
|
|
for (const id of options.keep ?? []) body.append("keepImageId", id);
|
|
for (const bytes of options.images ?? []) body.append("image", new File([bytes as Uint8Array<ArrayBuffer>], "image.png", { type: "image/png" }));
|
|
return new Request(url, { method: "POST", headers: { Origin: "https://guide.example.test" }, body });
|
|
}
|
|
const post = (text = "Original", options: Parameters<typeof request>[1] = {}) => publishComment(request(text, options), author, { target });
|
|
const page = () => listComments({ target, viewer: author });
|
|
beforeAll(async () => {
|
|
process.env.WEB_PUSH_PUBLIC_KEY = "test-public-key";
|
|
process.env.WEB_PUSH_PRIVATE_KEY = "test-private-key";
|
|
process.env.WEB_PUSH_SUBJECT = "https://guide.example.test";
|
|
process.env.DATABASE_URL = databaseUrl;
|
|
process.env.BETTER_AUTH_URL = "https://guide.example.test";
|
|
png = await sharp({ create: { width: 2, height: 2, channels: 3, background: "red" } }).png().toBuffer();
|
|
const db = getDb();
|
|
await db.insert(schema.users).values([author, other, admin].map((u) => ({ id: u.id, name: u.admin ? "Admin" : "Reader", email: `${u.id}@example.test`, emailVerified: true, role: u.admin ? "admin" : "user" })));
|
|
await db.insert(schema.catalogCharacters).values([guideId, otherGuideId, privateGuideId].map((id) => ({ key: `comment-${id}`, name: "Test", imageKey: "test.png" })));
|
|
await db.insert(schema.media).values({ id: mediaId, objectKey: `test/comments/${suffix}`, fileName: "test.png", mimeType: "image/png", byteSize: 1, status: "ready" });
|
|
await db.insert(schema.guides).values([guideId, otherGuideId, privateGuideId].map((id) => ({ id, characterKey: `comment-${id}`, slug: `comment-test-${id}`, name: "Test Guide", overview: "", coverMediaId: mediaId, isPublic: id !== privateGuideId })));
|
|
await db.insert(schema.stygianSchedules).values([scheduleId, scheduleId + 1].map((id) => ({ scheduleId: id, challengeName: "Test", scheduleStartTime: new Date(), scheduleEndTime: new Date() })));
|
|
});
|
|
beforeEach(async () => {
|
|
notify.mockReset().mockResolvedValue(undefined);
|
|
pushTransport.sendNotification.mockReset().mockResolvedValue(undefined);
|
|
pushTransport.setVapidDetails.mockReset();
|
|
await getDb().delete(schema.commentPushSubscriptions).where(inArray(schema.commentPushSubscriptions.userId, [author.id, other.id, admin.id]));
|
|
await getDb().delete(schema.notifications).where(inArray(schema.notifications.userId, [author.id, other.id, admin.id]));
|
|
await getDb().delete(schema.commentReadState).where(inArray(schema.commentReadState.userId, [author.id, other.id, admin.id]));
|
|
await getDb().delete(schema.commentThreads).where(inArray(schema.commentThreads.guideId, [guideId, otherGuideId, privateGuideId]));
|
|
await getDb().delete(schema.commentThreads).where(inArray(schema.commentThreads.scheduleId, [scheduleId, scheduleId + 1]));
|
|
storage.write.mockReset().mockResolvedValue(undefined); storage.delete.mockReset().mockResolvedValue(undefined);
|
|
session.get.mockResolvedValue(null);
|
|
rateLimit.mockReset().mockResolvedValue(undefined);
|
|
vi.mocked(after).mockClear();
|
|
});
|
|
afterAll(async () => {
|
|
const db = getDb();
|
|
await db.delete(schema.guides).where(inArray(schema.guides.id, [guideId, otherGuideId, privateGuideId]));
|
|
await db.delete(schema.stygianSchedules).where(inArray(schema.stygianSchedules.scheduleId, [scheduleId, scheduleId + 1]));
|
|
await db.delete(schema.media).where(eq(schema.media.id, mediaId));
|
|
await db.delete(schema.catalogCharacters).where(inArray(schema.catalogCharacters.key, [guideId, otherGuideId, privateGuideId].map((id) => `comment-${id}`)));
|
|
await db.delete(schema.users).where(inArray(schema.users.id, [author.id, other.id, admin.id]));
|
|
await db.delete(schema.auditLog).where(eq(schema.auditLog.authorId, admin.id));
|
|
await closeDb();
|
|
for (const [key, value] of Object.entries(pushEnvironment)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
|
|
if (originalUrl === undefined) delete process.env.DATABASE_URL; else process.env.DATABASE_URL = originalUrl;
|
|
if (originalOrigin === undefined) delete process.env.BETTER_AUTH_URL; else process.env.BETTER_AUTH_URL = originalOrigin;
|
|
});
|
|
it("notifies readers only after successful commits and normalizes guide topics", async () => {
|
|
const root = await publishComment(request("Live comment"), author, { target: target.toUpperCase().replace("GUIDE:", "guide:") });
|
|
expect(notify).toHaveBeenLastCalledWith(target);
|
|
expect((await page()).items[0].text).toBe("Live comment");
|
|
notify.mockClear();
|
|
await expect(publishComment(request("Stale edit", { version: 2 }), author, { id: root.id })).rejects.toMatchObject({ status: 409 });
|
|
expect(notify).not.toHaveBeenCalled();
|
|
await mutateComment(root.id, other, "reaction", 1);
|
|
expect(notify).toHaveBeenLastCalledWith(target);
|
|
expect((await page()).items[0].likes).toBe(1);
|
|
});
|
|
|
|
it("allows public reading while denying private and trashed guide access", async () => {
|
|
await post(); expect((await listComments({ target, viewer: null })).items).toHaveLength(1);
|
|
await expect(listComments({ target: `guide:${privateGuideId}`, viewer: author })).rejects.toMatchObject({ status: 404 });
|
|
await getDb().update(schema.guides).set({ trashedAt: new Date() }).where(eq(schema.guides.id, guideId));
|
|
await expect(page()).rejects.toMatchObject({ status: 404 });
|
|
await expect(publishComment(request("No"), admin, { target })).rejects.toMatchObject({ status: 409 });
|
|
await getDb().update(schema.guides).set({ trashedAt: null }).where(eq(schema.guides.id, guideId));
|
|
});
|
|
it("records the direct recipient while flattening reply-to-reply threads", async () => {
|
|
const root = await post(); const reply = await post("First reply", { reply: root.id });
|
|
const nested = await post("Reply to reply", { reply: reply.id });
|
|
const result = await listComments({ viewer: author, rootId: root.id });
|
|
expect(result.items.map((c) => c.id)).toEqual([reply.id, nested.id]);
|
|
expect(result.items[1]).toMatchObject({ rootId: root.id, replyToId: reply.id, replyToName: "Reader" });
|
|
expect((await page()).items[0].replyCount).toBe(2);
|
|
await expect(publishComment(request("Bad reply", { reply: reply.id }), author, { target: `guide:${otherGuideId}` })).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
it("groups the admin inbox and keeps nested admin replies attached to their recipient", async () => {
|
|
const root = await post();
|
|
const reply = await post("Reader reply", { reply: root.id });
|
|
const nested = await publishComment(request("Admin reply", { reply: reply.id }), admin, { target });
|
|
const second = await post("Second conversation");
|
|
const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
|
|
expect(grouped.items.map((item) => item.id)).toEqual([second.id, root.id]);
|
|
expect(grouped.items[1].replyCount).toBe(2);
|
|
const replies = await listComments({ viewer: admin, rootId: root.id });
|
|
expect(replies.items[1]).toMatchObject({ id: nested.id, rootId: root.id, replyToId: reply.id, authorAdmin: true });
|
|
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, unanswered: true })).items.map((item) => item.id)).toEqual([second.id]);
|
|
expect((await listComments({ viewer: admin, inbox: true, target })).items).toHaveLength(4);
|
|
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target: `guide:${otherGuideId}` })).items).toHaveLength(0);
|
|
session.get.mockResolvedValue({ user: { id: admin.id } });
|
|
const response = await inboxRoute.GET(new Request(`https://guide.example.test/api/admin/comments?grouped=true&target=${target}`));
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
|
expect((await response.json()).items.map((item: { id: string }) => item.id)).toEqual([second.id, root.id]);
|
|
});
|
|
it("finds grouped conversations by reply visibility without filtering their context", async () => {
|
|
const root = await post();
|
|
const reply = await post("Reply", { reply: root.id });
|
|
const nested = await post("Nested reply", { reply: reply.id });
|
|
await mutateComment(nested.id, admin, "moderation", true);
|
|
const hidden = await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" });
|
|
expect(hidden.items.map((item) => item.id)).toEqual([root.id]);
|
|
expect(hidden.items[0].hidden).toBe(false);
|
|
expect((await listComments({ viewer: admin, rootId: root.id })).items).toHaveLength(2);
|
|
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items.map((item) => item.id)).toEqual([root.id]);
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items).toHaveLength(0);
|
|
expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" })).items).toHaveLength(1);
|
|
});
|
|
it("keeps Stygian schedule threads independent", async () => {
|
|
await publishComment(request("Schedule A"), author, { target: `stygian:${scheduleId}` });
|
|
expect((await listComments({ viewer: author, target: `stygian:${scheduleId + 1}` })).items).toHaveLength(0);
|
|
expect((await listComments({ viewer: admin, inbox: true })).items.some((c) => c.target === `stygian:${scheduleId}`)).toBe(true);
|
|
});
|
|
it("switches and removes reactions without exposing dislike totals", async () => {
|
|
const root = await post();
|
|
await mutateComment(root.id, author, "reaction", 1);
|
|
expect((await page()).items[0]).toMatchObject({ likes: 1, reaction: 1 });
|
|
await mutateComment(root.id, author, "reaction", -1);
|
|
expect((await page()).items[0]).toMatchObject({ likes: 0, reaction: -1 });
|
|
expect((await page()).items[0]).not.toHaveProperty("dislikes");
|
|
await mutateComment(root.id, author, "reaction", 0);
|
|
expect((await page()).items[0].reaction).toBe(0);
|
|
});
|
|
it("restricts hearts and moderation to admins and records moderation", async () => {
|
|
const root = await post();
|
|
await expect(mutateComment(root.id, author, "heart", true)).rejects.toMatchObject({ status: 403 });
|
|
await expect(mutateComment(root.id, author, "moderation", true)).rejects.toMatchObject({ status: 403 });
|
|
await mutateComment(root.id, admin, "heart", true);
|
|
await mutateComment(root.id, admin, "reaction", 1);
|
|
expect((await page()).items[0]).toMatchObject({ hearted: true, likes: 1 });
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
const audit = await getDb().select().from(schema.auditLog).where(eq(schema.auditLog.authorId, admin.id));
|
|
expect(audit.map((event) => event.details?.target)).toContainEqual({ type: "comment", id: root.id });
|
|
});
|
|
it("preserves public text and images in immutable edit history", async () => {
|
|
const root = await post("Version one", { images: [png] });
|
|
const oldImage = (await page()).items[0].images[0];
|
|
await publishComment(request("Version two", { version: 1, images: [png] }), author, { id: root.id });
|
|
const history = await commentHistory(root.id, null);
|
|
expect(history.items.map((revision) => revision.text)).toEqual(["Version two", "Version one"]);
|
|
expect(history.items[1].images[0].id).toBe(oldImage.id);
|
|
expect((await commentImage(oldImage.id, null)).commentId).toBe(root.id);
|
|
expect(storage.delete).not.toHaveBeenCalled();
|
|
expect(storage.write).toHaveBeenCalledWith(expect.stringMatching(/^comments\//), expect.any(Uint8Array), expect.objectContaining({ acl: "public-read" }));
|
|
});
|
|
it("enforces ownership, retained-image ownership, and edit conflicts", async () => {
|
|
const root = await post("First", { images: [png] });
|
|
const image = (await page()).items[0].images[0];
|
|
await expect(publishComment(request("Stolen", { version: 1 }), other, { id: root.id })).rejects.toMatchObject({ status: 403 });
|
|
await publishComment(request("Second", { version: 1, keep: [image.id] }), author, { id: root.id });
|
|
await expect(publishComment(request("Stale", { version: 1 }), author, { id: root.id })).rejects.toMatchObject({ status: 409 });
|
|
const otherRoot = await post("Other");
|
|
await expect(publishComment(request("Foreign image", { version: 1, keep: [image.id] }), author, { id: otherRoot.id })).rejects.toMatchObject({ status: 400 });
|
|
await expect(mutateComment(root.id, other, "delete")).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
it("edits images through PATCH while preserving retained images and revision history", async () => {
|
|
const root = await post("Original", { images: [png] });
|
|
const originalImage = (await page()).items[0].images[0];
|
|
session.get.mockResolvedValue({ user: { id: author.id } });
|
|
const context = { params: Promise.resolve({ id: root.id }) };
|
|
const edit = request("Added image", { version: 1, keep: [originalImage.id], images: [png] });
|
|
const response = await itemRoute.PATCH(new Request(edit, { method: "PATCH" }), context);
|
|
expect(await response.json()).toMatchObject({ id: root.id });
|
|
expect(response.status).toBe(200);
|
|
const updated = (await page()).items[0];
|
|
expect(updated).toMatchObject({ version: 2, text: "Added image" });
|
|
expect(updated.images).toHaveLength(2);
|
|
expect(updated.images[0]).toEqual(originalImage);
|
|
const replacement = request("", { version: 2, images: [png] });
|
|
expect((await itemRoute.PATCH(new Request(replacement, { method: "PATCH" }), context)).status).toBe(200);
|
|
expect((await page()).items[0].images).toHaveLength(1);
|
|
const history = await commentHistory(root.id, author);
|
|
expect(history.items.map((item) => item.images.length)).toEqual([1, 2, 1]);
|
|
});
|
|
it("retries temporary image-storage failures without duplicating the edit", async () => {
|
|
const root = await post("Original");
|
|
storage.write.mockRejectedValueOnce(Object.assign(new Error("Temporary storage failure"), { code: "ECONNRESET" }));
|
|
await publishComment(request("Edited", { version: 1, images: [png] }), author, { id: root.id });
|
|
expect(storage.write).toHaveBeenCalledTimes(2);
|
|
expect(storage.write.mock.calls[0][0]).toBe(storage.write.mock.calls[1][0]);
|
|
expect((await page()).items[0]).toMatchObject({ text: "Edited", version: 2 });
|
|
expect((await commentHistory(root.id, author)).items).toHaveLength(2);
|
|
expect(storage.delete).not.toHaveBeenCalled();
|
|
});
|
|
it("keeps the original revision and cleans up images when storage retries fail", async () => {
|
|
const root = await post("Original");
|
|
storage.write.mockRejectedValue(Object.assign(new Error("Storage unavailable"), { statusCode: 503 }));
|
|
session.get.mockResolvedValue({ user: { id: author.id } });
|
|
const response = await itemRoute.PATCH(request("Failed edit", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ error: "comment-image-upload-failed" });
|
|
expect(storage.write).toHaveBeenCalledTimes(3);
|
|
expect(storage.delete).toHaveBeenCalledTimes(1);
|
|
expect((await page()).items[0]).toMatchObject({ text: "Original", version: 1 });
|
|
expect(after).not.toHaveBeenCalled();
|
|
});
|
|
it("blocks hidden-root replies, history, and attachments and restores access", async () => {
|
|
const root = await post("Root", { images: [png] }); const reply = await post("Reply", { reply: root.id, images: [png] });
|
|
const image = (await page()).items[0].images[0].id;
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
expect((await page()).items).toHaveLength(0);
|
|
await expect(commentHistory(reply.id, author)).rejects.toMatchObject({ status: 404 });
|
|
await expect(commentImage(image, null)).rejects.toMatchObject({ status: 404 });
|
|
await expect(post("New reply", { reply: root.id })).rejects.toMatchObject({ status: 404 });
|
|
const inbox = await listComments({ viewer: admin, inbox: true, target, status: "hidden" });
|
|
expect(inbox.items).toHaveLength(2);
|
|
await mutateComment(root.id, admin, "moderation", false);
|
|
expect((await page()).items).toHaveLength(1);
|
|
expect((await commentHistory(reply.id, null)).items).toHaveLength(1);
|
|
});
|
|
it("preserves hidden-reply placeholders without exposing text, images, or history", async () => {
|
|
const root = await post(); const reply = await post("Hidden reply", { reply: root.id, images: [png] });
|
|
const nested = await post("Visible follow-up", { reply: reply.id });
|
|
await mutateComment(reply.id, admin, "moderation", true);
|
|
const replies = await listComments({ viewer: null, rootId: root.id });
|
|
expect(replies.items[0]).toMatchObject({ id: reply.id, hidden: true, text: "", images: [], canInteract: false });
|
|
expect(replies.items[1]).toMatchObject({ id: nested.id, text: "Visible follow-up" });
|
|
await expect(commentHistory(reply.id, null)).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
it("permanently deletes a root and every reply with all associated data and S3 images", async () => {
|
|
const root = await post("Root", { images: [png] });
|
|
const reply = await post("Reply", { reply: root.id, images: [png] });
|
|
const nested = await publishComment(request("Nested", { reply: reply.id, images: [png] }), other, { target });
|
|
await mutateComment(nested.id, other, "reaction", 1);
|
|
const keys = storage.write.mock.calls.map((call) => call[0]);
|
|
const images = await getDb().select().from(schema.commentAttachments);
|
|
await mutateComment(root.id, author, "delete");
|
|
expect((await page()).items).toEqual([]);
|
|
for (const id of [root.id, reply.id, nested.id]) {
|
|
expect(await getDb().select().from(schema.comments).where(eq(schema.comments.id, id))).toEqual([]);
|
|
expect(await getDb().select().from(schema.commentRevisions).where(eq(schema.commentRevisions.commentId, id))).toEqual([]);
|
|
expect(await getDb().select().from(schema.commentAttachments).where(eq(schema.commentAttachments.commentId, id))).toEqual([]);
|
|
expect(await getDb().select().from(schema.commentReactions).where(eq(schema.commentReactions.commentId, id))).toEqual([]);
|
|
await expect(commentHistory(id, admin)).rejects.toMatchObject({ status: 404 });
|
|
}
|
|
for (const image of images.filter((image) => [root.id, reply.id, nested.id].includes(image.commentId))) {
|
|
await expect(commentImage(image.id, author)).rejects.toMatchObject({ status: 404 });
|
|
expect(await getDb().select().from(schema.commentRevisionAttachments).where(eq(schema.commentRevisionAttachments.attachmentId, image.id))).toEqual([]);
|
|
}
|
|
for (const key of keys) expect(storage.delete).toHaveBeenCalledWith(key);
|
|
await expect(listComments({ viewer: null, rootId: root.id })).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
it("deletes only the selected reply subtree and preserves its root and sibling", async () => {
|
|
const root = await post("Root");
|
|
const reply = await post("Delete reply", { reply: root.id });
|
|
const nested = await post("Delete nested", { reply: reply.id });
|
|
const sibling = await post("Keep sibling", { reply: root.id });
|
|
await mutateComment(reply.id, author, "delete");
|
|
expect((await page()).items[0]).toMatchObject({ id: root.id, replyCount: 1 });
|
|
expect((await listComments({ rootId: root.id, viewer: null })).items.map((item) => item.id)).toEqual([sibling.id]);
|
|
await expect(authorizeComment(nested.id, author)).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
it("paginates roots, replies, and top comments with stable tie ordering", async () => {
|
|
const firstRoot = await post("Root 0");
|
|
const [thread] = await getDb().select().from(schema.commentThreads).where(eq(schema.commentThreads.guideId, guideId));
|
|
const roots = [firstRoot, ...Array.from({ length: 22 }, () => ({ id: randomUUID() }))];
|
|
await getDb().transaction(async (tx) => {
|
|
await tx.insert(schema.comments).values(roots.slice(1).map((root) => ({ id: root.id, threadId: thread.id, authorId: author.id })));
|
|
await tx.insert(schema.commentRevisions).values(roots.slice(1).map((root, i) => ({ commentId: root.id, version: 1, text: `Root ${i + 1}` })));
|
|
});
|
|
const first = await page();
|
|
const second = await listComments({ target, viewer: author, cursor: first.nextCursor });
|
|
expect(first.items).toHaveLength(20); expect(second.items).toHaveLength(3);
|
|
expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(23);
|
|
await mutateComment(roots[0].id, other, "reaction", 1);
|
|
const top = await listComments({ target, viewer: author, sort: "top" });
|
|
expect(top.items[0].id).toBe(roots[0].id);
|
|
const topNext = await listComments({ target, viewer: author, sort: "top", cursor: top.nextCursor });
|
|
expect(new Set([...top.items, ...topNext.items].map((item) => item.id)).size).toBe(23);
|
|
const replyIds = Array.from({ length: 22 }, () => randomUUID());
|
|
await getDb().transaction(async (tx) => {
|
|
await tx.insert(schema.comments).values(replyIds.map((id) => ({ id, threadId: thread.id, authorId: author.id, rootId: roots[0].id, replyToId: roots[0].id })));
|
|
await tx.insert(schema.commentRevisions).values(replyIds.map((id, i) => ({ commentId: id, version: 1, text: `Reply ${i}` })));
|
|
});
|
|
const replies = await listComments({ viewer: author, rootId: roots[0].id });
|
|
expect(replies.items).toHaveLength(20);
|
|
expect((await listComments({ viewer: author, rootId: roots[0].id, cursor: replies.nextCursor })).items).toHaveLength(2);
|
|
const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target });
|
|
const groupedNext = await listComments({ viewer: admin, inbox: true, grouped: true, target, cursor: grouped.nextCursor });
|
|
expect(grouped.items).toHaveLength(20); expect(groupedNext.items).toHaveLength(3);
|
|
expect(new Set([...grouped.items, ...groupedNext.items].map((item) => item.id))).toEqual(new Set(roots.map((item) => item.id)));
|
|
}, 30000);
|
|
it("filters unanswered roots and treats an admin reply as an answer", async () => {
|
|
const root = await post();
|
|
const unanswered = await listComments({ viewer: admin, inbox: true, target, unanswered: true });
|
|
expect(unanswered.items).toHaveLength(1);
|
|
await publishComment(request("Admin answer", { reply: root.id }), admin, { target });
|
|
expect((await listComments({ viewer: admin, inbox: true, target, unanswered: true })).items).toHaveLength(0);
|
|
});
|
|
it("rejects fake image contents and cleans up a partially uploaded batch", async () => {
|
|
await expect(post("Fake", { images: [new Uint8Array([0, 1, 2])] })).rejects.toMatchObject({ status: 422 });
|
|
expect(storage.write).not.toHaveBeenCalled();
|
|
storage.write.mockRejectedValueOnce(new Error("storage unavailable"));
|
|
await expect(post("Failed", { images: [png] })).rejects.toMatchObject({ status: 503, message: "comment-image-upload-failed" });
|
|
expect(storage.delete).toHaveBeenCalledWith(storage.write.mock.calls[0][0]);
|
|
expect((await page()).items).toHaveLength(0);
|
|
});
|
|
it("allows only a single winner for concurrent edits", async () => {
|
|
const root = await post();
|
|
const result = await Promise.allSettled([publishComment(request("Edit A", { version: 1 }), author, { id: root.id }), publishComment(request("Edit B", { version: 1 }), author, { id: root.id })]);
|
|
expect(result.filter((entry) => entry.status === "fulfilled")).toHaveLength(1);
|
|
expect((await commentHistory(root.id, author)).items).toHaveLength(2);
|
|
});
|
|
it("uses current account permissions and rejects banned accounts", async () => {
|
|
session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } });
|
|
expect(await getCommentViewer()).toMatchObject({ ...author, name: "Reader", image: null });
|
|
await getDb().update(schema.users).set({ banned: true }).where(eq(schema.users.id, author.id));
|
|
expect(await getCommentViewer()).toBeNull();
|
|
await getDb().update(schema.users).set({ banned: false }).where(eq(schema.users.id, author.id));
|
|
await expect(listComments({ viewer: author, inbox: true })).rejects.toMatchObject({ status: 403 });
|
|
await expect(authorizeComment(randomUUID(), author)).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
it("requires sign-in and same origin at the HTTP boundary", async () => {
|
|
const url = `https://guide.example.test/api/comments?target=${target}`;
|
|
expect((await commentsRoute.POST(request("Guest", {}, url))).status).toBe(401);
|
|
session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } });
|
|
const foreign = request("Foreign", {}, url); foreign.headers.set("origin", "https://evil.example");
|
|
expect((await commentsRoute.POST(foreign)).status).toBe(403);
|
|
const created = await commentsRoute.POST(request("Signed in", {}, url));
|
|
expect(created.status).toBe(201);
|
|
expect(rateLimit).toHaveBeenCalledWith("comment-publish", author.id, 20);
|
|
session.get.mockResolvedValue(null);
|
|
const read = await commentsRoute.GET(new Request(url));
|
|
expect(read.status).toBe(200); expect(read.headers.get("cache-control")).toContain("no-store");
|
|
expect((await read.json()).items).toHaveLength(1);
|
|
});
|
|
it("rejects abusive posts, replies and edits before uploads, revisions or notifications", async () => {
|
|
session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } });
|
|
const url = `https://guide.example.test/api/comments?target=${target}`;
|
|
const blocked = await commentsRoute.POST(request("f.u.c.k", { images: [png] }, url));
|
|
expect(blocked.status).toBe(400);
|
|
expect(await blocked.json()).toEqual({ error: "comment-abusive-language" });
|
|
expect((await page()).items).toHaveLength(0);
|
|
expect(storage.write).not.toHaveBeenCalled();
|
|
expect(notify).not.toHaveBeenCalled();
|
|
const root = await post("Normal discussion");
|
|
notify.mockClear();
|
|
vi.mocked(after).mockClear();
|
|
const reply = await commentsRoute.POST(request("ไอ้เหี้ย", { reply: root.id }, url));
|
|
expect(reply.status).toBe(400);
|
|
const edited = await itemRoute.PATCH(request("buy now ".repeat(8), { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
|
|
expect(edited.status).toBe(400);
|
|
expect(await edited.json()).toEqual({ error: "comment-spam" });
|
|
expect((await page()).items).toHaveLength(1);
|
|
expect((await commentHistory(root.id, author)).items).toHaveLength(1);
|
|
expect(storage.write).not.toHaveBeenCalled();
|
|
expect(notify).not.toHaveBeenCalled();
|
|
expect(after).not.toHaveBeenCalled();
|
|
});
|
|
it("enforces author and admin rights through mutation endpoints", async () => {
|
|
const root = await post();
|
|
session.get.mockResolvedValue({ user: { id: other.id }, session: { id: "session" } });
|
|
const context = { params: Promise.resolve({ id: root.id }) };
|
|
expect((await itemRoute.PATCH(request("Not mine", { version: 1 }), context)).status).toBe(403);
|
|
expect((await itemRoute.DELETE(new Request(`https://guide.example.test/api/comments/${root.id}`, { method: "DELETE", headers: { Origin: "https://guide.example.test" } }), context)).status).toBe(403);
|
|
const actionContext = { params: Promise.resolve({ id: root.id, action: "heart" }) };
|
|
expect((await actionRoute.PUT(new Request("https://guide.example.test/api/comments/heart", {
|
|
method: "PUT", headers: { Origin: "https://guide.example.test", "Content-Type": "application/json" }, body: JSON.stringify({ hearted: true }),
|
|
}), actionContext)).status).toBe(403);
|
|
expect((await inboxRoute.GET(new Request("https://guide.example.test/api/admin/comments"))).status).toBe(403);
|
|
});
|
|
it("rejects oversized streamed bodies and malformed actions before saving", async () => {
|
|
session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } });
|
|
const url = `https://guide.example.test/api/comments?target=${target}`;
|
|
const oversized = request("Too large", {}, url); oversized.headers.set("content-length", String(60 * 1024 * 1024));
|
|
expect((await commentsRoute.POST(oversized)).status).toBe(413);
|
|
const root = await post();
|
|
const action = { params: Promise.resolve({ id: root.id, action: "reaction" }) };
|
|
const invalid = new Request("https://guide.example.test/api/comments/reaction", { method: "PUT", headers: { Origin: "https://guide.example.test", "Content-Type": "application/json" }, body: JSON.stringify({ value: 2 }) });
|
|
expect((await actionRoute.PUT(invalid, action)).status).toBe(400);
|
|
const missing = await itemRoute.GET(new Request(url), { params: Promise.resolve({ id: "invalid" }) });
|
|
expect(missing.status).toBe(404);
|
|
});
|
|
it("returns direct S3 CDN URLs after authorization and redirects legacy image links", async () => {
|
|
const root = await post("Image", { images: [png] });
|
|
const image = (await page()).items[0].images[0];
|
|
expect(image.url).toMatch(/^https:\/\/storage\.example\.test\/comments\//);
|
|
expect(image.url).not.toContain("/api/comments/images/");
|
|
const url = `https://guide.example.test/api/comments/images/${image.id}`;
|
|
const response = await imageRoute.GET(new Request(url), { params: Promise.resolve({ id: image.id }) });
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe(image.url);
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
expect((await listComments({ target, viewer: null })).items).toEqual([]);
|
|
});
|
|
it("denies attachment HTTP access once the owning comment is hidden", async () => {
|
|
const root = await post("Image", { images: [png] });
|
|
const image = (await page()).items[0].images[0].id;
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
const response = await imageRoute.GET(new Request(`https://guide.example.test/api/comments/images/${image}`), { params: Promise.resolve({ id: image }) });
|
|
expect(response.status).toBe(404); expect(response.headers.get("cache-control")).toContain("no-store");
|
|
});
|
|
|
|
it("stores comment push subscriptions for the signed-in account and protects unsubscribe ownership", async () => {
|
|
expect((await subscriptionRoute.GET()).status).toBe(401);
|
|
session.get.mockResolvedValue({ user: { id: author.id } });
|
|
expect(await (await subscriptionRoute.GET()).json()).toEqual({ publicKey: "test-public-key" });
|
|
const request = (method: string, body: unknown, origin = "https://guide.example.test") => new Request("https://guide.example.test/api/comments/push-subscription", { method, headers: { Origin: origin, "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
expect((await subscriptionRoute.POST(request("POST", subscription))).status).toBe(204);
|
|
expect((await subscriptionRoute.POST(request("POST", { ...subscription, endpoint: "https://127.0.0.1/push" }))).status).toBe(400);
|
|
expect((await subscriptionRoute.POST(request("POST", subscription, "https://evil.example"))).status).toBe(403);
|
|
session.get.mockResolvedValue({ user: { id: other.id } });
|
|
expect((await subscriptionRoute.DELETE(request("DELETE", { endpoint: subscription.endpoint }))).status).toBe(204);
|
|
expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, subscription.endpoint))).toHaveLength(1);
|
|
session.get.mockResolvedValue({ user: { id: author.id } });
|
|
expect((await subscriptionRoute.DELETE(request("DELETE", { endpoint: subscription.endpoint }))).status).toBe(204);
|
|
expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, subscription.endpoint))).toHaveLength(0);
|
|
});
|
|
it("pushes only to the direct reply recipient and links to the exact nested reply", async () => {
|
|
await saveSubscription(author.id);
|
|
await saveSubscription(other.id, `${subscription.endpoint}-other`);
|
|
const root = await post("Root");
|
|
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
|
|
await sendCommentPush(reply.id);
|
|
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
|
|
expect(pushTransport.sendNotification.mock.calls[0][0].endpoint).toBe(subscription.endpoint);
|
|
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Reply", url: `/comment-test-${guideId}/comment?reply=${reply.id}#comment-${root.id}` });
|
|
pushTransport.sendNotification.mockClear();
|
|
const nested = await post("Nested", { reply: reply.id });
|
|
expect((await listComments({ viewer: admin, rootId: root.id })).items.find((item) => item.id === reply.id)?.hasReplies).toBe(true);
|
|
expect((await listComments({ viewer: admin, rootId: root.id })).items.find((item) => item.id === nested.id)?.hasReplies).toBe(false);
|
|
await sendCommentPush(nested.id);
|
|
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
|
|
expect(pushTransport.sendNotification.mock.calls[0][0].endpoint).toBe(`${subscription.endpoint}-other`);
|
|
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1]).url).toContain(`reply=${nested.id}#comment-${root.id}`);
|
|
pushTransport.sendNotification.mockClear();
|
|
await sendCommentPush((await post("Self", { reply: root.id })).id);
|
|
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
|
|
});
|
|
it("notifies subscribed admins about new comments, checks current roles, and skips the author", async () => {
|
|
await saveSubscription(admin.id, `${subscription.endpoint}-admin`);
|
|
await saveSubscription(other.id, `${subscription.endpoint}-reader`);
|
|
const root = await post("New root");
|
|
const adminCalls = () => pushTransport.sendNotification.mock.calls.filter(([recipient]) => recipient.endpoint === `${subscription.endpoint}-admin`);
|
|
await sendCommentPush(root.id);
|
|
expect(adminCalls()).toHaveLength(1);
|
|
expect(JSON.parse(adminCalls()[0][1])).toMatchObject({ body: "New root", icon: "/icon/nav/Comment.webp", url: `/admin/comments?target=${encodeURIComponent(target)}&comment=${root.id}` });
|
|
expect(pushTransport.sendNotification.mock.calls.some(([recipient]) => recipient.endpoint === `${subscription.endpoint}-reader`)).toBe(false);
|
|
pushTransport.sendNotification.mockClear();
|
|
await sendCommentPush((await publishComment(request("Admin's own"), admin, { target })).id);
|
|
expect(adminCalls()).toHaveLength(0);
|
|
try {
|
|
for (const state of [{ role: "user" }, { role: "admin", emailVerified: false }, { role: "admin", emailVerified: true, banned: true }]) {
|
|
await getDb().update(schema.users).set(state).where(eq(schema.users.id, admin.id));
|
|
await sendCommentPush(root.id);
|
|
expect(adminCalls()).toHaveLength(0);
|
|
}
|
|
} finally {
|
|
await getDb().update(schema.users).set({ role: "admin", emailVerified: true, banned: false }).where(eq(schema.users.id, admin.id));
|
|
}
|
|
}, 60000);
|
|
it("links Stygian admin notifications and suppresses removed comments and expired subscriptions", async () => {
|
|
const endpoint = `${subscription.endpoint}-admin`;
|
|
await saveSubscription(admin.id, endpoint);
|
|
const root = await publishComment(request("New Stygian comment"), author, { target: `stygian:${scheduleId}` });
|
|
const adminCalls = () => pushTransport.sendNotification.mock.calls.filter(([recipient]) => recipient.endpoint === endpoint);
|
|
await sendCommentPush(root.id);
|
|
expect(JSON.parse(adminCalls()[0][1]).url).toBe(`/admin/comments?target=stygian%3A${scheduleId}&comment=${root.id}`);
|
|
pushTransport.sendNotification.mockClear();
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
await sendCommentPush(root.id);
|
|
expect(adminCalls()).toHaveLength(0);
|
|
await mutateComment(root.id, admin, "moderation", false);
|
|
pushTransport.sendNotification.mockImplementation(async (recipient) => { if (recipient.endpoint === endpoint) throw { statusCode: 410 }; });
|
|
await sendCommentPush(root.id);
|
|
expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, endpoint))).toHaveLength(0);
|
|
await saveSubscription(admin.id, endpoint);
|
|
await mutateComment(root.id, author, "delete");
|
|
pushTransport.sendNotification.mockClear();
|
|
await sendCommentPush(root.id);
|
|
expect(adminCalls()).toHaveLength(0);
|
|
}, 60000);
|
|
it("does not push hidden or deleted replies and removes expired browser subscriptions", async () => {
|
|
await saveSubscription(author.id);
|
|
const root = await post("Root");
|
|
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
await sendCommentPush(reply.id);
|
|
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
|
|
await mutateComment(root.id, admin, "moderation", false);
|
|
pushTransport.sendNotification.mockRejectedValueOnce({ statusCode: 410 });
|
|
await sendCommentPush(reply.id);
|
|
expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.userId, author.id))).toHaveLength(0);
|
|
await mutateComment(root.id, author, "delete");
|
|
pushTransport.sendNotification.mockClear();
|
|
await sendCommentPush(reply.id);
|
|
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
|
|
});
|
|
it("persists comment notifications without push permission and keeps history scoped to its owner", async () => {
|
|
const root = await post("Bell root");
|
|
await sendCommentPush(root.id);
|
|
const adminUser = { ...admin, role: "admin", emailVerified: true };
|
|
const authorUser = { ...author, role: "user", emailVerified: true };
|
|
expect((await listNotifications(adminUser)).items[0]).toMatchObject({ commentId: root.id, kind: "comment_new", readAt: null });
|
|
expect((await listNotifications(authorUser)).unread).toBe(0);
|
|
const reply = await publishComment(request("Bell reply", { reply: root.id }), other, { target });
|
|
const key = process.env.WEB_PUSH_PUBLIC_KEY;
|
|
delete process.env.WEB_PUSH_PUBLIC_KEY;
|
|
try { await sendCommentPush(reply.id); await sendCommentPush(reply.id); } finally { process.env.WEB_PUSH_PUBLIC_KEY = key; }
|
|
const inbox = await listNotifications(authorUser);
|
|
expect(inbox.unread).toBe(1); expect(inbox.items).toHaveLength(1);
|
|
await readNotifications(other.id, inbox.items[0].id);
|
|
expect((await listNotifications(authorUser)).unread).toBe(1);
|
|
await readNotifications(author.id, inbox.items[0].id);
|
|
expect((await listNotifications(authorUser)).unread).toBe(0);
|
|
expect((await listNotifications({ ...adminUser, role: "user" })).items).toHaveLength(0);
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
expect((await listNotifications(authorUser)).items).toHaveLength(0);
|
|
await mutateComment(root.id, admin, "moderation", false);
|
|
vi.mocked(notifyNotificationChange).mockClear();
|
|
await mutateComment(root.id, author, "delete");
|
|
expect(notifyNotificationChange).toHaveBeenCalledWith(author.id);
|
|
expect(notifyNotificationChange).toHaveBeenCalledWith(admin.id);
|
|
expect((await listNotifications(adminUser)).items).toHaveLength(0);
|
|
}, 60000);
|
|
it("notifies admins for each saved edit without duplicating the same revision", async () => {
|
|
const root = await post("Original");
|
|
const adminUser = { ...admin, role: "admin", emailVerified: true };
|
|
await sendCommentPush(root.id, 1);
|
|
await readNotifications(admin.id);
|
|
session.get.mockResolvedValue({ user: { id: author.id } });
|
|
const response = await itemRoute.PATCH(request("Edited with image", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) });
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({ id: root.id, version: 2 });
|
|
expect(after).toHaveBeenCalledTimes(1);
|
|
const task = vi.mocked(after).mock.calls[0][0];
|
|
if (typeof task !== "function") throw new Error("Expected scheduled notification callback");
|
|
await task();
|
|
await task();
|
|
const feed = await listNotifications(adminUser);
|
|
expect(feed.unread).toBe(1);
|
|
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", eventKey: `comment:${root.id}:edit:2`, body: "Edited with image", readAt: null });
|
|
await publishComment(request("Third revision", { version: 2 }), author, { id: root.id });
|
|
await sendCommentPush(root.id, 3);
|
|
expect((await listNotifications(adminUser)).unread).toBe(2);
|
|
// A delayed notification must use the revision that triggered it.
|
|
await sendCommentPush(root.id, 2);
|
|
expect((await listNotifications(adminUser)).unread).toBe(2);
|
|
});
|
|
it("notifies the direct recipient of edited replies and suppresses hidden edits", async () => {
|
|
await saveSubscription(author.id);
|
|
const root = await post("Root");
|
|
const reply = await publishComment(request("Reply", { reply: root.id }), other, { target });
|
|
await sendCommentPush(reply.id, 1);
|
|
await readNotifications(author.id);
|
|
pushTransport.sendNotification.mockClear();
|
|
await publishComment(request("Edited reply", { version: 1, images: [png] }), other, { id: reply.id });
|
|
await sendCommentPush(reply.id, 2);
|
|
expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Edited reply", title: "Reader แก้ไขคำตอบกลับความคิดเห็นของคุณ" });
|
|
const feed = await listNotifications({ ...author, role: "user", emailVerified: true });
|
|
expect(feed.unread).toBe(1);
|
|
expect(feed.items[0]).toMatchObject({ kind: "comment_edit", body: "Edited reply" });
|
|
expect(feed.items[0].url).toContain(`reply=${reply.id}#comment-${root.id}`);
|
|
await publishComment(request("Hidden revision", { version: 2 }), other, { id: reply.id });
|
|
await mutateComment(root.id, admin, "moderation", true);
|
|
pushTransport.sendNotification.mockClear();
|
|
await sendCommentPush(reply.id, 3);
|
|
expect(pushTransport.sendNotification).not.toHaveBeenCalled();
|
|
expect((await getDb().select().from(schema.notifications).where(eq(schema.notifications.eventKey, `comment:${reply.id}:edit:3`)))).toHaveLength(0);
|
|
});
|
|
it("pages notification history without dropping notifications sharing a timestamp", async () => {
|
|
const user = { ...author, role: "user", emailVerified: true };
|
|
for (let index = 0; index < 25; index++) await createNotifications([{ userId: author.id, url: "/" }], { eventKey: `fixture:${index}`, kind: "future", title: "Future notification", body: "Text" });
|
|
await getDb().update(schema.notifications).set({ createdAt: new Date("2026-01-01T00:00:00Z") }).where(eq(schema.notifications.userId, author.id));
|
|
const first = await listNotifications(user);
|
|
const [time, id] = first.nextCursor!.split("|");
|
|
const second = await listNotifications(user, { time, id });
|
|
expect(first.items).toHaveLength(20); expect(second.items).toHaveLength(5);
|
|
expect(new Set([...first.items, ...second.items].map(item => item.id)).size).toBe(25);
|
|
await readNotifications(author.id);
|
|
expect((await listNotifications(user)).unread).toBe(0);
|
|
}, 60000);
|
|
it("records commission notifications for customers and admins without browser subscriptions", async () => {
|
|
const ticketId = randomUUID(), messageId = randomUUID(), checkoutId = randomUUID();
|
|
await getDb().insert(schema.commissionCheckouts).values({ id: checkoutId, userId: author.id, amountBaht: 20, request: { teams: [], weapons: [], constellations: [{ characterKey: "test", levels: [0] }] } });
|
|
await getDb().insert(schema.commissionTickets).values({ id: ticketId, checkoutId, userId: author.id, title: "Notification ticket" });
|
|
await getDb().insert(schema.commissionMessages).values({ id: messageId, ticketId, authorId: other.id, text: "Commission bell" });
|
|
try {
|
|
await sendCommissionMessagePush({ id: messageId, ticketId, customerId: author.id, authorId: other.id, authorName: "Reader", ticketTitle: "Ticket", text: "Commission bell" });
|
|
expect((await listNotifications({ ...author, role: "user", emailVerified: true })).items[0]).toMatchObject({ kind: "commission_message", url: `/commission/tickets/${ticketId}` });
|
|
expect((await listNotifications({ ...admin, role: "admin", emailVerified: true })).items[0]).toMatchObject({ kind: "commission_message", url: `/admin/commission/${ticketId}` });
|
|
expect((await listNotifications({ ...other, role: "user", emailVerified: true })).items).toHaveLength(0);
|
|
} finally {
|
|
await getDb().delete(schema.commissionMessages).where(eq(schema.commissionMessages.id, messageId));
|
|
await getDb().delete(schema.commissionTickets).where(eq(schema.commissionTickets.id, ticketId));
|
|
await getDb().delete(schema.commissionCheckouts).where(eq(schema.commissionCheckouts.id, checkoutId));
|
|
}
|
|
}, 60000);
|
|
it("counts unread comments per guide for admins and clears only the selected guide", async () => {
|
|
await markCommentsRead(admin, "all");
|
|
await post("Unread");
|
|
await publishComment(request("Own admin comment"), admin, { target });
|
|
await publishComment(request("Other guide"), author, { target: `guide:${otherGuideId}` });
|
|
expect(await unreadCommentCounts(admin)).toEqual({ total: 2, counts: { [target]: 1, [`guide:${otherGuideId}`]: 1 } });
|
|
await expect(unreadCommentCounts(author)).rejects.toMatchObject({ status: 403 });
|
|
await expect(markCommentsRead(author, "all")).rejects.toMatchObject({ status: 403 });
|
|
await markCommentsRead(admin, target);
|
|
expect(await unreadCommentCounts(admin)).toEqual({ total: 1, counts: { [`guide:${otherGuideId}`]: 1 } });
|
|
await post("New after reading");
|
|
expect((await unreadCommentCounts(admin)).counts[target]).toBe(1);
|
|
await markCommentsRead(admin, "all");
|
|
expect(await unreadCommentCounts(admin)).toEqual({ total: 0, counts: {} });
|
|
});
|
|
|
|
});
|