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
116 changes: 116 additions & 0 deletions src/__tests__/check-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, test, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'

// Mock the checker module
vi.mock('@/lib/checker', () => ({
checkSite: vi.fn(),
}))

// Mock the admin client
const mockFrom = vi.fn()
const mockSupabase = { from: mockFrom }

vi.mock('@/lib/supabase/admin', () => ({
createAdminClient: () => mockSupabase,
}))

import { POST } from '@/app/api/checks/run/route'
import { checkSite } from '@/lib/checker'

describe('POST /api/checks/run', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.CRON_SECRET = 'test-secret'
})

test('rejects requests without valid CRON_SECRET', async () => {
const request = new NextRequest('http://localhost/api/checks/run', {
method: 'POST',
headers: { authorization: 'Bearer wrong-secret' },
})

const response = await POST(request)
expect(response.status).toBe(401)
})

test('rejects requests with no authorization header', async () => {
const request = new NextRequest('http://localhost/api/checks/run', {
method: 'POST',
})

const response = await POST(request)
expect(response.status).toBe(401)
})

test('returns empty result when no sites exist', async () => {
mockFrom.mockReturnValue({
select: vi.fn().mockReturnValue({
data: [],
error: null,
}),
})

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).toEqual({ message: 'No sites to check', checks: 0, incidents: 0 })
})

test('records checks and opens incidents for failures', 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 }) }
}
if (table === 'checks') {
return {
insert: () => ({
select: () => ({
single: () => ({
data: { id: 'check-1', site_id: 'site-1', status: 'failure' },
}),
}),
}),
}
}
if (table === 'incidents') {
return {
select: () => ({
eq: () => ({
eq: () => ({
limit: () => ({
single: () => ({ data: null }),
}),
}),
}),
}),
insert: vi.fn().mockReturnValue({ data: null, error: null }),
}
}
return { select: mockSelect }
})

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.checks).toBe(1)
expect(body.incidents).toBe(1)
})
})
65 changes: 65 additions & 0 deletions src/__tests__/checker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, test, expect, vi, beforeEach } from 'vitest'
import { checkSite } from '@/lib/checker'

describe('checkSite', () => {
beforeEach(() => {
vi.restoreAllMocks()
})

test('returns success for HTTP 200', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
}))

const result = await checkSite('https://example.com')
expect(result).toEqual({ status: 'success', statusCode: 200, error: null })
})

test('returns failure for HTTP 503', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 503,
}))

const result = await checkSite('https://example.com')
expect(result).toEqual({ status: 'failure', statusCode: 503, error: 'HTTP 503' })
})

test('returns failure on timeout', async () => {
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => {
const error = new Error('The operation was aborted')
error.name = 'AbortError'
return Promise.reject(error)
}))

const result = await checkSite('https://example.com')
expect(result).toEqual({ status: 'failure', statusCode: null, error: 'Connection timeout' })
})

test('returns failure on network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(
new Error('getaddrinfo ENOTFOUND example.com')
))

const result = await checkSite('https://example.com')
expect(result).toEqual({
status: 'failure',
statusCode: null,
error: 'getaddrinfo ENOTFOUND example.com',
})
})

test('returns failure on SSL error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(
new Error('unable to verify the first certificate')
))

const result = await checkSite('https://example.com')
expect(result).toEqual({
status: 'failure',
statusCode: null,
error: 'unable to verify the first certificate',
})
})
})
77 changes: 77 additions & 0 deletions src/app/api/checks/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { checkSite } from '@/lib/checker'

export const maxDuration = 60

export async function POST(request: NextRequest) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const supabase = createAdminClient()

const { data: sites, error: sitesError } = await supabase
.from('sites')
.select('*')

if (sitesError || !sites) {
return NextResponse.json({ error: 'Failed to fetch sites' }, { status: 500 })
}

if (sites.length === 0) {
return NextResponse.json({ message: 'No sites to check', checks: 0, incidents: 0 })
}

const results = await Promise.allSettled(
sites.map(async (site) => {
const result = await checkSite(site.url)
return { site, ...result }
})
)

let newIncidents = 0

for (const result of results) {
if (result.status === 'rejected') continue

const { site, status, statusCode, error } = result.value

const { data: check } = await supabase
.from('checks')
.insert({
site_id: site.id,
status,
status_code: statusCode,
error,
})
.select()
.single()

if (status === 'failure' && check) {
const { data: openIncident } = await supabase
.from('incidents')
.select('id')
.eq('site_id', site.id)
.eq('status', 'open')
.limit(1)
.single()

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

return NextResponse.json({
message: 'Checks complete',
checks: results.length,
incidents: newIncidents,
})
}
36 changes: 36 additions & 0 deletions src/lib/checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type CheckResult = {
status: 'success' | 'failure'
statusCode: number | null
error: string | null
}

export async function checkSite(url: string): Promise<CheckResult> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)

try {
const response = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
})

clearTimeout(timeout)

if (response.ok) {
return { status: 'success', statusCode: response.status, error: null }
} else {
return { status: 'failure', statusCode: response.status, error: `HTTP ${response.status}` }
}
} catch (err) {
clearTimeout(timeout)

if (err instanceof Error) {
if (err.name === 'AbortError') {
return { status: 'failure', statusCode: null, error: 'Connection timeout' }
}
return { status: 'failure', statusCode: null, error: err.message }
}

return { status: 'failure', statusCode: null, error: 'Unknown error' }
}
}
8 changes: 8 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/checks/run",
"schedule": "*/5 * * * *"
}
]
}