Merge pull request #3534 from egeoztass/feat/twilio-sms-notification
feat: add Twilio SMS notification channel
This commit is contained in:
16 files changed
+409
-8
No files matched your search
@@ -60,6 +60,16 @@ function buildDefaults(data: Notification | null): NotificationFormData {
|
||||
address: data.address || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "twilio") {
|
||||
return {
|
||||
type: "twilio",
|
||||
notificationName: data.notificationName || "",
|
||||
accountSid: data.accountSid || "",
|
||||
accessToken: data.accessToken || "",
|
||||
phone: data.phone || "",
|
||||
twilioPhoneNumber: data.twilioPhoneNumber || "",
|
||||
};
|
||||
}
|
||||
if (data?.type === "pushover") {
|
||||
return {
|
||||
type: "pushover",
|
||||
|
||||
@@ -149,7 +149,8 @@ const NotificationsCreatePage = () => {
|
||||
/>
|
||||
{watchedType !== "matrix" &&
|
||||
watchedType !== "telegram" &&
|
||||
watchedType !== "pushover" && (
|
||||
watchedType !== "pushover" &&
|
||||
watchedType !== "twilio" && (
|
||||
<ConfigBox
|
||||
title={addressConfig.title}
|
||||
subtitle={addressConfig.description}
|
||||
@@ -262,6 +263,88 @@ const NotificationsCreatePage = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "twilio" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.twilio.title")}
|
||||
subtitle={t("pages.notifications.form.twilio.description")}
|
||||
rightContent={
|
||||
<Stack spacing={theme.spacing(8)}>
|
||||
<Controller
|
||||
name="accountSid"
|
||||
control={control}
|
||||
defaultValue={"accountSid" in defaults ? defaults.accountSid : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionAccountSid")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.twilio.placeholderAccountSid"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="accessToken"
|
||||
control={control}
|
||||
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionAuthToken")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.twilio.placeholderAuthToken"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="twilioPhoneNumber"
|
||||
control={control}
|
||||
defaultValue={
|
||||
"twilioPhoneNumber" in defaults ? defaults.twilioPhoneNumber : ""
|
||||
}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionFromNumber")}
|
||||
placeholder={t(
|
||||
"pages.notifications.form.twilio.placeholderFromNumber"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="phone"
|
||||
control={control}
|
||||
defaultValue={"phone" in defaults ? defaults.phone : ""}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("pages.notifications.form.twilio.optionToNumber")}
|
||||
placeholder={t("pages.notifications.form.twilio.placeholderToNumber")}
|
||||
fullWidth
|
||||
error={!!fieldState.error}
|
||||
helperText={fieldState.error?.message ?? ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{watchedType === "matrix" && (
|
||||
<ConfigBox
|
||||
title={t("pages.notifications.form.matrix.title")}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const NotificationChannels = [
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
@@ -22,6 +23,8 @@ export interface Notification {
|
||||
homeserverUrl?: string;
|
||||
roomId?: string;
|
||||
accessToken?: string;
|
||||
accountSid?: string;
|
||||
twilioPhoneNumber?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -62,6 +62,14 @@ const pushoverSchema = baseSchema.extend({
|
||||
accessToken: z.string().min(1, "App token is required"),
|
||||
});
|
||||
|
||||
const twilioSchema = baseSchema.extend({
|
||||
type: z.literal("twilio"),
|
||||
accountSid: z.string().min(1, "Account SID is required"),
|
||||
accessToken: z.string().min(1, "Auth token is required"),
|
||||
phone: z.string().min(1, "Recipient phone number is required"),
|
||||
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
emailSchema,
|
||||
slackSchema,
|
||||
@@ -72,6 +80,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
teamsSchema,
|
||||
telegramSchema,
|
||||
pushoverSchema,
|
||||
twilioSchema,
|
||||
]);
|
||||
|
||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||
@@ -968,6 +968,18 @@
|
||||
"placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi",
|
||||
"optionUserKey": "User key",
|
||||
"placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
|
||||
},
|
||||
"twilio": {
|
||||
"title": "Twilio SMS configuration",
|
||||
"description": "Configure Twilio to send SMS notifications to a phone number.",
|
||||
"optionAccountSid": "Account SID",
|
||||
"placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"optionAuthToken": "Auth token",
|
||||
"placeholderAuthToken": "your_auth_token",
|
||||
"optionFromNumber": "From number (Twilio)",
|
||||
"placeholderFromNumber": "+15551234567",
|
||||
"optionToNumber": "To number (recipient)",
|
||||
"placeholderToNumber": "+15559876543"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TeamsProvider,
|
||||
TelegramProvider,
|
||||
PushoverProvider,
|
||||
TwilioProvider,
|
||||
// Interfaces
|
||||
INetworkService,
|
||||
IEmailService,
|
||||
@@ -300,6 +301,7 @@ export const initializeServices = async ({
|
||||
const teamsProvider = new TeamsProvider(logger);
|
||||
const telegramProvider = new TelegramProvider(logger);
|
||||
const pushoverProvider = new PushoverProvider(logger);
|
||||
const twilioProvider = new TwilioProvider(logger);
|
||||
|
||||
const notificationsService = new NotificationsService(
|
||||
notificationsRepository,
|
||||
@@ -313,6 +315,7 @@ export const initializeServices = async ({
|
||||
teamsProvider,
|
||||
telegramProvider,
|
||||
pushoverProvider,
|
||||
twilioProvider,
|
||||
settingsService,
|
||||
logger,
|
||||
notificationMessageBuilder
|
||||
|
||||
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[],
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[],
|
||||
required: true,
|
||||
},
|
||||
notificationName: {
|
||||
@@ -37,6 +37,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
homeserverUrl: { type: String },
|
||||
roomId: { type: String },
|
||||
accessToken: { type: String },
|
||||
accountSid: { type: String },
|
||||
twilioPhoneNumber: { type: String },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
||||
@@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository {
|
||||
homeserverUrl: doc.homeserverUrl ?? undefined,
|
||||
roomId: doc.roomId ?? undefined,
|
||||
accessToken: doc.accessToken ?? undefined,
|
||||
accountSid: doc.accountSid ?? undefined,
|
||||
twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined,
|
||||
createdAt: toDateString(doc.createdAt),
|
||||
updatedAt: toDateString(doc.updatedAt),
|
||||
};
|
||||
|
||||
@@ -14,20 +14,22 @@ interface NotificationRow {
|
||||
homeserver_url: string | null;
|
||||
room_id: string | null;
|
||||
access_token: string | null;
|
||||
account_sid: string | null;
|
||||
twilio_phone_number: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone,
|
||||
homeserver_url, room_id, access_token, created_at, updated_at`;
|
||||
homeserver_url, room_id, access_token, account_sid, twilio_phone_number, created_at, updated_at`;
|
||||
|
||||
export class TimescaleNotificationsRepository implements INotificationsRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
create = async (data: Partial<Notification>): Promise<Notification> => {
|
||||
const result = await this.pool.query<NotificationRow>(
|
||||
`INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token, account_sid, twilio_phone_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING ${COLUMNS}`,
|
||||
[
|
||||
data.userId,
|
||||
@@ -39,6 +41,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor
|
||||
data.homeserverUrl ?? null,
|
||||
data.roomId ?? null,
|
||||
data.accessToken ?? null,
|
||||
data.accountSid ?? null,
|
||||
data.twilioPhoneNumber ?? null,
|
||||
]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -83,6 +87,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor
|
||||
["homeserverUrl", "homeserver_url"],
|
||||
["roomId", "room_id"],
|
||||
["accessToken", "access_token"],
|
||||
["accountSid", "account_sid"],
|
||||
["twilioPhoneNumber", "twilio_phone_number"],
|
||||
];
|
||||
|
||||
for (const [key, column] of fieldMap) {
|
||||
@@ -134,6 +140,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor
|
||||
homeserverUrl: row.homeserver_url ?? undefined,
|
||||
roomId: row.room_id ?? undefined,
|
||||
accessToken: row.access_token ?? undefined,
|
||||
accountSid: row.account_sid ?? undefined,
|
||||
twilioPhoneNumber: row.twilio_phone_number ?? undefined,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from "@/service/infrastructure/notificationProviders/teams.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/telegram.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/pushover.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/twilio.js";
|
||||
|
||||
// System services
|
||||
export * from "@/service/system/settingsService.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
const SERVICE_NAME = "TwilioProvider";
|
||||
import type { Notification } from "@/types/index.js";
|
||||
import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import got from "got";
|
||||
|
||||
export class TwilioProvider extends NotificationProvider {
|
||||
async sendTestAlert(notification: Partial<Notification>): Promise<boolean> {
|
||||
if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, {
|
||||
form: {
|
||||
To: notification.phone,
|
||||
From: notification.twilioPhoneNumber,
|
||||
Body: getTestMessage(),
|
||||
},
|
||||
username: notification.accountSid,
|
||||
password: notification.accessToken,
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : "unknown error";
|
||||
const errStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.warn({
|
||||
message: "Twilio test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: errStack,
|
||||
details: { error: errMsg },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||
if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = this.buildSmsText(message);
|
||||
|
||||
try {
|
||||
await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, {
|
||||
form: {
|
||||
To: notification.phone,
|
||||
From: notification.twilioPhoneNumber,
|
||||
Body: text,
|
||||
},
|
||||
username: notification.accountSid,
|
||||
password: notification.accessToken,
|
||||
...this.gotRequestOptions(),
|
||||
});
|
||||
|
||||
this.logger.info({
|
||||
message: "Twilio SMS notification sent",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : "unknown error";
|
||||
const errStack = error instanceof Error ? error.stack : undefined;
|
||||
this.logger.warn({
|
||||
message: "Twilio SMS alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: errStack,
|
||||
details: { error: errMsg },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private buildSmsText(message: NotificationMessage): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(message.content.title);
|
||||
lines.push(message.content.summary);
|
||||
lines.push("");
|
||||
lines.push(`URL: ${message.monitor.url}`);
|
||||
lines.push(`Status: ${message.monitor.status}`);
|
||||
|
||||
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||
message.content.thresholds.forEach((breach) => {
|
||||
lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`);
|
||||
});
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export class NotificationsService implements INotificationsService {
|
||||
private teamsProvider: INotificationProvider;
|
||||
private telegramProvider: INotificationProvider;
|
||||
private pushoverProvider: INotificationProvider;
|
||||
private twilioProvider: INotificationProvider;
|
||||
private logger: ILogger;
|
||||
private settingsService: ISettingsService;
|
||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||
@@ -51,6 +52,7 @@ export class NotificationsService implements INotificationsService {
|
||||
teamsProvider: INotificationProvider,
|
||||
telegramProvider: INotificationProvider,
|
||||
pushoverProvider: INotificationProvider,
|
||||
twilioProvider: INotificationProvider,
|
||||
settingsService: ISettingsService,
|
||||
logger: ILogger,
|
||||
notificationMessageBuilder: INotificationMessageBuilder
|
||||
@@ -66,6 +68,7 @@ export class NotificationsService implements INotificationsService {
|
||||
this.teamsProvider = teamsProvider;
|
||||
this.telegramProvider = telegramProvider;
|
||||
this.pushoverProvider = pushoverProvider;
|
||||
this.twilioProvider = twilioProvider;
|
||||
this.settingsService = settingsService;
|
||||
this.logger = logger;
|
||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||
@@ -107,6 +110,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.telegramProvider.sendMessage!(notification, notificationMessage);
|
||||
case "pushover":
|
||||
return await this.pushoverProvider.sendMessage!(notification, notificationMessage);
|
||||
case "twilio":
|
||||
return await this.twilioProvider.sendMessage!(notification, notificationMessage);
|
||||
default:
|
||||
this.logger.warn({
|
||||
message: `Unknown notification type: ${notification.type}`,
|
||||
@@ -171,6 +176,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.telegramProvider.sendTestAlert(notification);
|
||||
case "pushover":
|
||||
return await this.pushoverProvider.sendTestAlert(notification);
|
||||
case "twilio":
|
||||
return await this.twilioProvider.sendTestAlert(notification);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const;
|
||||
export const NotificationChannels = [
|
||||
"email",
|
||||
"slack",
|
||||
"discord",
|
||||
"webhook",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
"telegram",
|
||||
"pushover",
|
||||
"twilio",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
export interface Notification {
|
||||
@@ -12,6 +23,8 @@ export interface Notification {
|
||||
homeserverUrl?: string;
|
||||
roomId?: string;
|
||||
accessToken?: string;
|
||||
accountSid?: string;
|
||||
twilioPhoneNumber?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -79,6 +79,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
address: z.string().min(1, "User key is required"),
|
||||
accessToken: z.string().min(1, "App token is required"),
|
||||
}),
|
||||
// Twilio SMS notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("twilio"),
|
||||
accountSid: z.string().min(1, "Account SID is required"),
|
||||
accessToken: z.string().min(1, "Auth token is required"),
|
||||
phone: z.string().min(1, "Recipient phone number is required"),
|
||||
twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const testNotificationBodyValidation = createNotificationBodyValidation;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createMockLogger } from "../../../helpers/createMockLogger.ts";
|
||||
import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts";
|
||||
import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
|
||||
|
||||
const mockGotPost = jest.fn().mockResolvedValue({});
|
||||
jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
|
||||
|
||||
const { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts");
|
||||
|
||||
const createProvider = () => {
|
||||
const logger = createMockLogger();
|
||||
return { provider: new TwilioProvider(logger as any), logger };
|
||||
};
|
||||
|
||||
const makeTwilioNotification = (overrides?: Record<string, unknown>) =>
|
||||
makeNotification({
|
||||
phone: "+15559876543",
|
||||
twilioPhoneNumber: "+15551234567",
|
||||
accountSid: "ACtest123",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
testNotificationProviderContract("TwilioProvider", {
|
||||
create: () => {
|
||||
mockGotPost.mockResolvedValue({});
|
||||
return createProvider().provider;
|
||||
},
|
||||
makeNotification: () => makeTwilioNotification(),
|
||||
});
|
||||
|
||||
describe("TwilioProvider", () => {
|
||||
beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("sends to Twilio API and returns true", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification())).toBe(true);
|
||||
expect(mockGotPost).toHaveBeenCalledWith(
|
||||
expect.stringContaining("api.twilio.com/2010-04-01/Accounts/"),
|
||||
expect.objectContaining({
|
||||
form: expect.objectContaining({ To: "+15559876543", From: "+15551234567" }),
|
||||
username: expect.any(String),
|
||||
password: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when accountSid is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accountSid: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accessToken: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when phone is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when twilioPhoneNumber (from number) is missing", async () => {
|
||||
expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ twilioPhoneNumber: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } }));
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendTestAlert", async () => {
|
||||
mockGotPost.mockRejectedValue("string error");
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("sends SMS and returns true", async () => {
|
||||
const { provider } = createProvider();
|
||||
expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(true);
|
||||
const form = mockGotPost.mock.calls[0][1].form;
|
||||
expect(form.Body).toContain("Monitor Down");
|
||||
expect(form.To).toBe("+15559876543");
|
||||
expect(form.From).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("returns false when accountSid is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accountSid: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when accessToken is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when phone is missing", async () => {
|
||||
expect(await createProvider().provider.sendMessage(makeTwilioNotification({ phone: "" }) as any, makeMessage())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false and logs on error", async () => {
|
||||
mockGotPost.mockRejectedValue(new Error("fail"));
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" }));
|
||||
});
|
||||
|
||||
it("handles non-Error thrown values in sendMessage", async () => {
|
||||
mockGotPost.mockRejectedValue(42);
|
||||
const { provider, logger } = createProvider();
|
||||
expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } }));
|
||||
});
|
||||
|
||||
it("includes thresholds in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeTwilioNotification() as any, makeMessageWithThresholds());
|
||||
expect(mockGotPost.mock.calls[0][1].form.Body).toContain("CPU");
|
||||
});
|
||||
|
||||
it("includes monitor URL in text", async () => {
|
||||
const { provider } = createProvider();
|
||||
await provider.sendMessage(makeTwilioNotification() as any, makeMessage());
|
||||
expect(mockGotPost.mock.calls[0][1].form.Body).toContain("https://example.com");
|
||||
});
|
||||
|
||||
it("omits thresholds when not present", async () => {
|
||||
const { provider } = createProvider();
|
||||
const msg = makeMessage();
|
||||
msg.content.thresholds = undefined;
|
||||
await provider.sendMessage(makeTwilioNotification() as any, msg);
|
||||
const text = mockGotPost.mock.calls[0][1].form.Body;
|
||||
expect(text).not.toContain("CPU");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
const teamsProvider = createProvider();
|
||||
const telegramProvider = createProvider();
|
||||
const pushoverProvider = createProvider();
|
||||
const twilioProvider = createProvider();
|
||||
const settingsService = createSettingsService();
|
||||
const notificationMessageBuilder = createMessageBuilder();
|
||||
|
||||
@@ -62,6 +63,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
teamsProvider,
|
||||
telegramProvider,
|
||||
pushoverProvider,
|
||||
twilioProvider,
|
||||
settingsService,
|
||||
notificationMessageBuilder,
|
||||
...overrides,
|
||||
@@ -79,6 +81,7 @@ const createService = (overrides?: Record<string, unknown>) => {
|
||||
defaults.teamsProvider as any,
|
||||
defaults.telegramProvider as any,
|
||||
defaults.pushoverProvider as any,
|
||||
defaults.twilioProvider as any,
|
||||
defaults.settingsService as any,
|
||||
defaults.logger as any,
|
||||
defaults.notificationMessageBuilder as any
|
||||
@@ -144,7 +147,7 @@ describe("NotificationsService", () => {
|
||||
});
|
||||
|
||||
it("routes to correct provider for each notification type", async () => {
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const;
|
||||
const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const;
|
||||
for (const type of types) {
|
||||
const deps = createService();
|
||||
(deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]);
|
||||
@@ -160,6 +163,8 @@ describe("NotificationsService", () => {
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
};
|
||||
expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
@@ -233,7 +238,7 @@ describe("NotificationsService", () => {
|
||||
// ── sendTestNotification ─────────────────────────────────────────────────
|
||||
|
||||
describe("sendTestNotification", () => {
|
||||
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)(
|
||||
it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)(
|
||||
"routes %s to the correct provider",
|
||||
async (type) => {
|
||||
const deps = createService();
|
||||
@@ -251,6 +256,8 @@ describe("NotificationsService", () => {
|
||||
email: deps.emailProvider,
|
||||
teams: deps.teamsProvider,
|
||||
telegram: deps.telegramProvider,
|
||||
pushover: deps.pushoverProvider,
|
||||
twilio: deps.twilioProvider,
|
||||
};
|
||||
expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user