From ea79b3bf083e76a7fbe0aa7d5491102c6eb33e5d Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 14:01:48 +0100 Subject: [PATCH] test(01.1-05): add comprehensive tests for CoordinationServer - Test server lifecycle (start, stop, isRunning) - Test HTTP endpoints (GET /health, GET /status, 404, 405) - Test PID file management (create, remove, stale cleanup) - Verify server throws on double-start or existing PID file --- src/server/index.test.ts | 220 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/server/index.test.ts diff --git a/src/server/index.test.ts b/src/server/index.test.ts new file mode 100644 index 0000000..fc10ce6 --- /dev/null +++ b/src/server/index.test.ts @@ -0,0 +1,220 @@ +/** + * CoordinationServer Tests + * + * Tests for the HTTP coordination server lifecycle, endpoints, and PID file management. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CoordinationServer } from './index.js'; +import { writeFile, unlink, mkdir, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { ProcessManager } from '../process/index.js'; +import type { LogManager } from '../logging/index.js'; + +/** + * Creates a mock ProcessManager for testing. + */ +function createMockProcessManager(): ProcessManager { + return { + spawn: vi.fn(), + stop: vi.fn(), + stopAll: vi.fn().mockResolvedValue(undefined), + restart: vi.fn(), + isRunning: vi.fn(), + } as unknown as ProcessManager; +} + +/** + * Creates a mock LogManager for testing. + */ +function createMockLogManager(): LogManager { + return { + ensureLogDir: vi.fn(), + ensureProcessDir: vi.fn(), + getProcessDir: vi.fn(), + getLogPath: vi.fn(), + listLogs: vi.fn(), + cleanOldLogs: vi.fn(), + getBaseDir: vi.fn(), + } as unknown as LogManager; +} + +/** + * Gets a random high port to avoid conflicts. + */ +function getRandomPort(): number { + return Math.floor(Math.random() * (65535 - 49152) + 49152); +} + +/** + * Gets a temporary PID file path. + */ +function getTempPidFile(): string { + return join(tmpdir(), `cw-test-${randomUUID()}.pid`); +} + +describe('CoordinationServer', () => { + let server: CoordinationServer; + let processManager: ProcessManager; + let logManager: LogManager; + let port: number; + let pidFile: string; + + beforeEach(() => { + processManager = createMockProcessManager(); + logManager = createMockLogManager(); + port = getRandomPort(); + pidFile = getTempPidFile(); + server = new CoordinationServer( + { port, pidFile }, + processManager, + logManager + ); + }); + + afterEach(async () => { + // Clean up server if running + if (server.isRunning()) { + await server.stop(); + } + // Clean up PID file if exists + try { + await unlink(pidFile); + } catch { + // Ignore if file doesn't exist + } + }); + + describe('lifecycle', () => { + it('should start listening on configured port', async () => { + await server.start(); + + expect(server.isRunning()).toBe(true); + expect(server.getPort()).toBe(port); + }); + + it('should throw if already running', async () => { + await server.start(); + + await expect(server.start()).rejects.toThrow('Server is already running'); + }); + + it('should throw if PID file exists from another server', async () => { + // Create a PID file with current process PID (simulating another server) + await mkdir(join(tmpdir()), { recursive: true }); + await writeFile(pidFile, process.pid.toString(), 'utf-8'); + + await expect(server.start()).rejects.toThrow( + /Another server appears to be running/ + ); + }); + + it('should stop the server', async () => { + await server.start(); + expect(server.isRunning()).toBe(true); + + await server.stop(); + expect(server.isRunning()).toBe(false); + }); + + it('should be idempotent when stopping (no error if not running)', async () => { + expect(server.isRunning()).toBe(false); + + // Should not throw + await server.stop(); + expect(server.isRunning()).toBe(false); + }); + + it('should report correct running state via isRunning()', async () => { + expect(server.isRunning()).toBe(false); + + await server.start(); + expect(server.isRunning()).toBe(true); + + await server.stop(); + expect(server.isRunning()).toBe(false); + }); + }); + + describe('HTTP endpoints', () => { + beforeEach(async () => { + await server.start(); + }); + + it('should return health status with uptime on GET /health', async () => { + const response = await fetch(`http://127.0.0.1:${port}/health`); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.status).toBe('ok'); + expect(typeof data.uptime).toBe('number'); + expect(data.uptime).toBeGreaterThanOrEqual(0); + expect(typeof data.processCount).toBe('number'); + }); + + it('should return server info on GET /status', async () => { + const response = await fetch(`http://127.0.0.1:${port}/status`); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.server).toBeDefined(); + expect(data.server.pid).toBe(process.pid); + expect(typeof data.server.uptime).toBe('number'); + expect(typeof data.server.startedAt).toBe('string'); + expect(Array.isArray(data.processes)).toBe(true); + }); + + it('should return 404 on GET /unknown', async () => { + const response = await fetch(`http://127.0.0.1:${port}/unknown`); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe('Not found'); + }); + + it('should return 405 on POST /health', async () => { + const response = await fetch(`http://127.0.0.1:${port}/health`, { + method: 'POST', + }); + const data = await response.json(); + + expect(response.status).toBe(405); + expect(data.error).toBe('Method not allowed'); + }); + }); + + describe('PID file management', () => { + it('should create PID file on start', async () => { + await server.start(); + + const content = await readFile(pidFile, 'utf-8'); + expect(parseInt(content.trim(), 10)).toBe(process.pid); + }); + + it('should remove PID file on stop', async () => { + await server.start(); + await server.stop(); + + await expect(readFile(pidFile, 'utf-8')).rejects.toThrow(/ENOENT/); + }); + + it('should clean up stale PID file from dead process', async () => { + // Create a stale PID file with a non-existent PID + // Use a very high PID that's unlikely to exist + const stalePid = 999999; + await mkdir(join(tmpdir()), { recursive: true }); + await writeFile(pidFile, stalePid.toString(), 'utf-8'); + + // Server should start successfully after cleaning up stale PID + await server.start(); + + expect(server.isRunning()).toBe(true); + + // Verify PID file now has our PID + const content = await readFile(pidFile, 'utf-8'); + expect(parseInt(content.trim(), 10)).toBe(process.pid); + }); + }); +});