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:
220
src/server/index.test.ts
Normal file
220
src/server/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user