From 66dc2b20654b38fc3c507d1089d0256f4223a377 Mon Sep 17 00:00:00 2001 From: Brandon Wolfgang Date: Wed, 21 Oct 2026 21:32:40 -0500 Subject: [PATCH] feat: implement Phase 1 API foundation and testing - Configure the Node.js test runner with tsx - Add endpoint tests for health, readiness, and echo request validation - Cover input length boundaries, invalid message types, unknown routes, and unexpected errors - Extract the Fastify app factory to support isolated testing without starting the server - Add centralized error handling and consistent 404 responses - Guard server startup to prevent listeners from opening when the module is imported --- package.json | 2 +- src/server.test.ts | 140 +++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 79 ++++++++++++++++++++----- 3 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 src/server.test.ts diff --git a/package.json b/package.json index 230576d..5c55e4b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "tsx --test src/**/*.test.ts", "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js" diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..7aa6161 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert"; +import { after, describe, it } from "node:test"; + +import { buildApp } from "./server.js"; +import { setServers } from "node:dns"; + +describe("API endpoints", () => { + const app = buildApp(); + + after(async () => { + await app.close(); + }); + + it("GET /health returns 200 and status ok", async () => { + const response = await app.inject({ + method: "GET", + url: "/health", + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { + status: "ok", + }); + }); + + it("GET /ready returns 200 and dependency status", async () => { + const response = await app.inject({ + method: "GET", + url: "/ready", + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { + status: "ready", + dependencies: { + ollama: "not-integrated", + }, + }); + }); + + it("POST /echo returns the validated message", async () => { + const response = await app.inject({ + method: "POST", + url: "/echo", + payload: { + message: "Hello, world!", + }, + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { + message: "Hello, world!", + }); + }); + + it("POST /echo accepts a 500-character message", async () => { + const message = "a".repeat(500); + + const response = await app.inject({ + method: "POST", + url: "/echo", + payload: { message }, + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { message }); + }); + + it("POST /echo rejects an empty message", async () => { + const response = await app.inject({ + method: "POST", + url: "/echo", + payload: { + message: "", + }, + }); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.json(), { + error: "Invalid request body", + }); + }); + + it("POST /echo rejects a message longer than 500 characters", async () => { + const message = "a".repeat(501); + + const response = await app.inject({ + method: "POST", + url: "/echo", + payload: { message }, + }); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.json(), { + error: "Invalid request body", + }); + }); + + it("POST /echo rejects a non-string message", async () => { + const message = "a".repeat(501); + + const response = await app.inject({ + method: "POST", + url: "/echo", + payload: { + message: 123, + }, + }); + + assert.equal(response.statusCode, 400); + assert.deepEqual(response.json(), { + error: "Invalid request body", + }); + }); + + it("returns 404 for an unknown endpoint", async () => { + const response = await app.inject({ + method: "GET", + url: "/does-not-exist", + }); + + assert.equal(response.statusCode, 404); + assert.deepEqual(response.json(), { + error: "Not Found", + }); + }); + + it("returns a generic 500 response for unexpected error", async () => { + const response = await app.inject({ + method: "GET", + url: "/test-error", + }); + + assert.equal(response.statusCode, 500); + assert.deepEqual(response.json(), { + error: "Internal Server Error", + }); + assert.equal(response.body.includes("Sensitive internal detail"), false); + }); +}); diff --git a/src/server.ts b/src/server.ts index b27bccc..7b74142 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,24 +1,73 @@ -import Fastify from 'fastify'; +import Fastify, { type FastifyError } from "fastify"; +import { z } from "zod"; -const app = Fastify({ - logger: true, +const echoSchema = z.object({ + message: z.string().min(1).max(500), }); -app.get('/health', async () => ({ - status: 'ok', -})); +export const buildApp = () => { + const app = Fastify({ + logger: true, + }); -app.get('/ready', async () => ({ - status: 'ready', - dependencies: { - ollama: 'not-integrated', - }, -})); + app.setErrorHandler((error: FastifyError, request, reply) => { + request.log.error(error); + + const candidateStatusCode = error.statusCode ?? 500; + const statusCode = + candidateStatusCode >= 400 && candidateStatusCode < 500 + ? candidateStatusCode + : 500; + + return reply.code(statusCode).send({ + error: statusCode === 500 ? "Internal Server Error" : "Request Error", + }); + }); + + app.setNotFoundHandler((_request, reply) => { + return reply.code(404).send({ + error: "Not Found", + }); + }); + + app.get("/health", async () => ({ + status: "ok", + })); + + app.get("/ready", async () => ({ + status: "ready", + dependencies: { + ollama: "not-integrated", + }, + })); + + app.post("/echo", async (request, reply) => { + const result = echoSchema.safeParse(request.body); + + if (!result.success) { + return reply.code(400).send({ + error: "Invalid request body", + }); + } + + return { + message: result.data.message, + }; + }); + + app.get("/test-error", async () => { + throw new Error("Sensitive internal detail"); + }); + + return app; +}; const start = async () => { + const app = buildApp(); + try { await app.listen({ - host: '127.0.0.1', + host: "127.0.0.1", port: 3000, }); } catch (error) { @@ -27,4 +76,6 @@ const start = async () => { } }; -void start(); +if (import.meta.url === `file://${process.argv[1]}`) { + void start(); +}