mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
test: add shared mock infrastructure and route tests (phase 7)
Consolidate duplicated MockSession/MockStateStore into test/mocks/, migrate respawn tests to shared mocks, and add 58 route tests for session, system, and respawn endpoints using Fastify app.inject(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared utilities for route testing.
|
||||
*
|
||||
* Creates minimal Fastify instances with just the route module under test
|
||||
* and a mock context. Uses app.inject() for HTTP testing without real ports.
|
||||
*/
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
|
||||
export interface RouteTestHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Fastify instance with a route module registered against a mock context.
|
||||
*
|
||||
* @param registerFn - The route registration function (e.g., registerSessionRoutes).
|
||||
* Uses `any` for ctx parameter because route functions expect typed port intersections
|
||||
* that MockRouteContext satisfies structurally but not nominally.
|
||||
* @param ctxOptions - Optional overrides for the mock context
|
||||
*/
|
||||
export async function createRouteTestHarness(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
registerFn: (app: FastifyInstance, ctx: any) => void,
|
||||
ctxOptions?: { sessionId?: string },
|
||||
): Promise<RouteTestHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
|
||||
// Register cookie plugin — some routes access req.cookies
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const ctx = createMockRouteContext(ctxOptions);
|
||||
|
||||
registerFn(app, ctx);
|
||||
await app.ready();
|
||||
|
||||
return { app, ctx };
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @fileoverview Tests for respawn route handlers.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js';
|
||||
|
||||
describe('respawn-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerRespawnRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/respawn ==========
|
||||
|
||||
describe('GET /api/sessions/:id/respawn', () => {
|
||||
it('returns disabled status when no controller exists', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.enabled).toBe(false);
|
||||
expect(body.status).toBeNull();
|
||||
});
|
||||
|
||||
it('returns status when controller exists', async () => {
|
||||
const mockController = {
|
||||
getStatus: vi.fn(() => ({
|
||||
state: 'watching',
|
||||
health: 100,
|
||||
cycleCount: 0,
|
||||
})),
|
||||
getConfig: vi.fn(() => ({})),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.enabled).toBe(true);
|
||||
expect(body.state).toBe('watching');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/respawn/config ==========
|
||||
|
||||
describe('GET /api/sessions/:id/respawn/config', () => {
|
||||
it('returns null config when no controller and no pre-saved config', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.config).toBeNull();
|
||||
expect(body.active).toBe(false);
|
||||
});
|
||||
|
||||
it('returns active config when controller exists', async () => {
|
||||
const mockConfig = { idleTimeoutMs: 5000, enabled: true };
|
||||
const mockController = {
|
||||
getConfig: vi.fn(() => mockConfig),
|
||||
getStatus: vi.fn(() => ({})),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.active).toBe(true);
|
||||
expect(body.config.idleTimeoutMs).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/respawn/stop ==========
|
||||
|
||||
describe('POST /api/sessions/:id/respawn/stop', () => {
|
||||
it('returns error when no controller exists', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/stop`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('stops existing controller and cleans up', async () => {
|
||||
const mockController = {
|
||||
stop: vi.fn(),
|
||||
getConfig: vi.fn(() => ({})),
|
||||
getStatus: vi.fn(() => ({})),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/stop`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(mockController.stop).toHaveBeenCalled();
|
||||
expect(harness.ctx.respawnControllers.has(harness.ctx._sessionId)).toBe(false);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('respawn:stopped', {
|
||||
sessionId: harness.ctx._sessionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/respawn/start ==========
|
||||
|
||||
describe('POST /api/sessions/:id/respawn/start', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/respawn/start',
|
||||
});
|
||||
// findSessionOrFail throws with statusCode 404
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects opencode sessions', async () => {
|
||||
harness.ctx._session.mode = 'opencode';
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/start`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/sessions/:id/respawn/config ==========
|
||||
|
||||
describe('PUT /api/sessions/:id/respawn/config', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/sessions/nonexistent/respawn/config',
|
||||
payload: { idleTimeoutMs: 5000 },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects invalid config', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
|
||||
payload: { idleTimeoutMs: 'not-a-number' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('updates running controller config', async () => {
|
||||
const mockController = {
|
||||
updateConfig: vi.fn(),
|
||||
getConfig: vi.fn(() => ({ idleTimeoutMs: 5000 })),
|
||||
getStatus: vi.fn(() => ({})),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
|
||||
payload: { idleTimeoutMs: 5000 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(mockController.updateConfig).toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
'respawn:configUpdated',
|
||||
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves pre-config when no controller running', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`,
|
||||
payload: { idleTimeoutMs: 5000 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.mux.updateRespawnConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/respawn/enable ==========
|
||||
|
||||
describe('POST /api/sessions/:id/respawn/enable', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/respawn/enable',
|
||||
});
|
||||
// findSessionOrFail not used here — let's check the actual response
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* @fileoverview Tests for session route handlers.
|
||||
*
|
||||
* Uses app.inject() (Fastify's built-in test helper) — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
describe('session-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSessionRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions ==========
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
it('returns session list when sessions exist', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array when no sessions', async () => {
|
||||
harness.ctx.sessions.clear();
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id ==========
|
||||
|
||||
describe('GET /api/sessions/:id', () => {
|
||||
it('returns session state for existing session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.id).toBe(harness.ctx._sessionId);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/sessions/:id ==========
|
||||
|
||||
describe('DELETE /api/sessions/:id', () => {
|
||||
it('deletes existing session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(
|
||||
harness.ctx._sessionId,
|
||||
true,
|
||||
'user_delete',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/sessions/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/sessions (delete all) ==========
|
||||
|
||||
describe('DELETE /api/sessions', () => {
|
||||
it('deletes all sessions', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/sessions',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.killed).toBe(1);
|
||||
expect(harness.ctx.cleanupSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/sessions/:id/name ==========
|
||||
|
||||
describe('PUT /api/sessions/:id/name', () => {
|
||||
it('renames session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/name`,
|
||||
payload: { name: 'new-name' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.name).toBe('new-name');
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
'session:updated',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/sessions/nonexistent/name',
|
||||
payload: { name: 'test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/sessions/:id/color ==========
|
||||
|
||||
describe('PUT /api/sessions/:id/color', () => {
|
||||
it('sets session color', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/color`,
|
||||
payload: { color: 'blue' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.color).toBe('blue');
|
||||
});
|
||||
|
||||
it('rejects invalid color', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/color`,
|
||||
payload: { color: 'neon-rainbow' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/input ==========
|
||||
|
||||
describe('POST /api/sessions/:id/input', () => {
|
||||
it('sends input to session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/input`,
|
||||
payload: { input: 'hello' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/input',
|
||||
payload: { input: 'hello' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty payload', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/input`,
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/resize ==========
|
||||
|
||||
describe('POST /api/sessions/:id/resize', () => {
|
||||
it('resizes session terminal', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 120, rows: 40 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40);
|
||||
});
|
||||
|
||||
it('rejects cols exceeding max (500)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 501, rows: 24 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects rows exceeding max (200)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 80, rows: 201 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects zero dimensions', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
|
||||
payload: { cols: 0, rows: 24 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/terminal ==========
|
||||
|
||||
describe('GET /api/sessions/:id/terminal', () => {
|
||||
it('returns terminal buffer', async () => {
|
||||
harness.ctx._session.terminalBuffer = 'hello world';
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.terminalBuffer).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/terminal',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/run ==========
|
||||
|
||||
describe('POST /api/sessions/:id/run', () => {
|
||||
it('runs prompt on session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/run`,
|
||||
payload: { prompt: 'do something' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty prompt', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/run`,
|
||||
payload: { prompt: '' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/run',
|
||||
payload: { prompt: 'test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
harness.ctx._session.isBusy.mockReturnValue(true);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/run`,
|
||||
payload: { prompt: 'test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/interactive ==========
|
||||
|
||||
describe('POST /api/sessions/:id/interactive', () => {
|
||||
it('starts interactive mode', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.startInteractive).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/interactive',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
harness.ctx._session.isBusy.mockReturnValue(true);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/interactive`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/shell ==========
|
||||
|
||||
describe('POST /api/sessions/:id/shell', () => {
|
||||
it('starts shell mode', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/shell`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx._session.startShell).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error if session is busy', async () => {
|
||||
harness.ctx._session.isBusy.mockReturnValue(true);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/shell`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/output ==========
|
||||
|
||||
describe('GET /api/sessions/:id/output', () => {
|
||||
it('returns session output data', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/output`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveProperty('textOutput');
|
||||
expect(body.data).toHaveProperty('messages');
|
||||
expect(body.data).toHaveProperty('errorBuffer');
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/output',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/ralph-state ==========
|
||||
|
||||
describe('GET /api/sessions/:id/ralph-state', () => {
|
||||
it('returns ralph state data', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-state`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveProperty('loop');
|
||||
expect(body.data).toHaveProperty('todos');
|
||||
expect(body.data).toHaveProperty('todoStats');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/active-tools ==========
|
||||
|
||||
describe('GET /api/sessions/:id/active-tools', () => {
|
||||
it('returns active tools', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/active-tools`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveProperty('tools');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/logout ==========
|
||||
|
||||
describe('POST /api/logout', () => {
|
||||
it('returns success', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/logout',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Tests for system route handlers.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* Note: Some system routes access singleton modules (subagentWatcher, imageWatcher)
|
||||
* directly. Those routes are tested lightly here since we can't easily mock singletons
|
||||
* without vi.mock(). Focus is on routes that use the port context.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
describe('system-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerSystemRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/status ==========
|
||||
|
||||
describe('GET /api/status', () => {
|
||||
it('returns server status', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.getLightState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/config ==========
|
||||
|
||||
describe('GET /api/config', () => {
|
||||
it('returns config', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.config).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/config ==========
|
||||
|
||||
describe('PUT /api/config', () => {
|
||||
it('updates config with valid payload', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/config',
|
||||
payload: { maxConcurrentSessions: 10 },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.store.setConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unknown config fields (strict schema)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/config',
|
||||
payload: { unknownField: 'invalid' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/stats ==========
|
||||
|
||||
describe('GET /api/stats', () => {
|
||||
it('returns aggregate stats', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/stats' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.stats).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/token-stats ==========
|
||||
|
||||
describe('GET /api/token-stats', () => {
|
||||
it('returns daily and aggregate token stats', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/token-stats' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.daily).toBeDefined();
|
||||
expect(body.totals).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/debug/memory ==========
|
||||
|
||||
describe('GET /api/debug/memory', () => {
|
||||
it('returns memory and map usage info', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/debug/memory' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.memory).toBeDefined();
|
||||
expect(body.memory.rssMB).toBeGreaterThan(0);
|
||||
expect(body.mapSizes).toBeDefined();
|
||||
expect(body.uptime).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/system/stats ==========
|
||||
|
||||
describe('GET /api/system/stats', () => {
|
||||
it('returns CPU and memory stats', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toHaveProperty('cpu');
|
||||
expect(body).toHaveProperty('memory');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cleanup-state ==========
|
||||
|
||||
describe('POST /api/cleanup-state', () => {
|
||||
it('cleans up stale session state', async () => {
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.cleanedSessions).toBe(0);
|
||||
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/subagents ==========
|
||||
|
||||
describe('GET /api/subagents', () => {
|
||||
it('returns subagent list', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/subagents' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/auth/revoke ==========
|
||||
|
||||
describe('POST /api/auth/revoke', () => {
|
||||
it('returns success even without auth sessions', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/revoke',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user