Skip to content
42 changes: 36 additions & 6 deletions e2e/contacts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,53 @@ test("shows empty state", async ({ page }) => {
await expect(page.getByText("No contacts added yet.")).toBeVisible();
});

test("adds a contact", async ({ page }) => {
test("adds an email contact and displays it in the list", async ({ page }) => {
await page.goto("/settings");
await page.getByPlaceholder("email@example.com").fill("contact@example.com");
await page.getByRole("button", { name: "Add" }).click();

await expect(page.getByText("contact@example.com")).toBeVisible();
const contactRow = page.getByRole("listitem").filter({ hasText: "contact@example.com" });
await expect(contactRow).toBeVisible();
await expect(contactRow.getByText("email")).toBeVisible();
await expect(contactRow.getByText("contact@example.com")).toBeVisible();
});

test("removes a contact", async ({ page, adminClient }) => {
// Seed a contact
await adminClient.from("contacts").insert({ email: "remove-me@example.com" });
test("adds a Slack contact and displays it in the list", async ({ page }) => {
await page.goto("/settings");
await page.getByRole("combobox").selectOption("slack");
await page.getByPlaceholder("#channel name").fill("#alerts");
await page.getByPlaceholder("https://hooks.slack.com/services/...").fill("https://hooks.slack.com/services/T00/B00/test");
await page.getByRole("button", { name: "Add" }).click();

const contactRow = page.getByRole("listitem").filter({ hasText: "#alerts" });
await expect(contactRow).toBeVisible();
await expect(contactRow.getByText("slack")).toBeVisible();
await expect(contactRow.getByText("#alerts")).toBeVisible();
await expect(contactRow.getByRole("button", { name: "Remove" })).toBeVisible();
});

test("removes an email contact", async ({ page, adminClient }) => {
await adminClient.from("contacts").insert({ type: "email", email: "remove-me@example.com" });

await page.goto("/settings");
await expect(page.getByText("remove-me@example.com")).toBeVisible();

// Click the Remove button next to the specific contact
const contactRow = page.getByRole("listitem").filter({ hasText: "remove-me@example.com" });
await contactRow.getByRole("button", { name: "Remove" }).click();
await expect(page.getByText("remove-me@example.com")).not.toBeVisible();
});

test("removes a Slack contact", async ({ page, adminClient }) => {
await adminClient.from("contacts").insert({
type: "slack",
webhook_url: "https://hooks.slack.com/services/T00/B00/remove-test",
label: "#ops-alerts",
});

await page.goto("/settings");
const contactRow = page.getByRole("listitem").filter({ hasText: "#ops-alerts" });
await expect(contactRow).toBeVisible();

await contactRow.getByRole("button", { name: "Remove" }).click();
await expect(page.getByText("#ops-alerts")).not.toBeVisible();
});
44 changes: 31 additions & 13 deletions src/__tests__/check-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ vi.mock('@/lib/checker', async (importOriginal) => {
})

// Mock the notifications module
const mockSendIncidentAlert = vi.fn()
const mockSendIncidentEmail = vi.fn()
const mockSendIncidentSlack = vi.fn()
vi.mock('@/lib/notifications', () => ({
sendIncidentAlert: (...args: unknown[]) => mockSendIncidentAlert(...args),
sendIncidentEmail: (...args: unknown[]) => mockSendIncidentEmail(...args),
sendIncidentSlack: (...args: unknown[]) => mockSendIncidentSlack(...args),
}))

// Mock the admin client
Expand All @@ -30,7 +32,8 @@ import { checkSite } from '@/lib/checker'
describe('POST /api/checks/run', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendIncidentAlert.mockResolvedValue(undefined)
mockSendIncidentEmail.mockResolvedValue(undefined)
mockSendIncidentSlack.mockResolvedValue(undefined)
process.env.CRON_SECRET = 'test-secret'
})

Expand Down Expand Up @@ -90,7 +93,7 @@ describe('POST /api/checks/run', () => {
expect(body).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 })
})

test('hard failure (HTTP 500) creates incident immediately, sends email', async () => {
test('hard failure creates incident, sends email and Slack alerts', async () => {
const site = { id: 'site-1', name: 'Test', url: 'https://example.com' }

mockFrom.mockImplementation((table: string) => {
Expand Down Expand Up @@ -129,7 +132,10 @@ describe('POST /api/checks/run', () => {
if (table === 'contacts') {
return {
select: () => ({
data: [{ email: 'alice@example.com' }],
data: [
{ type: 'email', email: 'alice@example.com', webhook_url: null },
{ type: 'slack', email: null, webhook_url: 'https://hooks.slack.com/services/T00/B00/xxx' },
],
}),
}
}
Expand All @@ -151,13 +157,20 @@ describe('POST /api/checks/run', () => {
const body = await response.json()
expect(body.checks).toBe(1)
expect(body.incidents).toBe(1)
expect(mockSendIncidentAlert).toHaveBeenCalledWith({
expect(mockSendIncidentEmail).toHaveBeenCalledWith({
siteName: 'Test',
siteUrl: 'https://example.com',
error: 'HTTP 500',
incidentId: 'inc-1',
contactEmails: ['alice@example.com'],
})
expect(mockSendIncidentSlack).toHaveBeenCalledWith({
siteName: 'Test',
siteUrl: 'https://example.com',
error: 'HTTP 500',
incidentId: 'inc-1',
webhookUrls: ['https://hooks.slack.com/services/T00/B00/xxx'],
})
})

test('single soft failure (HTTP 503) does not create incident', async () => {
Expand Down Expand Up @@ -217,7 +230,8 @@ describe('POST /api/checks/run', () => {
const response = await POST(request)
const body = await response.json()
expect(body.incidents).toBe(0)
expect(mockSendIncidentAlert).not.toHaveBeenCalled()
expect(mockSendIncidentEmail).not.toHaveBeenCalled()
expect(mockSendIncidentSlack).not.toHaveBeenCalled()
})

test('three soft failures within an hour creates incident', async () => {
Expand Down Expand Up @@ -272,7 +286,7 @@ describe('POST /api/checks/run', () => {
if (table === 'contacts') {
return {
select: () => ({
data: [{ email: 'alice@example.com' }],
data: [{ type: 'email', email: 'alice@example.com', webhook_url: null }],
}),
}
}
Expand All @@ -293,7 +307,8 @@ describe('POST /api/checks/run', () => {
const response = await POST(request)
const body = await response.json()
expect(body.incidents).toBe(1)
expect(mockSendIncidentAlert).toHaveBeenCalled()
expect(mockSendIncidentEmail).toHaveBeenCalled()
expect(mockSendIncidentSlack).toHaveBeenCalled()
})

test('two soft failures within an hour does not create incident', async () => {
Expand Down Expand Up @@ -356,7 +371,8 @@ describe('POST /api/checks/run', () => {
const response = await POST(request)
const body = await response.json()
expect(body.incidents).toBe(0)
expect(mockSendIncidentAlert).not.toHaveBeenCalled()
expect(mockSendIncidentEmail).not.toHaveBeenCalled()
expect(mockSendIncidentSlack).not.toHaveBeenCalled()
})

test('records checks without creating incidents for successful checks', async () => {
Expand Down Expand Up @@ -397,7 +413,8 @@ describe('POST /api/checks/run', () => {
expect(mockInsert).toHaveBeenCalledWith(
expect.objectContaining({ status: 'success', status_code: 200 })
)
expect(mockSendIncidentAlert).not.toHaveBeenCalled()
expect(mockSendIncidentEmail).not.toHaveBeenCalled()
expect(mockSendIncidentSlack).not.toHaveBeenCalled()
})

test('skips rejected promises from Promise.allSettled', async () => {
Expand Down Expand Up @@ -441,7 +458,7 @@ describe('POST /api/checks/run', () => {
expect(body.incidents).toBe(0)
})

test('does not send email for existing open incident', async () => {
test('does not send alerts for existing open incident', async () => {
const site = { id: 'site-1', name: 'Test', url: 'https://example.com' }

mockFrom.mockImplementation((table: string) => {
Expand Down Expand Up @@ -489,7 +506,8 @@ describe('POST /api/checks/run', () => {
const response = await POST(request)
const body = await response.json()
expect(body.incidents).toBe(0)
expect(mockSendIncidentAlert).not.toHaveBeenCalled()
expect(mockSendIncidentEmail).not.toHaveBeenCalled()
expect(mockSendIncidentSlack).not.toHaveBeenCalled()
})
})

Expand Down
84 changes: 80 additions & 4 deletions src/__tests__/contacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ describe("addContact action", () => {
mockGetUser.mockResolvedValue({ data: { user: null } })

const formData = new FormData()
formData.set("email", "test@example.com")
formData.set("contact_email", "test@example.com")

await expect(addContact(formData)).rejects.toThrow("NEXT_REDIRECT:/login")
})

test("inserts contact for authenticated users", async () => {
test("inserts email contact for authenticated users", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})
Expand All @@ -51,11 +51,47 @@ describe("addContact action", () => {
mockFrom.mockReturnValue({ insert: mockInsert })

const formData = new FormData()
formData.set("email", "test@example.com")
formData.set("contact_type", "email")
formData.set("contact_email", "test@example.com")

await addContact(formData)
expect(mockFrom).toHaveBeenCalledWith("contacts")
expect(mockInsert).toHaveBeenCalledWith({ email: "test@example.com" })
expect(mockInsert).toHaveBeenCalledWith({ type: "email", email: "test@example.com" })
})

test("inserts Slack contact for authenticated users", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const mockInsert = vi.fn().mockReturnValue({ error: null })
mockFrom.mockReturnValue({ insert: mockInsert })

const formData = new FormData()
formData.set("contact_type", "slack")
formData.set("contact_label", "#alerts")
formData.set("contact_webhook_url", "https://hooks.slack.com/services/T00/B00/xxx")

await addContact(formData)
expect(mockFrom).toHaveBeenCalledWith("contacts")
expect(mockInsert).toHaveBeenCalledWith({
type: "slack",
webhook_url: "https://hooks.slack.com/services/T00/B00/xxx",
label: "#alerts",
})
})

test("rejects invalid Slack webhook URL", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const formData = new FormData()
formData.set("contact_type", "slack")
formData.set("contact_webhook_url", "https://example.com/not-slack")

await addContact(formData)
expect(mockFrom).not.toHaveBeenCalled()
})

test("does nothing if email is empty", async () => {
Expand All @@ -64,9 +100,49 @@ describe("addContact action", () => {
})

const formData = new FormData()
formData.set("contact_type", "email")
await addContact(formData)
expect(mockFrom).not.toHaveBeenCalled()
})

test("does nothing if webhook URL is empty for Slack", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const formData = new FormData()
formData.set("contact_type", "slack")
formData.set("contact_label", "#alerts")
await addContact(formData)
expect(mockFrom).not.toHaveBeenCalled()
})

test("does nothing if label is empty for Slack", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const formData = new FormData()
formData.set("contact_type", "slack")
formData.set("contact_webhook_url", "https://hooks.slack.com/services/T00/B00/xxx")
await addContact(formData)
expect(mockFrom).not.toHaveBeenCalled()
})

test("defaults to email type when contact_type is not set", async () => {
mockGetUser.mockResolvedValue({
data: { user: { id: "user-1" } },
})

const mockInsert = vi.fn().mockReturnValue({ error: null })
mockFrom.mockReturnValue({ insert: mockInsert })

const formData = new FormData()
formData.set("contact_email", "test@example.com")

await addContact(formData)
expect(mockInsert).toHaveBeenCalledWith({ type: "email", email: "test@example.com" })
})
})

describe("deleteContact action", () => {
Expand Down
Loading