Files
Profa 2253d05618 feat: flatten project structure — archive v2, promote v3 to root
Remove the nested v3/ subdirectory by moving all active source code,
tests, configs, and assets directly to the project root. Archive v2/
(dead code, 847MB) to v2-archive.tar.gz.

Key changes:
- Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/
- Move v3/tsconfig.json, v3/vitest.config.ts to root
- Merge v3/package.json into root package.json (ESM, scripts, deps, exports)
- Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/
- Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes
- Fix all init installer path resolution (3-level-up → 2-level-up)
- Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe"
- Update settings.json ADR/DDD directories to docs/implementation/
- Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks)
- Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces
- Update 15 guidance shards: v3/src/ → src/ path references
- Fix sync interfaces: remove v3/ and v2/ path references
- Fix test path calculations (process.cwd() + '..' no longer needed)
- Fix flaky perf test threshold (500ms → 1000ms for CI environments)
- Fix complexity-analyzer test to match ADR-051 keyword-based eligibility

Build passes, 17,901 tests pass, CLI and MCP bundles verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:35:41 +00:00

720 lines
22 KiB
TypeScript

/**
* Agentic QE v3 - HTTP Client Unit Tests
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
HttpClient,
createHttpClient,
getHttpClient,
} from '../../../../src/shared/http';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('HttpClient', () => {
let client: HttpClient;
beforeEach(() => {
client = createHttpClient();
mockFetch.mockReset();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
// ============================================================================
// Basic Request Tests
// ============================================================================
describe('get', () => {
it('should make a GET request successfully', async () => {
const mockResponse = new Response(JSON.stringify({ data: 'test' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
mockFetch.mockResolvedValueOnce(mockResponse);
const result = await client.get('https://api.example.com/data');
expect(result.success).toBe(true);
if (result.success) {
expect(result.value.status).toBe(200);
}
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({ method: 'GET' })
);
});
it('should include custom headers', async () => {
mockFetch.mockResolvedValueOnce(new Response('ok'));
await client.get('https://api.example.com/data', {
headers: { Authorization: 'Bearer token123' },
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer token123',
}),
})
);
});
});
describe('post', () => {
it('should make a POST request with JSON body', async () => {
mockFetch.mockResolvedValueOnce(new Response('{"id": 1}', { status: 201 }));
const body = { name: 'test', value: 42 };
const result = await client.post('https://api.example.com/items', body);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/items',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(body),
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
})
);
});
});
describe('put', () => {
it('should make a PUT request with JSON body', async () => {
mockFetch.mockResolvedValueOnce(new Response('{"updated": true}'));
const body = { name: 'updated' };
const result = await client.put('https://api.example.com/items/1', body);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/items/1',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify(body),
})
);
});
});
describe('delete', () => {
it('should make a DELETE request', async () => {
mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
const result = await client.delete('https://api.example.com/items/1');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/items/1',
expect.objectContaining({ method: 'DELETE' })
);
});
});
// ============================================================================
// Timeout Tests
// ============================================================================
describe('timeout', () => {
it('should timeout after specified duration', async () => {
// Simulate AbortError when signal is aborted
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
mockFetch.mockImplementation((_url: string, options: RequestInit) => {
return new Promise((resolve, reject) => {
// Check if signal is already aborted
if (options.signal?.aborted) {
reject(abortError);
return;
}
// Listen for abort event
options.signal?.addEventListener('abort', () => {
reject(abortError);
});
});
});
const resultPromise = client.get('https://api.example.com/slow', {
timeout: 1000,
retries: 0,
circuitBreaker: false,
});
// Advance timers to trigger the AbortController timeout
await vi.advanceTimersByTimeAsync(1500);
const result = await resultPromise;
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('TIMEOUT');
}
});
it('should use default timeout of 30000ms', async () => {
mockFetch.mockResolvedValueOnce(new Response('ok'));
await client.get('https://api.example.com/data');
// Verify AbortController signal was passed
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
signal: expect.any(AbortSignal),
})
);
});
});
// ============================================================================
// Retry Tests
// ============================================================================
describe('retry', () => {
it('should retry failed requests with exponential backoff', async () => {
mockFetch
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(new Response('ok'));
const resultPromise = client.get('https://api.example.com/flaky', {
retries: 3,
retryDelay: 100,
circuitBreaker: false,
});
// First retry after 100ms (100 * 2^0)
await vi.advanceTimersByTimeAsync(100);
// Second retry after 200ms (100 * 2^1)
await vi.advanceTimersByTimeAsync(200);
const result = await resultPromise;
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('should return error after all retries exhausted', async () => {
mockFetch.mockRejectedValue(new Error('Persistent error'));
const resultPromise = client.get('https://api.example.com/broken', {
retries: 2,
retryDelay: 50,
circuitBreaker: false,
});
// Advance through all retries
await vi.advanceTimersByTimeAsync(50); // First retry
await vi.advanceTimersByTimeAsync(100); // Second retry
const result = await resultPromise;
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toBe('Persistent error');
expect(result.error.timing?.retryCount).toBe(2);
}
expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
it('should not retry when retries is 0', async () => {
mockFetch.mockRejectedValue(new Error('Error'));
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should apply exponential backoff with correct delays', async () => {
mockFetch
.mockRejectedValueOnce(new Error('Error 1'))
.mockRejectedValueOnce(new Error('Error 2'))
.mockRejectedValueOnce(new Error('Error 3'))
.mockResolvedValueOnce(new Response('ok'));
const resultPromise = client.get('https://api.example.com/data', {
retries: 3,
retryDelay: 1000,
circuitBreaker: false,
});
// First retry: 1000 * 2^0 = 1000ms
await vi.advanceTimersByTimeAsync(1000);
expect(mockFetch).toHaveBeenCalledTimes(2);
// Second retry: 1000 * 2^1 = 2000ms
await vi.advanceTimersByTimeAsync(2000);
expect(mockFetch).toHaveBeenCalledTimes(3);
// Third retry: 1000 * 2^2 = 4000ms
await vi.advanceTimersByTimeAsync(4000);
expect(mockFetch).toHaveBeenCalledTimes(4);
const result = await resultPromise;
expect(result.success).toBe(true);
});
});
// ============================================================================
// Circuit Breaker Tests
// ============================================================================
describe('circuit breaker', () => {
it('should open circuit after threshold failures', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Make 5 failing requests to open the circuit
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
}
const state = client.getCircuitState('https://api.example.com/unstable');
expect(state.state).toBe('open');
expect(state.failures).toBe(5);
});
it('should reject requests when circuit is open', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open the circuit
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
}
// Next request should fail immediately
mockFetch.mockClear();
const result = await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('CIRCUIT_OPEN');
}
expect(mockFetch).not.toHaveBeenCalled();
});
it('should transition to half-open after reset timeout', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open the circuit
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
}
// Advance time past the reset timeout (30 seconds)
await vi.advanceTimersByTimeAsync(31000);
// Next request should be allowed (half-open)
mockFetch.mockResolvedValueOnce(new Response('ok'));
const result = await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalled();
});
it('should close circuit after successful request in half-open state', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open the circuit
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
}
// Advance time to half-open
await vi.advanceTimersByTimeAsync(31000);
// Successful request should close the circuit
mockFetch.mockResolvedValueOnce(new Response('ok'));
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
const state = client.getCircuitState('https://api.example.com/unstable');
expect(state.state).toBe('closed');
expect(state.failures).toBe(0);
});
it('should allow bypassing circuit breaker with option', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open the circuit
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
}
// Request with circuit breaker disabled should go through
mockFetch.mockClear();
mockFetch.mockResolvedValueOnce(new Response('ok'));
const result = await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalled();
});
it('should reset circuit breaker manually', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open the circuit
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/unstable', {
retries: 0,
circuitBreaker: true,
});
}
client.resetCircuit('https://api.example.com/unstable');
const state = client.getCircuitState('https://api.example.com/unstable');
expect(state.state).toBe('closed');
expect(state.failures).toBe(0);
});
it('should reset all circuits when called without URL', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open circuits for multiple domains
for (let i = 0; i < 5; i++) {
await client.get('https://api1.example.com/data', {
retries: 0,
circuitBreaker: true,
});
await client.get('https://api2.example.com/data', {
retries: 0,
circuitBreaker: true,
});
}
client.resetCircuit();
const state1 = client.getCircuitState('https://api1.example.com');
const state2 = client.getCircuitState('https://api2.example.com');
expect(state1.state).toBe('closed');
expect(state2.state).toBe('closed');
});
it('should track circuits per origin', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Fail requests to one domain
for (let i = 0; i < 5; i++) {
await client.get('https://api1.example.com/data', {
retries: 0,
circuitBreaker: true,
});
}
// Other domain should still work
mockFetch.mockResolvedValueOnce(new Response('ok'));
const result = await client.get('https://api2.example.com/data', {
retries: 0,
circuitBreaker: true,
});
expect(result.success).toBe(true);
});
});
// ============================================================================
// Health Check Tests
// ============================================================================
describe('healthCheck', () => {
it('should return true for healthy endpoint', async () => {
mockFetch.mockResolvedValueOnce(new Response('OK', { status: 200 }));
const isHealthy = await client.healthCheck('https://api.example.com/health');
expect(isHealthy).toBe(true);
});
it('should return false for unhealthy endpoint', async () => {
mockFetch.mockResolvedValueOnce(
new Response('Service Unavailable', { status: 503 })
);
const isHealthy = await client.healthCheck('https://api.example.com/health');
expect(isHealthy).toBe(false);
});
it('should return false when request fails', async () => {
// Health check has retries: 1, so we need to fail twice
mockFetch
.mockRejectedValueOnce(new Error('Connection refused'))
.mockRejectedValueOnce(new Error('Connection refused'));
const resultPromise = client.healthCheck('https://api.example.com/health');
// Advance timer for the retry delay
await vi.advanceTimersByTimeAsync(1000);
const isHealthy = await resultPromise;
expect(isHealthy).toBe(false);
});
it('should use short timeout for health checks', async () => {
// Simulate AbortError when signal is aborted
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
mockFetch.mockImplementation((_url: string, options: RequestInit) => {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(abortError);
return;
}
options.signal?.addEventListener('abort', () => {
reject(abortError);
});
});
});
const resultPromise = client.healthCheck('https://api.example.com/health');
// Health check uses 5000ms timeout, advance past it
await vi.advanceTimersByTimeAsync(6000);
// Also advance for the retry
await vi.advanceTimersByTimeAsync(2000);
const isHealthy = await resultPromise;
expect(isHealthy).toBe(false);
});
it('should disable circuit breaker for health checks', async () => {
mockFetch.mockRejectedValue(new Error('Service unavailable'));
// Open the circuit through regular requests
for (let i = 0; i < 5; i++) {
await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: true,
});
}
// Health check should still go through (circuit breaker disabled)
mockFetch.mockClear();
mockFetch.mockResolvedValueOnce(new Response('OK'));
const isHealthy = await client.healthCheck('https://api.example.com/health');
expect(isHealthy).toBe(true);
expect(mockFetch).toHaveBeenCalled();
});
});
// ============================================================================
// Request Timing Tests
// ============================================================================
describe('request timing', () => {
it('should include timing metrics in successful response', async () => {
mockFetch.mockResolvedValueOnce(new Response('ok'));
const result = await client.get('https://api.example.com/data', {
circuitBreaker: false,
});
expect(result.success).toBe(true);
// Note: Timing is only exposed in error responses for Result pattern
});
it('should include timing metrics in error response', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.timing).toBeDefined();
expect(result.error.timing?.duration).toBeGreaterThanOrEqual(0);
expect(result.error.timing?.startTime).toBeLessThanOrEqual(
result.error.timing?.endTime ?? 0
);
}
});
it('should track retry count in timing', async () => {
mockFetch
.mockRejectedValueOnce(new Error('Error 1'))
.mockRejectedValueOnce(new Error('Error 2'))
.mockRejectedValueOnce(new Error('Error 3'));
const resultPromise = client.get('https://api.example.com/data', {
retries: 2,
retryDelay: 100,
circuitBreaker: false,
});
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(200);
const result = await resultPromise;
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.timing?.retryCount).toBe(2);
}
});
});
// ============================================================================
// Error Code Tests
// ============================================================================
describe('error codes', () => {
it('should return TIMEOUT for AbortError', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
mockFetch.mockRejectedValueOnce(abortError);
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('TIMEOUT');
}
});
it('should return CONNECTION_REFUSED for ECONNREFUSED', async () => {
mockFetch.mockRejectedValueOnce(new Error('connect ECONNREFUSED'));
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('CONNECTION_REFUSED');
}
});
it('should return DNS_ERROR for ENOTFOUND', async () => {
mockFetch.mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND'));
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('DNS_ERROR');
}
});
it('should return NETWORK_TIMEOUT for ETIMEDOUT', async () => {
mockFetch.mockRejectedValueOnce(new Error('connect ETIMEDOUT'));
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('NETWORK_TIMEOUT');
}
});
it('should return REQUEST_FAILED for unknown errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Unknown error'));
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('REQUEST_FAILED');
}
});
it('should include original error as cause', async () => {
const originalError = new Error('Original error message');
mockFetch.mockRejectedValueOnce(originalError);
const result = await client.get('https://api.example.com/data', {
retries: 0,
circuitBreaker: false,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.cause).toBe(originalError);
expect(result.error.message).toBe('Original error message');
}
});
});
// ============================================================================
// Singleton Tests
// ============================================================================
describe('singleton', () => {
it('should return the same instance from getHttpClient', () => {
const client1 = getHttpClient();
const client2 = getHttpClient();
expect(client1).toBe(client2);
});
it('should return new instance from createHttpClient', () => {
const client1 = createHttpClient();
const client2 = createHttpClient();
expect(client1).not.toBe(client2);
});
});
});