mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(notifications): purge provider row when deleting a notification
This commit is contained in:
parent
1572008cdf
commit
76387c5fc2
229
apps/dokploy/__test__/services/notification-remove.test.ts
Normal file
229
apps/dokploy/__test__/services/notification-remove.test.ts
Normal file
@ -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<unknown[]>;
|
||||
};
|
||||
|
||||
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<unknown>) => 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<string, unknown>
|
||||
)[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();
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user