Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions src/__tests__/check-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ vi.mock('@/lib/checker', () => ({
checkSite: vi.fn(),
}))

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

// Mock the admin client
const mockFrom = vi.fn()
const mockSupabase = { from: mockFrom }
Expand All @@ -20,6 +26,7 @@ import { checkSite } from '@/lib/checker'
describe('POST /api/checks/run', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendIncidentAlert.mockResolvedValue(undefined)
process.env.CRON_SECRET = 'test-secret'
})

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

test('records checks and opens incidents for failures', async () => {
test('records checks and opens incidents for failures, sends email', async () => {
const site = { id: 'site-1', name: 'Test', url: 'https://example.com' }

// Mock sites query
const mockSelect = vi.fn()
mockFrom.mockImplementation((table: string) => {
if (table === 'sites') {
return { select: () => ({ data: [site], error: null }) }
Expand All @@ -91,10 +96,21 @@ describe('POST /api/checks/run', () => {
}),
}),
}),
insert: vi.fn().mockReturnValue({ data: null, error: null }),
insert: () => ({
select: () => ({
single: () => ({ data: { id: 'inc-1' } }),
}),
}),
}
}
return { select: mockSelect }
if (table === 'contacts') {
return {
select: () => ({
data: [{ email: 'alice@example.com' }],
}),
}
}
return {}
})

vi.mocked(checkSite).mockResolvedValue({
Expand All @@ -112,5 +128,63 @@ describe('POST /api/checks/run', () => {
const body = await response.json()
expect(body.checks).toBe(1)
expect(body.incidents).toBe(1)
expect(mockSendIncidentAlert).toHaveBeenCalledWith({
siteName: 'Test',
siteUrl: 'https://example.com',
error: 'HTTP 503',
incidentId: 'inc-1',
contactEmails: ['alice@example.com'],
})
})

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

mockFrom.mockImplementation((table: string) => {
if (table === 'sites') {
return { select: () => ({ data: [site], error: null }) }
}
if (table === 'checks') {
return {
insert: () => ({
select: () => ({
single: () => ({
data: { id: 'check-2', site_id: 'site-1', status: 'failure' },
}),
}),
}),
}
}
if (table === 'incidents') {
return {
select: () => ({
eq: () => ({
eq: () => ({
limit: () => ({
single: () => ({ data: { id: 'inc-existing' } }),
}),
}),
}),
}),
}
}
return {}
})

vi.mocked(checkSite).mockResolvedValue({
status: 'failure',
statusCode: 503,
error: 'HTTP 503',
})

const request = new NextRequest('http://localhost/api/checks/run', {
method: 'POST',
headers: { authorization: 'Bearer test-secret' },
})

const response = await POST(request)
const body = await response.json()
expect(body.incidents).toBe(0)
expect(mockSendIncidentAlert).not.toHaveBeenCalled()
})
})
82 changes: 82 additions & 0 deletions src/__tests__/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, test, expect, vi, beforeEach } from "vitest"

// Mock Resend
const mockSend = vi.fn()
vi.mock("resend", () => ({
Resend: class {
emails = { send: mockSend }
},
}))

import { sendIncidentAlert } from "@/lib/notifications"

describe("sendIncidentAlert", () => {
beforeEach(() => {
vi.clearAllMocks()
mockSend.mockResolvedValue({ id: "email-1" })
})

test("sends email to all contacts with correct content", async () => {
await sendIncidentAlert({
siteName: "Main Website",
siteUrl: "https://example.com",
error: "HTTP 503",
incidentId: "inc-1",
contactEmails: ["alice@example.com", "bob@example.com"],
})

expect(mockSend).toHaveBeenCalledTimes(1)
const call = mockSend.mock.calls[0][0]
expect(call.to).toEqual(["alice@example.com", "bob@example.com"])
expect(call.subject).toContain("Main Website")
expect(call.subject).toContain("[Down]")
expect(call.html).toContain("Main Website")
expect(call.html).toContain("https://example.com")
expect(call.html).toContain("HTTP 503")
expect(call.html).toContain("/incidents/inc-1")
})

test("does nothing when contactEmails is empty", async () => {
await sendIncidentAlert({
siteName: "Main Website",
siteUrl: "https://example.com",
error: "HTTP 503",
incidentId: "inc-1",
contactEmails: [],
})

expect(mockSend).not.toHaveBeenCalled()
})

test("handles email send failure gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockSend.mockRejectedValue(new Error("API rate limit"))

await sendIncidentAlert({
siteName: "Main Website",
siteUrl: "https://example.com",
error: "HTTP 503",
incidentId: "inc-1",
contactEmails: ["alice@example.com"],
})

expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send incident alert email:",
expect.any(Error)
)
consoleSpy.mockRestore()
})

test("works without error field", async () => {
await sendIncidentAlert({
siteName: "Main Website",
siteUrl: "https://example.com",
error: null,
incidentId: "inc-1",
contactEmails: ["alice@example.com"],
})

const call = mockSend.mock.calls[0][0]
expect(call.html).not.toContain("<strong>Error:</strong>")
})
})
29 changes: 24 additions & 5 deletions src/app/api/checks/run/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { checkSite } from '@/lib/checker'
import { sendIncidentAlert } from '@/lib/notifications'

export const maxDuration = 60

Expand Down Expand Up @@ -59,12 +60,30 @@ export async function POST(request: NextRequest) {
.single()

if (!openIncident) {
await supabase.from('incidents').insert({
site_id: site.id,
check_id: check.id,
status: 'open',
})
const { data: incident } = await supabase
.from('incidents')
.insert({
site_id: site.id,
check_id: check.id,
status: 'open',
})
.select('id')
.single()
newIncidents++

if (incident) {
const { data: contacts } = await supabase
.from('contacts')
.select('email')

await sendIncidentAlert({
siteName: site.name,
siteUrl: site.url,
error,
incidentId: incident.id,
contactEmails: (contacts ?? []).map((c: { email: string }) => c.email),
})
}
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions src/lib/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Resend } from "resend"

let _resend: Resend | null = null
function getResend() {
if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY)
return _resend
}

type IncidentAlertParams = {
siteName: string
siteUrl: string
error: string | null
incidentId: string
contactEmails: string[]
}

export async function sendIncidentAlert({
siteName,
siteUrl,
error,
incidentId,
contactEmails,
}: IncidentAlertParams) {
if (contactEmails.length === 0) return

const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://status.rootsofprogress.org"
const incidentLink = `${appUrl}/incidents/${incidentId}`

const subject = `[Down] ${siteName} is not responding`
const html = `
<h2>${siteName} is down</h2>
<p><strong>URL:</strong> ${siteUrl}</p>
${error ? `<p><strong>Error:</strong> ${error}</p>` : ""}
<p><a href="${incidentLink}">View incident details</a></p>
`.trim()

const fromAddress = process.env.RESEND_FROM_EMAIL || "alerts@status.rootsofprogress.org"

try {
await getResend().emails.send({
from: fromAddress,
to: contactEmails,
subject,
html,
})
} catch (err) {
console.error("Failed to send incident alert email:", err)
}
}