diff --git a/src/routes/readyz/+server.ts b/src/routes/readyz/+server.ts new file mode 100644 index 0000000..c4a3fcd --- /dev/null +++ b/src/routes/readyz/+server.ts @@ -0,0 +1,27 @@ +import { json } from '@sveltejs/kit'; +import { sql } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +// Readiness probe: reports 200 only when the app can actually serve traffic +// (i.e. the database is reachable). This is distinct from /health, which is a +// pure liveness check. Orchestrator readiness gates should point here; the +// container liveness HEALTHCHECK stays on /health. +// A readiness verdict must never be cached: the endpoint flips between +// 200/ready and 503/unavailable, and a stale cached response could mis-gate +// traffic. Mark every response no-store so no intermediary reuses it. +const NO_STORE = { 'Cache-Control': 'no-store' }; + +export const GET: RequestHandler = async () => { + try { + await db.execute(sql`select 1`); + } catch (err) { + console.error('[readyz] database check failed', err); + return json( + { status: 'unavailable', checks: { database: 'down' } }, + { status: 503, headers: NO_STORE } + ); + } + + return json({ status: 'ready', checks: { database: 'ok' } }, { headers: NO_STORE }); +}; diff --git a/tests/e2e/readyz.e2e.ts b/tests/e2e/readyz.e2e.ts new file mode 100644 index 0000000..0432a88 --- /dev/null +++ b/tests/e2e/readyz.e2e.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test'; + +test('readiness endpoint reports ready when the database is reachable', async ({ request }) => { + const response = await request.get('/readyz'); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.status).toBe('ready'); + expect(body.checks.database).toBe('ok'); +}); diff --git a/tests/unit/readyz-endpoint.test.ts b/tests/unit/readyz-endpoint.test.ts new file mode 100644 index 0000000..1501e07 --- /dev/null +++ b/tests/unit/readyz-endpoint.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { executeMock } = vi.hoisted(() => ({ + executeMock: vi.fn() +})); + +vi.mock('$lib/server/db', () => ({ db: { execute: executeMock } })); + +vi.mock('drizzle-orm', () => ({ + sql: (strings: TemplateStringsArray) => strings.join('') +})); + +// SvelteKit's generated $types is not available under vitest; the endpoint only +// uses the type import, so stub it out. +vi.mock('./$types', () => ({})); + +import { GET } from '../../src/routes/readyz/+server'; + +// The route handler only reads request-independent state, so an empty event is fine. +const callGet = () => GET({} as never); + +describe('/readyz readiness probe', () => { + beforeEach(() => { + executeMock.mockReset(); + }); + + it('returns 200/ready with no-store when the database is reachable', async () => { + executeMock.mockResolvedValueOnce(undefined); + + const response = await callGet(); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ status: 'ready', checks: { database: 'ok' } }); + }); + + it('returns 503/unavailable with no-store when the database check throws', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + executeMock.mockRejectedValueOnce(new Error('connection refused')); + + const response = await callGet(); + + expect(response.status).toBe(503); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + status: 'unavailable', + checks: { database: 'down' } + }); + expect(consoleError).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +});