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
This commit is contained in:
Lukas May
2026-01-30 14:01:48 +01:00
parent f2a7b3f77a
commit ea79b3bf08

220
src/server/index.test.ts Normal file
View File

@@ -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);
});
});
});