diff --git a/apps/web/__tests__/unit/delete-comment-notifications.test.ts b/apps/web/__tests__/unit/delete-comment-notifications.test.ts new file mode 100644 index 00000000000..e4b9e032b80 --- /dev/null +++ b/apps/web/__tests__/unit/delete-comment-notifications.test.ts @@ -0,0 +1,112 @@ +import { Comment, Video } from "@cap/web-domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + query: vi.fn(), + getCurrentUser: vi.fn(), + revalidatePath: vi.fn(), +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const client = drizzle(mocks.query); + return { + db: () => ({ + transaction: (callback: (tx: typeof client) => Promise) => + callback(client), + }), + }; +}); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: mocks.getCurrentUser, +})); +vi.mock("@cap/web-backend", () => ({ Storage: {}, VideosRepo: {} })); +vi.mock("@/lib/server", () => ({})); +vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath })); + +import { deleteComment } from "@/actions/videos/delete-comment"; + +const commentId = Comment.CommentId.make("reaction"); +const videoId = Video.VideoId.make("video"); + +beforeEach(() => { + mocks.getCurrentUser.mockResolvedValue({ id: "viewer" }); + mocks.query.mockReset(); + mocks.query.mockResolvedValue({ rows: [{ affectedRows: 1, insertId: 0 }] }); + mocks.query.mockResolvedValueOnce({ + rows: [[commentId, videoId, null, null]], + }); +}); + +describe("deleted comment notifications", () => { + it.each([null, Comment.CommentId.make("")])( + "removes the reaction notification for a root with parent %j", + async (parentId) => { + await expect( + deleteComment({ commentId, videoId, parentId }), + ).resolves.toEqual({ success: true }); + + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining("delete from `comments`"), + [commentId, "viewer"], + "execute", + ); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining( + "where (`notifications`.`type` in (?, ?) and JSON_EXTRACT(`notifications`.`data`, '$.comment.id') = ?)", + ), + ["comment", "reaction", commentId], + "execute", + ); + expect(mocks.revalidatePath).toHaveBeenCalledWith(`/s/${videoId}`); + }, + ); + + it("removes reply notifications for replies to a deleted root", async () => { + await deleteComment({ commentId, videoId, parentId: null }); + + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining( + "where (`notifications`.`type` = ? and JSON_EXTRACT(`notifications`.`data`, '$.parentCommentId') = ?)", + ), + ["reply", commentId], + "execute", + ); + }); + + it("still removes only the reply notification when deleting a reply", async () => { + await deleteComment({ + commentId, + videoId, + parentId: Comment.CommentId.make("parent"), + }); + + const deletes = mocks.query.mock.calls.filter(([query]) => + query.startsWith("delete from `notifications`"), + ); + expect(deletes).toEqual([ + [ + expect.stringContaining("'$.comment.id'"), + ["reply", commentId], + "execute", + ], + ]); + }); + + it("does not delete notifications for a missing or unowned comment", async () => { + mocks.query.mockReset(); + mocks.query.mockResolvedValue({ rows: [] }); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + deleteComment({ commentId, videoId, parentId: null }), + ).rejects.toThrow("Comment not found or you don't have permission"); + + expect(mocks.query).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("`comments`.`authorId` = ?"), + [commentId, "viewer", 1], + "all", + ); + expect(mocks.revalidatePath).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/__tests__/unit/reply-notification.test.ts b/apps/web/__tests__/unit/reply-notification.test.ts index 4202e92c752..01cad3820b0 100644 --- a/apps/web/__tests__/unit/reply-notification.test.ts +++ b/apps/web/__tests__/unit/reply-notification.test.ts @@ -81,6 +81,13 @@ describe("reply notifications", () => { expect(inserts[0]?.[1]).toEqual( expect.arrayContaining(["comment-author", "reply-org", "reply"]), ); + const data = inserts[0]?.[1].find( + (param: unknown) => typeof param === "string" && param.startsWith("{"), + ); + expect(JSON.parse(data)).toMatchObject({ + comment: { id: "reply" }, + parentCommentId: "parent", + }); expect(mocks.sendEmail).not.toHaveBeenCalled(); }, ); diff --git a/apps/web/actions/videos/delete-comment.ts b/apps/web/actions/videos/delete-comment.ts index 29b14bb49ad..397f1258a1a 100644 --- a/apps/web/actions/videos/delete-comment.ts +++ b/apps/web/actions/videos/delete-comment.ts @@ -5,7 +5,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { comments, notifications } from "@cap/database/schema"; import { Storage, VideosRepo } from "@cap/web-backend"; import type { Comment, Video } from "@cap/web-domain"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { Effect, Option } from "effect"; import { revalidatePath } from "next/cache"; import * as EffectRuntime from "@/lib/server"; @@ -84,7 +84,7 @@ export async function deleteComment({ .delete(notifications) .where( and( - eq(notifications.type, "comment"), + inArray(notifications.type, ["comment", "reaction"]), sql`JSON_EXTRACT(${notifications.data}, '$.comment.id') = ${commentId}`, ), ); @@ -94,7 +94,7 @@ export async function deleteComment({ .where( and( eq(notifications.type, "reply"), - sql`JSON_EXTRACT(${notifications.data}, '$.comment.parentCommentId') = ${commentId}`, + sql`JSON_EXTRACT(${notifications.data}, '$.parentCommentId') = ${commentId}`, ), ); } diff --git a/apps/web/app/api/mobile/[...route]/route.ts b/apps/web/app/api/mobile/[...route]/route.ts index 6cdbe00c1ad..0480b5420f3 100644 --- a/apps/web/app/api/mobile/[...route]/route.ts +++ b/apps/web/app/api/mobile/[...route]/route.ts @@ -3778,7 +3778,7 @@ const ApiLive = HttpApiBuilder.api(Mobile.MobileApiContract).pipe( .where( and( eq(Db.notifications.type, "reply"), - sql`JSON_EXTRACT(${Db.notifications.data}, '$.comment.parentCommentId') = ${path.id}`, + sql`JSON_EXTRACT(${Db.notifications.data}, '$.parentCommentId') = ${path.id}`, ), ); }