From 27095d08c60f77a23b913c291c560b5212b71b59 Mon Sep 17 00:00:00 2001 From: Guide Fari Date: Sat, 11 Jul 2026 00:50:37 +0200 Subject: [PATCH] test(api): blackbox behavior spec for health endpoints Step 1b of the Hono -> Effect HttpApi migration (docs/migration-effect-http-api.md). - decodeResponseBody (packages/api/src/testing.ts): decodes an HTTP Response body against a response schema with no decoding-service requirements. Reusable across any server implementation serving the same contract. - health.blackbox.test.ts (apps/vps): request/response assertions against the live Hono app (status codes, body shape via the shared @gbfm/api schemas). No knowledge of Hono or Effect internals -- the same assertions will run unchanged once health is ported to HttpApiBuilder in step 3a, proving behavior was preserved. Deliberately not testing the readiness-failure/500 path or the 5s cache's internal call count here: forcing a DB failure or observing call counts needs a seam in app.ts this contract-only step doesn't touch. Revisit once step 3a gives us handler-level injection. --- apps/vps/package.json | 1 + apps/vps/src/health.blackbox.test.ts | 53 ++++++++++++++++++++++++++++ bun.lock | 1 + packages/api/package.json | 3 +- packages/api/src/testing.ts | 11 ++++++ 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 apps/vps/src/health.blackbox.test.ts create mode 100644 packages/api/src/testing.ts 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)) +}