import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
import type { NotificationChannel } from "@/api/alerts-api";
const alertsApi = vi.hoisted(() => ({
getAlertFirings: vi.fn(async () => []),
acknowledgeAlertFiring: vi.fn(async () => {}),
acknowledgeAllAlertFirings: vi.fn(async () => {}),
getAlertRules: vi.fn(async () => []),
getNotificationChannels: vi.fn(async () => [] as NotificationChannel[]),
deleteAlertRule: vi.fn(async () => {}),
deleteNotificationChannel: vi.fn(async () => {}),
testNotificationChannel: vi.fn(async () => {}),
}));
vi.mock("@/api/alerts-api", () => alertsApi);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
// A real (non-null) fake so tests can see what channels actually reach the
// rule dialog, instead of only asserting the API call count.
vi.mock("../../sidebar/AlertRuleDialog", () => ({
AlertRuleDialog: ({
open,
channels,
}: {
open: boolean;
channels: NotificationChannel[];
}) =>
open ? (
{channels.length === 0
? "no channels"
: channels.map((c) => c.name).join(",")}
) : null,
}));
vi.mock("../../sidebar/NotificationChannelDialog", () => ({
NotificationChannelDialog: () => null,
}));
import { AlertsPanel } from "../../sidebar/AlertsPanel";
function channel(
overrides: Partial = {},
): NotificationChannel {
return {
id: 1,
userId: "user-1",
name: "ntfy",
type: "ntfy",
config: "{}",
enabled: true,
createdAt: new Date().toISOString(),
...overrides,
};
}
beforeEach(() => {
alertsApi.getAlertFirings.mockReset().mockResolvedValue([]);
alertsApi.getAlertRules.mockReset().mockResolvedValue([]);
alertsApi.getNotificationChannels.mockReset().mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
describe("AlertsPanel - notification channels", () => {
it("loads notification channels on mount, before the Channels tab is ever opened", async () => {
alertsApi.getNotificationChannels.mockResolvedValue([channel()]);
render();
// Regression: channels used to only load once the user switched to the
// "channels" tab, so editing a rule before ever visiting that tab showed
// an empty channel picker even though channels existed.
await waitFor(() => {
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
});
it("populates the rule dialog's channel picker without ever visiting the Channels tab", async () => {
alertsApi.getNotificationChannels.mockResolvedValue([
channel({ id: 1, name: "ntfy-home" }),
channel({ id: 2, name: "webhook-slack" }),
]);
render();
await waitFor(() => {
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
// Go straight to Rules and open the "new rule" dialog, skipping Channels
// entirely, this is exactly the sequence the original bug broke.
fireEvent.click(screen.getByText("Rules"));
fireEvent.click(screen.getByText("Add"));
expect(screen.getByTestId("rule-dialog-channels").textContent).toBe(
"ntfy-home,webhook-slack",
);
});
it("does not refetch channels again when switching to the Rules tab", async () => {
render();
await waitFor(() => {
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
// Switching tabs only reloads rules here, not channels, since channels
// were already fetched on mount by the fix under test.
fireEvent.click(screen.getByText("Rules"));
await waitFor(() => {
expect(alertsApi.getAlertRules).toHaveBeenCalledTimes(1);
});
expect(alertsApi.getNotificationChannels).toHaveBeenCalledTimes(1);
});
});