diff --git a/apps/dokploy/__test__/services/notification-remove.test.ts b/apps/dokploy/__test__/services/notification-remove.test.ts new file mode 100644 index 000000000..d843be886 --- /dev/null +++ b/apps/dokploy/__test__/services/notification-remove.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type DeleteCall = { + table: unknown; + cond: unknown; +}; + +const recorder = vi.hoisted(() => { + const state = { + deleteCalls: [] as DeleteCall[], + notificationsReturning: [] as unknown[], + }; + + type Chain = { + where: (cond: unknown) => Chain; + returning: () => Promise; + }; + + const buildChain = (table: unknown): Chain => { + const self: Chain = { + where(cond: unknown) { + state.deleteCalls.push({ table, cond }); + return self; + }, + returning() { + return Promise.resolve(state.notificationsReturning); + }, + }; + return self; + }; + + const tx = { delete: vi.fn((table: unknown) => buildChain(table)) }; + const dbMock = { + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => cb(tx)), + delete: vi.fn(), + }; + + return { + dbMock, + deleteCalls: state.deleteCalls, + reset: () => { + state.deleteCalls.length = 0; + state.notificationsReturning = []; + }, + setNotificationReturning: (rows: unknown[]) => { + state.notificationsReturning = rows; + }, + }; +}); + +vi.mock("@dokploy/server/db", () => ({ db: recorder.dbMock })); + +import { + custom, + discord, + email, + gotify, + lark, + mattermost, + notifications, + ntfy, + pushover, + resend, + slack, + teams, + telegram, +} from "@dokploy/server/db/schema"; +import { removeNotificationById } from "@dokploy/server/services/notification"; + +const PROVIDERS = [ + { type: "slack", table: slack, idCol: "slackId" }, + { type: "telegram", table: telegram, idCol: "telegramId" }, + { type: "discord", table: discord, idCol: "discordId" }, + { type: "email", table: email, idCol: "emailId" }, + { type: "resend", table: resend, idCol: "resendId" }, + { type: "gotify", table: gotify, idCol: "gotifyId" }, + { type: "ntfy", table: ntfy, idCol: "ntfyId" }, + { type: "mattermost", table: mattermost, idCol: "mattermostId" }, + { type: "custom", table: custom, idCol: "customId" }, + { type: "lark", table: lark, idCol: "larkId" }, + { type: "pushover", table: pushover, idCol: "pushoverId" }, + { type: "teams", table: teams, idCol: "teamsId" }, +] as const; + +const ALL_PROVIDER_TABLES = PROVIDERS.map((p) => p.table); + +const chunkInfo = (cond: unknown): { column: unknown; value: unknown } => { + const chunks = + (cond as { queryChunks?: unknown[] } | null)?.queryChunks ?? []; + let column: unknown; + let value: unknown; + for (const c of chunks) { + if (!c || typeof c !== "object") { + continue; + } + if (value === undefined && "value" in c && "encoder" in c) { + value = (c as { value: unknown }).value; + } else if (column === undefined && "table" in c && "dataType" in c) { + column = c; + } + } + return { column, value }; +}; + +const callsFor = (table: unknown): DeleteCall[] => + recorder.deleteCalls.filter((c) => c.table === table); + +beforeEach(() => { + recorder.reset(); + vi.clearAllMocks(); +}); + +describe("removeNotificationById", () => { + describe("provider row cleanup", () => { + it.each(PROVIDERS.map((p) => [p.type, p] as const))( + "deletes the linked %s provider row inside the transaction", + async (type, provider) => { + const providerId = `${type}-row-id`; + const deletedRow = { + notificationId: "notif-1", + name: "n", + notificationType: type, + organizationId: "org", + [provider.idCol]: providerId, + }; + recorder.setNotificationReturning([deletedRow]); + + const result = await removeNotificationById("notif-1"); + + expect(result).toBe(deletedRow); + expect(recorder.dbMock.transaction).toHaveBeenCalledTimes(1); + + const notifCalls = callsFor(notifications); + expect(notifCalls).toHaveLength(1); + const notifInfo = chunkInfo(notifCalls[0]?.cond); + expect(notifInfo.column).toBe(notifications.notificationId); + expect(notifInfo.value).toBe("notif-1"); + + const providerCalls = callsFor(provider.table); + expect(providerCalls).toHaveLength(1); + const providerInfo = chunkInfo(providerCalls[0]?.cond); + const expectedColumn = ( + provider.table as unknown as Record + )[provider.idCol]; + expect(providerInfo.column).toBe(expectedColumn); + expect(providerInfo.value).toBe(providerId); + + for (const other of ALL_PROVIDER_TABLES) { + if (other === provider.table) { + continue; + } + expect(callsFor(other)).toHaveLength(0); + } + + expect(recorder.dbMock.delete).not.toHaveBeenCalled(); + }, + ); + }); + + it("returns undefined and deletes no provider row when the notification does not exist", async () => { + recorder.setNotificationReturning([]); + + const result = await removeNotificationById("missing-id"); + + expect(result).toBeUndefined(); + expect(recorder.dbMock.transaction).toHaveBeenCalledTimes(1); + expect(callsFor(notifications)).toHaveLength(1); + for (const table of ALL_PROVIDER_TABLES) { + expect(callsFor(table)).toHaveLength(0); + } + }); + + it("skips the provider delete without throwing when the matching id is null", async () => { + const deletedRow = { + notificationId: "notif-2", + name: "n", + notificationType: "slack", + organizationId: "org", + slackId: null, + }; + recorder.setNotificationReturning([deletedRow]); + + const result = await removeNotificationById("notif-2"); + + expect(result).toBe(deletedRow); + expect(callsFor(notifications)).toHaveLength(1); + for (const table of ALL_PROVIDER_TABLES) { + expect(callsFor(table)).toHaveLength(0); + } + }); + + it("deletes only the provider matching notificationType, not a stray id of another type", async () => { + const deletedRow = { + notificationId: "notif-3", + name: "n", + notificationType: "slack", + organizationId: "org", + slackId: null, + discordId: "stray-discord-id", + }; + recorder.setNotificationReturning([deletedRow]); + + const result = await removeNotificationById("notif-3"); + + expect(result).toBe(deletedRow); + expect(callsFor(notifications)).toHaveLength(1); + expect(callsFor(slack)).toHaveLength(0); + expect(callsFor(discord)).toHaveLength(0); + for (const table of ALL_PROVIDER_TABLES) { + expect(callsFor(table)).toHaveLength(0); + } + }); + + it("does not call db.delete directly on the top-level db", async () => { + recorder.setNotificationReturning([ + { + notificationId: "notif-4", + name: "n", + notificationType: "slack", + organizationId: "org", + slackId: "slack-id", + }, + ]); + + await removeNotificationById("notif-4"); + + expect(recorder.dbMock.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/services/notification.ts b/packages/server/src/services/notification.ts index c0b7fdf38..3727082c0 100644 --- a/packages/server/src/services/notification.ts +++ b/packages/server/src/services/notification.ts @@ -838,12 +838,90 @@ export const findNotificationById = async (notificationId: string) => { }; export const removeNotificationById = async (notificationId: string) => { - const result = await db - .delete(notifications) - .where(eq(notifications.notificationId, notificationId)) - .returning(); + return db.transaction(async (tx) => { + const deleted = await tx + .delete(notifications) + .where(eq(notifications.notificationId, notificationId)) + .returning() + .then((value) => value[0]); - return result[0]; + if (!deleted) { + return undefined; + } + + switch (deleted.notificationType) { + case "slack": + if (deleted.slackId) { + await tx.delete(slack).where(eq(slack.slackId, deleted.slackId)); + } + break; + case "telegram": + if (deleted.telegramId) { + await tx + .delete(telegram) + .where(eq(telegram.telegramId, deleted.telegramId)); + } + break; + case "discord": + if (deleted.discordId) { + await tx + .delete(discord) + .where(eq(discord.discordId, deleted.discordId)); + } + break; + case "email": + if (deleted.emailId) { + await tx.delete(email).where(eq(email.emailId, deleted.emailId)); + } + break; + case "resend": + if (deleted.resendId) { + await tx.delete(resend).where(eq(resend.resendId, deleted.resendId)); + } + break; + case "gotify": + if (deleted.gotifyId) { + await tx.delete(gotify).where(eq(gotify.gotifyId, deleted.gotifyId)); + } + break; + case "ntfy": + if (deleted.ntfyId) { + await tx.delete(ntfy).where(eq(ntfy.ntfyId, deleted.ntfyId)); + } + break; + case "mattermost": + if (deleted.mattermostId) { + await tx + .delete(mattermost) + .where(eq(mattermost.mattermostId, deleted.mattermostId)); + } + break; + case "custom": + if (deleted.customId) { + await tx.delete(custom).where(eq(custom.customId, deleted.customId)); + } + break; + case "lark": + if (deleted.larkId) { + await tx.delete(lark).where(eq(lark.larkId, deleted.larkId)); + } + break; + case "pushover": + if (deleted.pushoverId) { + await tx + .delete(pushover) + .where(eq(pushover.pushoverId, deleted.pushoverId)); + } + break; + case "teams": + if (deleted.teamsId) { + await tx.delete(teams).where(eq(teams.teamsId, deleted.teamsId)); + } + break; + } + + return deleted; + }); }; export const createLarkNotification = async (