Merge pull request #3375 from tb102122/feature/teams-notification-channel
add teams
This commit is contained in:
11 files changed
+573
-8
No files matched your search
@@ -5,6 +5,7 @@ export const NotificationChannels = [
|
||||
"webhook",
|
||||
"pager_duty",
|
||||
"matrix",
|
||||
"teams",
|
||||
] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ const matrixSchema = baseSchema.extend({
|
||||
accessToken: z.string().min(1, "Access token is required"),
|
||||
});
|
||||
|
||||
const teamsSchema = baseSchema.extend({
|
||||
type: z.literal("teams"),
|
||||
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
|
||||
});
|
||||
|
||||
export const notificationSchema = z.discriminatedUnion("type", [
|
||||
emailSchema,
|
||||
slackSchema,
|
||||
@@ -52,6 +57,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
webhookSchema,
|
||||
pagerDutySchema,
|
||||
matrixSchema,
|
||||
teamsSchema,
|
||||
]);
|
||||
|
||||
export type NotificationFormData = z.infer<typeof notificationSchema>;
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DiscordProvider,
|
||||
PagerDutyProvider,
|
||||
MatrixProvider,
|
||||
TeamsProvider,
|
||||
// Interfaces
|
||||
INetworkService,
|
||||
IEmailService,
|
||||
@@ -228,6 +229,7 @@ export const initializeServices = async ({
|
||||
const discordProvider = new DiscordProvider(logger);
|
||||
const pagerDutyProvider = new PagerDutyProvider(logger);
|
||||
const matrixProvider = new MatrixProvider(logger);
|
||||
const teamsProvider = new TeamsProvider(logger);
|
||||
|
||||
const notificationsService = new NotificationsService(
|
||||
notificationsRepository,
|
||||
@@ -238,6 +240,7 @@ export const initializeServices = async ({
|
||||
discordProvider,
|
||||
pagerDutyProvider,
|
||||
matrixProvider,
|
||||
teamsProvider,
|
||||
settingsService,
|
||||
logger,
|
||||
notificationMessageBuilder
|
||||
|
||||
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as NotificationChannel[],
|
||||
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
|
||||
required: true,
|
||||
},
|
||||
notificationName: {
|
||||
|
||||
@@ -273,8 +273,8 @@ export class UserService implements IUserService {
|
||||
await this.recoveryTokensRepository.deleteManyByEmail(email);
|
||||
const recoveryToken = await this.recoveryTokensRepository.create(email);
|
||||
const name = user.firstName;
|
||||
const { clientHost } = this.settingsService.getSettings();
|
||||
const url = `${clientHost}/set-new-password/${recoveryToken.token}`;
|
||||
const settings = this.settingsService.getSettings();
|
||||
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
|
||||
|
||||
const html = await this.emailService.buildEmail("passwordResetTemplate", {
|
||||
name,
|
||||
|
||||
@@ -27,6 +27,7 @@ export * from "@/service/infrastructure/notificationProviders/INotificationProvi
|
||||
export * from "@/service/infrastructure/notificationProviders/matrix.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/slack.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/teams.js";
|
||||
export * from "@/service/infrastructure/notificationProviders/webhook.js";
|
||||
|
||||
// System services
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
const SERVICE_NAME = "TeamsProvider";
|
||||
import type { Notification } from "@/types/index.js";
|
||||
import { INotificationProvider } from "@/service/index.js";
|
||||
import type { NotificationMessage } from "@/types/notificationMessage.js";
|
||||
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
|
||||
import type { ILogger } from "@/utils/logger.js";
|
||||
import got, { HTTPError } from "got";
|
||||
|
||||
// Types for Adaptive Card elements
|
||||
type TextBlock = {
|
||||
type: "TextBlock";
|
||||
text: string;
|
||||
weight?: "Bolder" | "Normal" | "Lighter";
|
||||
size?: "Small" | "Medium" | "Large" | "ExtraLarge";
|
||||
color?: "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention";
|
||||
wrap?: boolean;
|
||||
spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto";
|
||||
isSubtle?: boolean;
|
||||
};
|
||||
|
||||
type ColumnSet = {
|
||||
type: "ColumnSet";
|
||||
separator?: boolean;
|
||||
spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto";
|
||||
columns: unknown[];
|
||||
};
|
||||
|
||||
type Fact = {
|
||||
title: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type FactSet = {
|
||||
type: "FactSet";
|
||||
facts: Fact[];
|
||||
};
|
||||
|
||||
type Action = {
|
||||
type: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type AdaptiveCard = {
|
||||
type: "AdaptiveCard";
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json";
|
||||
version: "1.4";
|
||||
body: (TextBlock | ColumnSet | FactSet)[];
|
||||
actions?: Action[];
|
||||
};
|
||||
|
||||
type TeamsMessage = {
|
||||
type: "message";
|
||||
attachments: Array<{
|
||||
contentType: "application/vnd.microsoft.card.adaptive";
|
||||
contentUrl: null;
|
||||
content: AdaptiveCard;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class TeamsProvider implements INotificationProvider {
|
||||
private logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async sendTestAlert(notification: Notification): Promise<boolean> {
|
||||
if (!notification.address) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.wrapAdaptiveCard({
|
||||
type: "AdaptiveCard",
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
version: "1.4",
|
||||
body: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: getTestMessage(),
|
||||
weight: "Bolder",
|
||||
size: "Medium",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await got.post(notification.address, {
|
||||
json: payload,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as HTTPError;
|
||||
this.logger.warn({
|
||||
message: "Teams test alert failed",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendTestAlert",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
|
||||
if (!notification.address) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = this.wrapAdaptiveCard(this.buildAdaptiveCard(message));
|
||||
|
||||
try {
|
||||
await got.post(notification.address, {
|
||||
json: payload,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.logger.info({
|
||||
message: "Teams notification sent via sendMessage",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as HTTPError;
|
||||
this.logger.warn({
|
||||
message: "Teams alert failed via sendMessage",
|
||||
service: SERVICE_NAME,
|
||||
method: "sendMessage",
|
||||
stack: err?.stack,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an Adaptive Card in the Teams webhook envelope format
|
||||
*/
|
||||
private wrapAdaptiveCard(card: AdaptiveCard): TeamsMessage {
|
||||
return {
|
||||
type: "message",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an Adaptive Card from NotificationMessage
|
||||
*/
|
||||
private buildAdaptiveCard(message: NotificationMessage): AdaptiveCard {
|
||||
const colorMap: Record<string, string> = {
|
||||
critical: "attention",
|
||||
warning: "warning",
|
||||
success: "good",
|
||||
info: "accent",
|
||||
};
|
||||
const color = (colorMap[message.severity] || "default") as "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention" | undefined;
|
||||
|
||||
const body: (TextBlock | ColumnSet | FactSet)[] = [];
|
||||
|
||||
// Header with colored status indicator
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: message.content.title,
|
||||
weight: "Bolder",
|
||||
size: "Large",
|
||||
color,
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
// Summary
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: message.content.summary,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
});
|
||||
|
||||
// Separator
|
||||
body.push({
|
||||
type: "ColumnSet",
|
||||
separator: true,
|
||||
spacing: "Medium",
|
||||
columns: [],
|
||||
});
|
||||
|
||||
// Monitor details as a FactSet
|
||||
body.push({
|
||||
type: "FactSet",
|
||||
facts: [
|
||||
{ title: "Name", value: message.monitor.name },
|
||||
{ title: "Type", value: message.monitor.type },
|
||||
{ title: "Status", value: message.monitor.status },
|
||||
{ title: "URL", value: message.monitor.url },
|
||||
],
|
||||
});
|
||||
|
||||
// Threshold breaches
|
||||
if (message.content.thresholds && message.content.thresholds.length > 0) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: "**Threshold Breaches**",
|
||||
weight: "Bolder",
|
||||
spacing: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
for (const breach of message.content.thresholds) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: `• **${breach.metric.toUpperCase()}**: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Additional details
|
||||
if (message.content.details && message.content.details.length > 0) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: "**Additional Information**",
|
||||
weight: "Bolder",
|
||||
spacing: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
for (const detail of message.content.details) {
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: `• ${detail}`,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp footer
|
||||
body.push({
|
||||
type: "TextBlock",
|
||||
text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`,
|
||||
size: "Small",
|
||||
isSubtle: true,
|
||||
spacing: "Medium",
|
||||
wrap: true,
|
||||
});
|
||||
|
||||
// Actions (incident link)
|
||||
const actions: Action[] = [];
|
||||
if (message.content.incident) {
|
||||
actions.push({
|
||||
type: "Action.OpenUrl",
|
||||
title: "View Incident",
|
||||
url: `${message.clientHost}/incidents/${message.content.incident.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: "AdaptiveCard",
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
version: "1.4",
|
||||
body,
|
||||
...(actions.length > 0 ? { actions } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export class NotificationsService implements INotificationsService {
|
||||
private discordProvider: INotificationProvider;
|
||||
private pagerDutyProvider: INotificationProvider;
|
||||
private matrixProvider: INotificationProvider;
|
||||
private teamsProvider: INotificationProvider;
|
||||
private logger: ILogger;
|
||||
private settingsService: ISettingsService;
|
||||
private notificationMessageBuilder: INotificationMessageBuilder;
|
||||
@@ -45,6 +46,7 @@ export class NotificationsService implements INotificationsService {
|
||||
discordProvider: INotificationProvider,
|
||||
pagerDutyProvider: INotificationProvider,
|
||||
matrixProvider: INotificationProvider,
|
||||
teamsProvider: INotificationProvider,
|
||||
settingsService: ISettingsService,
|
||||
logger: ILogger,
|
||||
notificationMessageBuilder: INotificationMessageBuilder
|
||||
@@ -57,6 +59,7 @@ export class NotificationsService implements INotificationsService {
|
||||
this.discordProvider = discordProvider;
|
||||
this.pagerDutyProvider = pagerDutyProvider;
|
||||
this.matrixProvider = matrixProvider;
|
||||
this.teamsProvider = teamsProvider;
|
||||
this.settingsService = settingsService;
|
||||
this.logger = logger;
|
||||
this.notificationMessageBuilder = notificationMessageBuilder;
|
||||
@@ -92,6 +95,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.discordProvider.sendMessage!(notification, notificationMessage);
|
||||
case "email":
|
||||
return await this.emailProvider.sendMessage!(notification, notificationMessage);
|
||||
case "teams":
|
||||
return await this.teamsProvider.sendMessage!(notification, notificationMessage);
|
||||
default:
|
||||
this.logger.warn({
|
||||
message: `Unknown notification type: ${notification.type}`,
|
||||
@@ -150,6 +155,8 @@ export class NotificationsService implements INotificationsService {
|
||||
return await this.matrixProvider.sendTestAlert(notification);
|
||||
case "webhook":
|
||||
return await this.webhookProvider.sendTestAlert(notification);
|
||||
case "teams":
|
||||
return await this.teamsProvider.sendTestAlert(notification);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as const;
|
||||
export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as const;
|
||||
export type NotificationChannel = (typeof NotificationChannels)[number];
|
||||
|
||||
export interface Notification {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("webhook"),
|
||||
address: z.string().url("Please enter a valid Webhook URL"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -27,7 +27,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("slack"),
|
||||
address: z.string().url("Please enter a valid Webhook URL"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -36,7 +36,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("discord"),
|
||||
address: z.string().url("Please enter a valid Webhook URL"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
homeserverUrl: z.union([z.string(), z.literal("")]).optional(),
|
||||
roomId: z.union([z.string(), z.literal("")]).optional(),
|
||||
accessToken: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -55,10 +55,16 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("matrix"),
|
||||
address: z.union([z.string(), z.literal("")]).optional(),
|
||||
homeserverUrl: z.string().url("Please enter a valid Homeserver URL"),
|
||||
homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }),
|
||||
roomId: z.string().min(1, "Room ID is required"),
|
||||
accessToken: z.string().min(1, "Access Token is required"),
|
||||
}),
|
||||
// Teams notification
|
||||
z.object({
|
||||
notificationName: z.string().min(1, "Notification name is required"),
|
||||
type: z.literal("teams"),
|
||||
address: z.url({ message: "Please enter a valid Webhook URL" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const sendTestEmailBodyValidation = z.object({
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts";
|
||||
import type { Notification } from "../src/types/notification.ts";
|
||||
import type { NotificationMessage } from "../src/types/notificationMessage.ts";
|
||||
|
||||
// Mock got
|
||||
jest.unstable_mockModule("got", () => ({
|
||||
default: { post: jest.fn() },
|
||||
}));
|
||||
|
||||
const createLogger = () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
});
|
||||
|
||||
const createNotification = (overrides?: Partial<Notification>): Notification => ({
|
||||
id: "notif-1",
|
||||
userId: "user-1",
|
||||
teamId: "team-1",
|
||||
type: "teams",
|
||||
notificationName: "Teams Alert",
|
||||
address: "https://xxxxx.webhook.office.com/webhookb2/test",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMessage = (overrides?: Partial<NotificationMessage>): NotificationMessage => ({
|
||||
type: "monitor_down",
|
||||
severity: "critical",
|
||||
monitor: {
|
||||
id: "mon-1",
|
||||
name: "API Server",
|
||||
url: "https://api.example.com",
|
||||
type: "http",
|
||||
status: "down",
|
||||
},
|
||||
content: {
|
||||
title: "🔴 Monitor Down",
|
||||
summary: "API Server is not responding",
|
||||
details: ["Response timeout after 30s"],
|
||||
thresholds: [],
|
||||
incident: {
|
||||
id: "inc-1",
|
||||
url: "https://app.example.com/incidents/inc-1",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
||||
},
|
||||
clientHost: "https://app.example.com",
|
||||
metadata: {
|
||||
teamId: "team-1",
|
||||
notificationReason: "monitor_down",
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("TeamsProvider", () => {
|
||||
let provider: TeamsProvider;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
let gotPost: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
logger = createLogger();
|
||||
provider = new TeamsProvider(logger);
|
||||
const got = await import("got");
|
||||
gotPost = got.default.post as jest.Mock;
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("sendTestAlert", () => {
|
||||
it("returns false when address is missing", async () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends an Adaptive Card test message and returns true on success", async () => {
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(true);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
// Verify Teams webhook envelope
|
||||
const payload = options.json;
|
||||
expect(payload.type).toBe("message");
|
||||
expect(payload.attachments).toHaveLength(1);
|
||||
expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive");
|
||||
|
||||
// Verify Adaptive Card structure
|
||||
const card = payload.attachments[0].content;
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
expect(card.body[0].type).toBe("TextBlock");
|
||||
expect(card.body[0].text).toContain("test notification");
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
gotPost.mockRejectedValue(new Error("Network error"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendTestAlert(notification);
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Teams test alert failed",
|
||||
service: "TeamsProvider",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessage", () => {
|
||||
it("returns false when address is missing", async () => {
|
||||
const notification = createNotification({ address: undefined });
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(gotPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a well-formed Adaptive Card and returns true", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage();
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(gotPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
expect(url).toBe(notification.address);
|
||||
|
||||
const payload = options.json;
|
||||
expect(payload.type).toBe("message");
|
||||
|
||||
const card = payload.attachments[0].content;
|
||||
expect(card.type).toBe("AdaptiveCard");
|
||||
expect(card.version).toBe("1.4");
|
||||
|
||||
// Verify body contains expected elements
|
||||
const textBlocks = card.body.filter((b: any) => b.type === "TextBlock");
|
||||
const factSets = card.body.filter((b: any) => b.type === "FactSet");
|
||||
|
||||
// Title block
|
||||
expect(textBlocks[0].text).toBe(message.content.title);
|
||||
expect(textBlocks[0].color).toBe("attention"); // critical -> attention
|
||||
|
||||
// Summary block
|
||||
expect(textBlocks[1].text).toBe(message.content.summary);
|
||||
|
||||
// FactSet with monitor details
|
||||
expect(factSets).toHaveLength(1);
|
||||
const facts = factSets[0].facts;
|
||||
expect(facts).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "Name", value: "API Server" }),
|
||||
expect.objectContaining({ title: "URL", value: "https://api.example.com" }),
|
||||
expect.objectContaining({ title: "Status", value: "down" }),
|
||||
])
|
||||
);
|
||||
|
||||
// Incident action button
|
||||
expect(card.actions).toHaveLength(1);
|
||||
expect(card.actions[0].type).toBe("Action.OpenUrl");
|
||||
expect(card.actions[0].title).toBe("View Incident");
|
||||
expect(card.actions[0].url).toContain("/infrastructure/mon-1");
|
||||
});
|
||||
|
||||
it("omits actions when no incident is present", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({
|
||||
content: {
|
||||
title: "Test",
|
||||
summary: "Summary",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
const result = await provider.sendMessage(notification, message);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.actions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes threshold breaches when present", async () => {
|
||||
const notification = createNotification();
|
||||
const message = createMessage({
|
||||
type: "threshold_breach",
|
||||
severity: "warning",
|
||||
content: {
|
||||
title: "Threshold Breach",
|
||||
summary: "CPU usage exceeded",
|
||||
thresholds: [
|
||||
{
|
||||
metric: "cpu",
|
||||
currentValue: 95,
|
||||
threshold: 80,
|
||||
unit: "%",
|
||||
formattedValue: "95%",
|
||||
},
|
||||
],
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
|
||||
// Title should use "warning" color
|
||||
expect(card.body[0].color).toBe("warning");
|
||||
|
||||
// Find threshold text blocks
|
||||
const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**");
|
||||
expect(thresholdHeader).toBeDefined();
|
||||
|
||||
const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU"));
|
||||
expect(cpuBlock).toBeDefined();
|
||||
expect(cpuBlock.text).toContain("95%");
|
||||
expect(cpuBlock.text).toContain("threshold: 80%");
|
||||
});
|
||||
|
||||
it("maps severity to correct Adaptive Card colors", async () => {
|
||||
const notification = createNotification();
|
||||
|
||||
const severityMap = [
|
||||
{ severity: "critical", expected: "attention" },
|
||||
{ severity: "warning", expected: "warning" },
|
||||
{ severity: "success", expected: "good" },
|
||||
{ severity: "info", expected: "accent" },
|
||||
] as const;
|
||||
|
||||
for (const { severity, expected } of severityMap) {
|
||||
gotPost.mockReset();
|
||||
gotPost.mockResolvedValue({});
|
||||
|
||||
const message = createMessage({ severity });
|
||||
await provider.sendMessage(notification, message);
|
||||
|
||||
const [, options] = gotPost.mock.calls[0] as [string, { json: any }];
|
||||
const card = options.json.attachments[0].content;
|
||||
expect(card.body[0].color).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false and logs warning on HTTP error", async () => {
|
||||
gotPost.mockRejectedValue(new Error("502 Bad Gateway"));
|
||||
const notification = createNotification();
|
||||
const result = await provider.sendMessage(notification, createMessage());
|
||||
expect(result).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Teams alert failed via sendMessage",
|
||||
service: "TeamsProvider",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user