diff --git a/apps/vps/package.json b/apps/vps/package.json index b435e9cd..8908a329 100644 --- a/apps/vps/package.json +++ b/apps/vps/package.json @@ -34,6 +34,7 @@ "@effect/opentelemetry": "4.0.0-beta.93", "@effect/platform": "0.96.2", "@effect/platform-bun": "4.0.0-beta.93", + "@gbfm/api": "workspace:*", "@gbfm/core": "workspace:*", "@gbfm/email": "workspace:*", "@hono/zod-openapi": "1.3.0", diff --git a/apps/vps/src/health.blackbox.test.ts b/apps/vps/src/health.blackbox.test.ts new file mode 100644 index 00000000..e06a7016 --- /dev/null +++ b/apps/vps/src/health.blackbox.test.ts @@ -0,0 +1,53 @@ +import { HealthLiveResponse, HealthReadyResponse } from '@gbfm/api/health' +import { decodeResponseBody } from '@gbfm/api/testing' +import { beforeAll, describe, expect, it } from 'vitest' +import type { AppType } from '@/app' + +// Asserts only the wire contract (status, JSON body against @gbfm/api schemas) so these same assertions can run unchanged once health is ported to HttpApiBuilder (step 3a). +let app: AppType + +beforeAll(async () => { + const mod = await import('@/app') + app = mod.default +}) + +describe('Health endpoints', () => { + it('GET /health/live returns 200 with liveness JSON', async () => { + const res = await app.request('/health/live') + + expect(res.status).toBe(200) + await expect(decodeResponseBody(HealthLiveResponse, res)).resolves.toEqual({ ok: true }) + }) + + it('GET /health/ready returns 200 with readiness JSON when the database is reachable', async () => { + const res = await app.request('/health/ready') + + expect(res.status).toBe(200) + await expect(decodeResponseBody(HealthReadyResponse, res)).resolves.toEqual({ + dbConnected: true + }) + }) + + it('GET /health returns the same readiness result as /health/ready', async () => { + const res = await app.request('/health') + + expect(res.status).toBe(200) + await expect(decodeResponseBody(HealthReadyResponse, res)).resolves.toEqual({ + dbConnected: true + }) + }) + + it('repeated readiness calls within the cache window keep returning 200', async () => { + const first = await app.request('/health/ready') + const second = await app.request('/health/ready') + + expect(first.status).toBe(200) + expect(second.status).toBe(200) + }) + + it('responds 404 to unsupported methods on health paths', async () => { + const res = await app.request('/health/live', { method: 'POST' }) + + expect(res.status).toBe(404) + }) +}) diff --git a/bun.lock b/bun.lock index f83aacd0..3238bb00 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,7 @@ "@effect/opentelemetry": "4.0.0-beta.93", "@effect/platform": "0.96.2", "@effect/platform-bun": "4.0.0-beta.93", + "@gbfm/api": "workspace:*", "@gbfm/core": "workspace:*", "@gbfm/email": "workspace:*", "@hono/zod-openapi": "1.3.0", diff --git a/packages/api/package.json b/packages/api/package.json index c8b1eb89..6a83b711 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { "./health": "./src/health.ts", - "./errors": "./src/errors.ts" + "./errors": "./src/errors.ts", + "./testing": "./src/testing.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/api/src/testing.ts b/packages/api/src/testing.ts new file mode 100644 index 00000000..f8fb6071 --- /dev/null +++ b/packages/api/src/testing.ts @@ -0,0 +1,11 @@ +import type { Schema } from 'effect' +import { Effect, SchemaParser } from 'effect' + +// Lets a blackbox test decode a live Response against the same schema an HttpApiEndpoint declares, independent of which server produced it. +export const decodeResponseBody = async >( + schema: S, + response: Response +): Promise => { + const body: unknown = await response.json() + return Effect.runPromise(SchemaParser.decodeUnknownEffect(schema)(body)) +}