Skip to content

Commit 256f31c

Browse files
committed
Add single API endpoint to trigger all CRON jobs, avoiding need to upgrade Vercel plan atm
1 parent 2150348 commit 256f31c

4 files changed

Lines changed: 239 additions & 10 deletions

File tree

playwright.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { defineConfig, devices } from '@playwright/test'
44
* Read environment variables from file.
55
* https://github.com/motdotla/dotenv
66
*/
7-
import 'dotenv/config'
7+
import dotenv from 'dotenv'
8+
import { isCI } from 'src/lib/config/environmentServer'
9+
10+
if ( !isCI() ) dotenv.config({ path: '.env.development' })
811

912
/**
1013
* See https://playwright.dev/docs/test-configuration.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import type { APIRoute } from 'astro'
3+
import { GET as runAll } from '@pages/api/cron/run-all'
4+
5+
const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret'))
6+
const getSiteUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.com'))
7+
8+
vi.mock('@pages/api/_environment/environmentApi', async () => {
9+
const actual = await vi.importActual<typeof import('@pages/api/_environment/environmentApi')>(
10+
'@pages/api/_environment/environmentApi',
11+
)
12+
return {
13+
...actual,
14+
getCronSecret: getCronSecretMock,
15+
getSiteUrl: getSiteUrlMock,
16+
}
17+
})
18+
19+
const buildContext = (request: Request) => ({
20+
request,
21+
clientAddress: '127.0.0.1',
22+
cookies: {
23+
get: () => undefined,
24+
},
25+
})
26+
27+
describe('cron runner', () => {
28+
let fetchMock: ReturnType<typeof vi.fn>
29+
let warnSpy: ReturnType<typeof vi.spyOn>
30+
31+
const run = (request: Request) => runAll(buildContext(request) as unknown as Parameters<APIRoute>[0])
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
fetchMock = vi.fn()
36+
vi.stubGlobal('fetch', fetchMock)
37+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
38+
})
39+
40+
afterEach(() => {
41+
vi.unstubAllGlobals()
42+
warnSpy.mockRestore()
43+
})
44+
45+
const createResponse = (path: string, overrides?: Partial<Response>) =>
46+
({
47+
ok: true,
48+
status: 200,
49+
statusText: 'OK',
50+
headers: new Headers({ 'x-vercel-elapsed-time': '123' }),
51+
json: vi.fn().mockResolvedValue({ path }),
52+
text: vi.fn().mockResolvedValue(''),
53+
...overrides,
54+
}) as unknown as Response
55+
56+
it('rejects unauthorized requests', async () => {
57+
const request = new Request('https://example.com/api/cron/run-all')
58+
const response = await run(request)
59+
const body = await response.json()
60+
61+
expect(response.status).toBe(401)
62+
expect(body.error.code).toBe('UNAUTHORIZED')
63+
expect(fetchMock).not.toHaveBeenCalled()
64+
expect(warnSpy).toHaveBeenCalled()
65+
})
66+
67+
it('calls downstream cron endpoints sequentially', async () => {
68+
fetchMock
69+
.mockResolvedValueOnce(createResponse('/api/cron/cleanup-confirmations'))
70+
.mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests'))
71+
.mockResolvedValueOnce(createResponse('/api/cron/ping-integrations'))
72+
73+
const request = new Request('https://example.com/api/cron/run-all', {
74+
method: 'GET',
75+
headers: {
76+
authorization: 'Bearer cron-secret',
77+
},
78+
})
79+
80+
const response = await run(request)
81+
const body = await response.json()
82+
83+
expect(response.status).toBe(200)
84+
expect(Array.isArray(body.results)).toBe(true)
85+
expect(body.results).toHaveLength(3)
86+
expect(fetchMock).toHaveBeenCalledTimes(3)
87+
88+
const headersUsed = fetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>
89+
expect(headersUsed.Authorization ?? headersUsed.authorization).toBe('Bearer cron-secret')
90+
})
91+
92+
it('surfaces downstream failure details', async () => {
93+
fetchMock
94+
.mockResolvedValueOnce(
95+
createResponse('/api/cron/cleanup-confirmations', {
96+
ok: false,
97+
status: 500,
98+
statusText: 'Internal Server Error',
99+
headers: new Headers(),
100+
}),
101+
)
102+
.mockResolvedValueOnce(createResponse('/api/cron/cleanup-dsar-requests'))
103+
.mockResolvedValueOnce(createResponse('/api/cron/ping-integrations'))
104+
105+
const request = new Request('https://example.com/api/cron/run-all', {
106+
method: 'GET',
107+
headers: {
108+
authorization: 'Bearer cron-secret',
109+
},
110+
})
111+
112+
const response = await run(request)
113+
const body = await response.json()
114+
115+
expect(response.status).toBe(500)
116+
expect(body.error.code).toBe('CRON_RUNNER_TARGET_FAILED')
117+
expect(body.error.message).toBe('Cron runner failed to execute downstream jobs')
118+
})
119+
})

src/pages/api/cron/run-all.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { APIRoute } from 'astro'
2+
import { getCronSecret, getSiteUrl } from '@pages/api/_environment/environmentApi'
3+
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
4+
import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler'
5+
import { createApiFunctionContext } from '@pages/api/_utils/requestContext'
6+
7+
export const prerender = false
8+
9+
const ROUTE = '/api/cron/run-all'
10+
const CRON_ENDPOINTS = [
11+
'/api/cron/cleanup-confirmations',
12+
'/api/cron/cleanup-dsar-requests',
13+
'/api/cron/ping-integrations',
14+
]
15+
16+
const buildErrorResponse = (
17+
error: unknown,
18+
context: ReturnType<typeof createApiFunctionContext>['context'],
19+
fallbackMessage: string,
20+
) => buildApiErrorResponse(handleApiFunctionError(error, context), { fallbackMessage })
21+
22+
async function triggerCronEndpoint(path: string) {
23+
const url = new URL(path, getSiteUrl())
24+
const response = await fetch(url.toString(), {
25+
headers: {
26+
Authorization: `Bearer ${getCronSecret()}`,
27+
},
28+
})
29+
30+
const elapsedMs = response.headers.get('x-vercel-elapsed-time')
31+
const duration = typeof elapsedMs === 'string' ? Number(elapsedMs) : undefined
32+
33+
let body: unknown
34+
35+
try {
36+
body = await response.json()
37+
} catch {
38+
body = await response.text()
39+
}
40+
41+
if (!response.ok) {
42+
throw new ApiFunctionError(
43+
`Cron runner failed for ${path}: ${response.status} ${response.statusText}`,
44+
{
45+
status: response.status,
46+
code: 'CRON_RUNNER_TARGET_FAILED',
47+
route: ROUTE,
48+
operation: path,
49+
details: {
50+
body,
51+
},
52+
},
53+
)
54+
}
55+
56+
return {
57+
path,
58+
status: response.status,
59+
durationMs: typeof duration === 'number' && Number.isFinite(duration) ? duration : undefined,
60+
body,
61+
}
62+
}
63+
64+
export const GET: APIRoute = async ({ request, clientAddress, cookies }) => {
65+
const { context: apiContext } = createApiFunctionContext({
66+
route: ROUTE,
67+
operation: 'GET',
68+
request,
69+
clientAddress,
70+
cookies,
71+
})
72+
73+
const authHeader = request.headers.get('authorization')
74+
if (authHeader !== `Bearer ${getCronSecret()}`) {
75+
console.warn('Unauthorized cron runner attempt - invalid CRON_SECRET')
76+
apiContext.extra = {
77+
...(apiContext.extra || {}),
78+
authHeader: authHeader ? 'PRESENT' : 'MISSING',
79+
clientAddress,
80+
}
81+
return buildErrorResponse(
82+
new ApiFunctionError({
83+
message: 'Unauthorized',
84+
status: 401,
85+
code: 'UNAUTHORIZED',
86+
}),
87+
apiContext,
88+
'Unauthorized cron access',
89+
)
90+
}
91+
92+
try {
93+
const results = await Promise.all(CRON_ENDPOINTS.map(triggerCronEndpoint))
94+
95+
return new Response(
96+
JSON.stringify({
97+
success: true,
98+
results,
99+
timestamp: new Date().toISOString(),
100+
}),
101+
{
102+
status: 200,
103+
headers: {
104+
'Content-Type': 'application/json',
105+
},
106+
},
107+
)
108+
} catch (error) {
109+
apiContext.extra = {
110+
...(apiContext.extra || {}),
111+
authHeader: 'REDACTED',
112+
}
113+
return buildErrorResponse(error, apiContext, 'Cron runner failed to execute downstream jobs')
114+
}
115+
}

vercel.json

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,8 @@
44
"regions": ["iad1"],
55
"crons": [
66
{
7-
"path": "/api/cron/cleanup-confirmations",
7+
"path": "/api/cron/run-all",
88
"schedule": "0 2 * * *"
9-
},
10-
{
11-
"path": "/api/cron/cleanup-dsar-requests",
12-
"schedule": "0 3 * * *"
13-
},
14-
{
15-
"path": "/api/cron/ping-integrations",
16-
"schedule": "0 4 * * *"
179
}
1810
]
1911
}

0 commit comments

Comments
 (0)