Files
Codeman/test/edge-cases.test.ts
T
arkonandClaude Opus 4.8 458fb81cbe feat(api): establish stable HTTP contract — uniform {success,data} envelope, status codes, /api/v1
Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).

Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).

Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.

Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.

Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:30:43 +02:00

408 lines
13 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const TEST_PORT = 3110;
const CASES_DIR = join(homedir(), 'codeman-cases');
describe('Edge Cases and Error Handling', () => {
let server: WebServer;
let baseUrl: string;
const createdCases: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
});
afterEach(() => {
// Clean up cases created during this test
while (createdCases.length > 0) {
const caseName = createdCases.pop()!;
const casePath = join(CASES_DIR, caseName);
if (existsSync(casePath)) {
rmSync(casePath, { recursive: true, force: true });
}
}
});
afterAll(async () => {
await server.stop();
}, 30000);
describe('Session Edge Cases', () => {
it('should handle deleting non-existent session gracefully', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id-12345`, {
method: 'DELETE',
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toContain('not found');
});
it('should handle getting non-existent session gracefully', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id-12345`);
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle running prompt on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'test' }),
});
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle input to non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'test' }),
});
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle resize on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols: 80, rows: 24 }),
});
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle interactive mode on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/interactive`, {
method: 'POST',
});
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle terminal buffer request on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/terminal`);
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle output request on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/output`);
const data = await response.json();
expect(data.error).toContain('not found');
});
});
describe('Case Edge Cases', () => {
it('should reject empty case name', async () => {
const response = await fetch(`${baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toContain('Invalid case name');
});
it('should reject case name with only special characters', async () => {
const response = await fetch(`${baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '!@#$%^' }),
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toContain('Invalid case name');
});
it('should reject case name with path traversal attempt', async () => {
const response = await fetch(`${baseUrl}/api/cases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '../escape' }),
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toContain('Invalid case name');
});
it('should handle getting non-existent case', async () => {
const response = await fetch(`${baseUrl}/api/cases/this-case-does-not-exist-12345`);
const data = await response.json();
expect(data.error).toBe('Case not found');
});
});
describe('Quick Start Edge Cases', () => {
it('should reject quick start with empty case name', async () => {
const response = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: '' }),
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toContain('Invalid case name');
});
it('should reject quick start with special characters', async () => {
const response = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: 'test/../../etc/passwd' }),
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toContain('Invalid case name');
});
it('should handle very long case names gracefully', async () => {
// Test with a very long but valid case name
const longName = 'a'.repeat(100);
const response = await fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: longName }),
});
const data = await response.json();
// Should succeed with valid characters, even if long
if (data.success) {
createdCases.push(data.data.caseName);
}
// Either succeeds or fails gracefully
expect(data).toHaveProperty('success');
});
});
describe('Respawn Controller Edge Cases', () => {
it('should handle getting respawn status for non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn`);
const data = await response.json();
expect(data.data.enabled).toBe(false);
expect(data.data.status).toBeNull();
});
it('should handle starting respawn on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn/start`, {
method: 'POST',
});
const data = await response.json();
expect(data.error).toContain('not found');
});
it('should handle stopping non-existent respawn controller', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn/stop`, {
method: 'POST',
});
const data = await response.json();
expect(data.error).toBe('Respawn controller not found');
});
it('should handle updating config on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/respawn/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idleTimeoutMs: 5000 }),
});
const data = await response.json();
expect(data.error).toContain('not found');
});
});
describe('Scheduled Run Edge Cases', () => {
it('should handle stopping non-existent scheduled run', async () => {
const response = await fetch(`${baseUrl}/api/scheduled/non-existent`, {
method: 'DELETE',
});
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toBe('Scheduled run not found');
});
it('should handle getting non-existent scheduled run', async () => {
const response = await fetch(`${baseUrl}/api/scheduled/non-existent`);
const data = await response.json();
expect(data.error).toBe('Scheduled run not found');
});
});
});
describe('Concurrent Session Handling', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT + 1, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT + 1}`;
});
afterAll(async () => {
await server.stop();
}, 30000);
it('should handle multiple sessions simultaneously', async () => {
// Create multiple sessions concurrently
const createPromises = Array(5)
.fill(null)
.map(() =>
fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
}).then((r) => r.json())
);
const results = await Promise.all(createPromises);
// All should succeed
for (const result of results) {
expect(result.success).toBe(true);
expect(result.data.session.id).toBeDefined();
}
// Verify sessions are listed
const listRes = await fetch(`${baseUrl}/api/sessions`);
const sessions = await listRes.json();
expect(sessions.data.length).toBeGreaterThanOrEqual(5);
// Clean up - delete all created sessions
for (const result of results) {
await fetch(`${baseUrl}/api/sessions/${result.data.session.id}`, {
method: 'DELETE',
});
}
});
it('should handle rapid session creation and deletion', async () => {
const iterations = 10;
for (let i = 0; i < iterations; i++) {
// Create
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const createData = await createRes.json();
expect(createData.success).toBe(true);
// Delete immediately
const deleteRes = await fetch(`${baseUrl}/api/sessions/${createData.data.session.id}`, {
method: 'DELETE',
});
const deleteData = await deleteRes.json();
expect(deleteData.success).toBe(true);
}
});
it('should handle multiple quick starts concurrently', async () => {
const caseNames = ['concurrent-test-1', 'concurrent-test-2', 'concurrent-test-3'];
const createdCases: string[] = [];
const quickStartPromises = caseNames.map((name) =>
fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: `${name}-${Date.now()}` }),
}).then((r) => r.json())
);
const results = await Promise.all(quickStartPromises);
for (const result of results) {
expect(result.success).toBe(true);
if (result.data.caseName) {
createdCases.push(result.data.caseName);
}
}
// Cleanup
const { rmSync, existsSync } = await import('node:fs');
const { join } = await import('node:path');
const { homedir } = await import('node:os');
for (const name of createdCases) {
const path = join(homedir(), 'codeman-cases', name);
if (existsSync(path)) {
rmSync(path, { recursive: true, force: true });
}
}
});
});
describe('API Request Validation', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(TEST_PORT + 2, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT + 2}`;
});
afterAll(async () => {
await server.stop();
}, 30000);
it('should handle malformed JSON in request body', async () => {
const response = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not valid json',
});
// Should return an error status
expect(response.status).toBeGreaterThanOrEqual(400);
});
it('should handle empty request body when one is expected', async () => {
const response = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '',
});
// Should either succeed with defaults or return appropriate error
// Fastify handles this gracefully
expect([200, 400, 500]).toContain(response.status);
});
it('should handle requests with missing content-type', async () => {
const response = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
});
// Should work or fail gracefully
expect(response.status).toBeDefined();
});
});