Files
2026-07-13 13:20:22 +08:00

540 lines
17 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for common/adapter/httpBridge.ts (T3 in N3 test checklist).
* Tests HTTP/WS bridge factories, error handling, and port resolution.
*
* @vitest-environment node
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
getBaseUrl,
httpGet,
httpPost,
httpPut,
httpPatch,
httpDelete,
stubProvider,
withResponseMap,
BackendHttpError,
isBackendHttpError,
wsEmitter,
wsMappedEmitter,
stubEmitter,
httpRequest,
} from '@/common/adapter/httpBridge';
type FakeSocketEventMap = {
open: () => void;
message: (event: MessageEvent<string>) => void;
close: (event: CloseEvent) => void;
error: () => void;
};
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
private readonly listeners: { [K in keyof FakeSocketEventMap]: FakeSocketEventMap[K][] } = {
open: [],
message: [],
close: [],
error: [],
};
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
addEventListener<K extends keyof FakeSocketEventMap>(type: K, listener: FakeSocketEventMap[K]) {
this.listeners[type].push(listener);
}
close() {
this.readyState = FakeWebSocket.CLOSED;
}
dispatchOpen() {
this.readyState = FakeWebSocket.OPEN;
for (const listener of this.listeners.open) listener();
}
dispatchClose() {
this.readyState = FakeWebSocket.CLOSED;
for (const listener of this.listeners.close) listener({ code: 1006, reason: '' } as CloseEvent);
}
}
describe('httpBridge', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('getBaseUrl', () => {
it('returns fallback URL in node environment with no globalThis.__backendPort', () => {
const result = getBaseUrl();
expect(result).toBe('http://127.0.0.1:13400');
});
it('reads port from globalThis.__backendPort when set', () => {
(globalThis as { __backendPort?: number }).__backendPort = 23456;
const result = getBaseUrl();
expect(result).toBe('http://127.0.0.1:23456');
delete (globalThis as { __backendPort?: number }).__backendPort;
});
it('reads port from window.__backendPort with priority', () => {
(globalThis as { __backendPort?: number }).__backendPort = 11111;
vi.stubGlobal('window', { __backendPort: 34567 });
const result = getBaseUrl();
expect(result).toBe('http://127.0.0.1:34567');
delete (globalThis as { __backendPort?: number }).__backendPort;
});
it('returns empty string in WebUI mode (window + document, no __backendPort)', () => {
vi.stubGlobal('window', {});
vi.stubGlobal('document', {});
const result = getBaseUrl();
expect(result).toBe('');
});
});
describe('httpGet', () => {
it('constructs provider and invoke, provider is no-op', () => {
const h = httpGet('/api/x');
expect(h.provider).toBeTypeOf('function');
expect(h.invoke).toBeTypeOf('function');
// Provider should be no-op (no error)
h.provider(() => Promise.resolve({ ok: true }));
});
it('invoke triggers fetch with GET, no body, unwraps data envelope', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { x: 1 } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
const result = await httpGet<{ x: number }>('/api/foo').invoke();
expect(result).toEqual({ x: 1 });
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toContain('/api/foo');
expect(fetchSpy.mock.calls[0][1]?.method).toBe('GET');
expect(fetchSpy.mock.calls[0][1]?.body).toBeUndefined();
});
});
describe('httpPost', () => {
it('invoke serializes body and sends content-type header', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { created: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
const result = await httpPost<{ created: boolean }, { k: string }>('/api/x').invoke({ k: 'v' });
expect(result).toEqual({ created: true });
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][1]?.method).toBe('POST');
expect(fetchSpy.mock.calls[0][1]?.body).toBe('{"k":"v"}');
expect(fetchSpy.mock.calls[0][1]?.headers).toEqual({ 'Content-Type': 'application/json' });
});
it('applies mapBody custom mapper', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { ok: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
await httpPost('/api/x', (p: string) => ({ wrapped: p })).invoke('raw');
expect(fetchSpy.mock.calls[0][1]?.body).toBe('{"wrapped":"raw"}');
});
});
describe('path as function', () => {
it('resolves path with params', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { ok: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
await httpGet<{ ok: boolean }, { id: string }>((p) => `/api/${p.id}`).invoke({ id: 'abc' });
expect(fetchSpy.mock.calls[0][0]).toContain('/api/abc');
});
});
describe('error handling', () => {
it('non-2xx response throws BackendHttpError with code/status/backendMessage', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
success: false,
error: 'bad',
code: 'X_BAD',
details: { workspace_path: '/tmp/Archive ' },
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
)
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await httpGet('/api/x').invoke();
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(BackendHttpError);
const err = e as BackendHttpError;
expect(err.status).toBe(400);
expect(err.code).toBe('X_BAD');
expect(err.backendMessage).toBe('bad');
expect(err.details).toEqual({ workspace_path: '/tmp/Archive ' });
expect(err.body).toEqual({
success: false,
error: 'bad',
code: 'X_BAD',
details: { workspace_path: '/tmp/Archive ' },
});
}
});
it('non-JSON error response captures raw text without double body consumption (#3249)', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response('Unauthorized', {
status: 401,
statusText: 'Unauthorized',
headers: { 'Content-Type': 'text/plain' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await httpGet('/api/x').invoke();
expect.fail('Should have thrown');
} catch (e) {
// Before the fix this threw TypeError "body stream already read" instead
expect(e).toBeInstanceOf(BackendHttpError);
const err = e as BackendHttpError;
expect(err.status).toBe(401);
expect(err.body).toBe('Unauthorized');
}
});
it('empty error body falls back to empty string', async () => {
const fetchSpy = vi.fn().mockResolvedValue(new Response('', { status: 502 }));
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await httpGet('/api/x').invoke();
expect.fail('Should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(BackendHttpError);
const err = e as BackendHttpError;
expect(err.status).toBe(502);
expect(err.body).toBe('');
}
});
});
describe('non-JSON response', () => {
it('returns undefined when content-type is not JSON', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response('', {
status: 200,
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
const result = await httpGet('/api/x').invoke();
expect(result).toBeUndefined();
});
});
describe('stubProvider', () => {
it('returns default value and logs warning', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const provider = stubProvider('test', 42);
const result = await provider.invoke();
expect(result).toBe(42);
expect(warnSpy).toHaveBeenCalledWith('[httpBridge] stub: test not yet implemented in backend');
});
});
describe('withResponseMap', () => {
it('wraps invoke and applies mapper', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { raw: 'abc' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
const inner = httpGet<{ raw: string }>('/api/data');
const mapped = withResponseMap(inner, (data) => data.raw.toUpperCase());
const result = await mapped.invoke();
expect(result).toBe('ABC');
});
});
describe('BackendHttpError', () => {
it('instanceof check works', () => {
const err = new BackendHttpError({ method: 'GET', path: '/api/x', status: 500, body: {} });
expect(err).toBeInstanceOf(BackendHttpError);
expect(isBackendHttpError(err)).toBe(true);
});
it('duck-typing check works for compatible object', () => {
const obj = {
name: 'BackendHttpError',
status: 500,
code: 'X',
backendMessage: 'msg',
};
expect(isBackendHttpError(obj)).toBe(true);
});
it('duck-typing returns false when status is missing', () => {
const obj = {
name: 'BackendHttpError',
code: 'X',
};
expect(isBackendHttpError(obj)).toBe(false);
});
it('returns false for non-BackendHttpError', () => {
expect(isBackendHttpError(new Error('other'))).toBe(false);
expect(isBackendHttpError(null)).toBe(false);
expect(isBackendHttpError('string')).toBe(false);
});
});
describe('wsEmitter', () => {
it('emits realtime.reconnected only after a prior websocket open', () => {
vi.useFakeTimers();
vi.stubGlobal('window', { __backendPort: 13400 });
vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket);
vi.spyOn(console, 'debug').mockImplementation(() => {});
FakeWebSocket.instances = [];
const events: unknown[] = [];
const unsubscribe = wsEmitter('realtime.reconnected').on((payload: unknown) => events.push(payload));
FakeWebSocket.instances[0].dispatchOpen();
expect(events).toEqual([]);
FakeWebSocket.instances[0].dispatchClose();
vi.advanceTimersByTime(1000);
FakeWebSocket.instances[1].dispatchOpen();
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ timestamp: expect.any(Number) });
FakeWebSocket.instances[1].dispatchClose();
vi.clearAllTimers();
unsubscribe();
vi.useRealTimers();
});
it('on returns unsubscribe function that removes listener', () => {
const events: unknown[] = [];
const emitter = wsEmitter('test-event');
const unsub = emitter.on((payload: unknown) => {
events.push(payload);
});
// Trigger event manually via mock (since emit is no-op in source)
// In real usage, WS message handler dispatches to listeners
// For testing, we verify the subscription registry behavior
// Verify unsub removes the listener
expect(typeof unsub).toBe('function');
unsub();
// After unsub, listener should not be called
// (We can't directly test emit behavior without mocking WebSocket,
// but we verify the on/off mechanics work)
});
it('emit is a no-op function', () => {
const emitter = wsEmitter('test');
// Should not throw
emitter.emit();
});
});
describe('wsMappedEmitter', () => {
it('applies transform function to subscribed callback', () => {
const events: number[] = [];
const emitter = wsMappedEmitter<number>('raw-event', (raw: unknown) => (raw as { v: number }).v * 2);
emitter.on((value: number) => {
events.push(value);
});
// In real usage, WS message with { v: 3 } would trigger the transform
// Here we verify the subscription and transform logic is wired
});
});
describe('stubEmitter', () => {
it('on returns harmless unsubscribe', () => {
const e = stubEmitter('x');
const off = e.on(() => {});
expect(typeof off).toBe('function');
off(); // Should not throw
});
it('emit is a no-op', () => {
const e = stubEmitter('x');
// Should not throw
e.emit();
});
});
describe('httpRequest', () => {
it('performs fetch and unwraps data envelope', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { result: 'ok' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
const result = await httpRequest<{ result: string }>('GET', '/api/test');
expect(result).toEqual({ result: 'ok' });
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining('/api/test'), {
method: 'GET',
headers: {},
body: undefined,
});
});
it('sends JSON body for POST with content-type', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
await httpRequest('POST', '/api/create', { key: 'value' });
expect(fetchSpy.mock.calls[0][1]?.body).toBe('{"key":"value"}');
expect(fetchSpy.mock.calls[0][1]?.headers).toEqual({ 'Content-Type': 'application/json' });
});
});
describe('httpPut', () => {
it('sends PUT request with body', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { updated: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
await httpPut<{ updated: boolean }, { id: string }>('/api/items/:id').invoke({ id: '123' });
expect(fetchSpy.mock.calls[0][1]?.method).toBe('PUT');
});
});
describe('httpPatch', () => {
it('sends PATCH request with body', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { patched: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
await httpPatch('/api/items').invoke({ id: '123' });
expect(fetchSpy.mock.calls[0][1]?.method).toBe('PATCH');
});
});
describe('httpDelete', () => {
it('sends DELETE request with no body', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { deleted: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
vi.stubGlobal('fetch', fetchSpy);
vi.spyOn(console, 'debug').mockImplementation(() => {});
await httpDelete('/api/items/123').invoke();
expect(fetchSpy.mock.calls[0][1]?.method).toBe('DELETE');
expect(fetchSpy.mock.calls[0][1]?.body).toBeUndefined();
});
});
});