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

233 lines
7.6 KiB
TypeScript

/**
* @license
* Copyright 2025 AionUi (aionui.com)
* SPDX-License-Identifier: Apache-2.0
*
* Unit tests for mockHttpBridge helper (T6 in N3 test checklist).
* This file verifies the mock helper itself — domain tests (T1-T5) use the helper.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockHttpBridge, resetMockHttpBridge, type MockHttpBridge } from './mockHttpBridge';
describe('mockHttpBridge helper', () => {
let mock: MockHttpBridge;
beforeEach(() => {
mock = createMockHttpBridge();
});
it('createMockHttpBridge() returns object with frozen public API', () => {
expect(mock).toBeDefined();
expect(typeof mock.onGet).toBe('function');
expect(typeof mock.onPost).toBe('function');
expect(typeof mock.onPut).toBe('function');
expect(typeof mock.onPatch).toBe('function');
expect(typeof mock.onDelete).toBe('function');
expect(typeof mock.emit).toBe('function');
expect(Array.isArray(mock.calls)).toBe(true);
expect(typeof mock.routeCount).toBe('number');
expect(typeof mock.wsListenerCount).toBe('number');
expect(typeof mock.reset).toBe('function');
expect(typeof mock.asModule).toBe('function');
});
it('onGet registers a handler; asModule().httpGet(path).invoke() returns handler result', async () => {
mock.onGet('/api/foo', () => ({ ok: true }));
const module = mock.asModule();
const result = await module.httpGet('/api/foo').invoke();
expect(result).toEqual({ ok: true });
expect(mock.calls).toHaveLength(1);
expect(mock.calls[0]).toMatchObject({
method: 'GET',
path: '/api/foo',
pathPattern: '/api/foo',
params: {},
query: {},
body: undefined,
});
});
it('onPost forwards body and returns handler result', async () => {
mock.onPost('/api/items', (ctx) => {
expect(ctx.body).toEqual({ id: 'a' });
return { created: ctx.body };
});
const module = mock.asModule();
const result = await module.httpPost('/api/items').invoke({ id: 'a' });
expect(result).toEqual({ created: { id: 'a' } });
expect(mock.calls).toHaveLength(1);
expect(mock.calls[0].body).toEqual({ id: 'a' });
});
it(':param placeholder populates params map', async () => {
mock.onGet('/api/providers/:id', (ctx) => {
expect(ctx.params.id).toBe('p1');
return { provider: ctx.params.id };
});
const module = mock.asModule();
const result = await module.httpGet('/api/providers/p1').invoke();
expect(result).toEqual({ provider: 'p1' });
expect(mock.calls[0].params).toEqual({ id: 'p1' });
});
it('unmatched route throws "unexpected call" by default', async () => {
mock.onGet('/api/foo', () => ({ ok: true }));
const module = mock.asModule();
await expect(module.httpGet('/api/bar').invoke()).rejects.toThrow(/unexpected call/);
});
it('unmatched option "warn" returns undefined and logs console.warn', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mock = createMockHttpBridge({ unmatched: 'warn' });
mock.onGet('/api/foo', () => ({ ok: true }));
const module = mock.asModule();
const result = await module.httpGet('/api/bar').invoke();
expect(result).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('unmatched route GET /api/bar'));
warnSpy.mockRestore();
});
it('emit() dispatches to all wsEmitter listeners synchronously', () => {
const module = mock.asModule();
const events: unknown[] = [];
const unsubscribe = module.wsEmitter('test-event').on((payload: unknown) => {
events.push(payload);
});
mock.emit('test-event', { data: 'hello' });
expect(events).toEqual([{ data: 'hello' }]);
mock.emit('test-event', { data: 'world' });
expect(events).toEqual([{ data: 'hello' }, { data: 'world' }]);
expect(mock.wsListenerCount).toBe(1);
unsubscribe();
mock.emit('test-event', { data: 'ignored' });
expect(events).toHaveLength(2); // No new event
expect(mock.wsListenerCount).toBe(0);
});
it('reset() clears routes, listeners, and calls', async () => {
mock.onGet('/api/test', () => ({ ok: true }));
const module = mock.asModule();
module.wsEmitter('event').on(() => {});
await module.httpGet('/api/test').invoke();
expect(mock.routeCount).toBe(1);
expect(mock.wsListenerCount).toBe(1);
expect(mock.calls).toHaveLength(1);
mock.reset();
expect(mock.routeCount).toBe(0);
expect(mock.wsListenerCount).toBe(0);
expect(mock.calls).toHaveLength(0);
});
it('resetMockHttpBridge() convenience function calls mock.reset()', () => {
mock.onGet('/api/test', () => ({ ok: true }));
expect(mock.routeCount).toBe(1);
resetMockHttpBridge(mock);
expect(mock.routeCount).toBe(0);
});
it('query string is parsed and stripped from path', async () => {
mock.onGet('/api/search', (ctx) => {
expect(ctx.query).toEqual({ q: 'test', page: '2' });
return { results: [] };
});
const module = mock.asModule();
await module.httpGet('/api/search?q=test&page=2').invoke();
expect(mock.calls[0].query).toEqual({ q: 'test', page: '2' });
expect(mock.calls[0].path).toBe('/api/search?q=test&page=2');
});
it('multiple :params are extracted correctly', async () => {
mock.onGet('/api/users/:userId/posts/:postId', (ctx) => {
expect(ctx.params.userId).toBe('u123');
expect(ctx.params.postId).toBe('p456');
return { user: ctx.params.userId, post: ctx.params.postId };
});
const module = mock.asModule();
const result = await module.httpGet('/api/users/u123/posts/p456').invoke();
expect(result).toEqual({ user: 'u123', post: 'p456' });
});
it('stubProvider returns default value and logs warning', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const module = mock.asModule();
const provider = module.stubProvider('test-stub', 42);
const result = await provider.invoke();
expect(result).toBe(42);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("stubProvider('test-stub')"), 42);
warnSpy.mockRestore();
});
it('withResponseMap wraps invoke and applies mapper', async () => {
mock.onGet('/api/data', () => ({ raw: 'abc' }));
const module = mock.asModule();
const inner = module.httpGet<{ raw: string }>('/api/data');
const mapped = module.withResponseMap(inner, (data) => data.raw.toUpperCase());
const result = await mapped.invoke();
expect(result).toBe('ABC');
});
it('wsMappedEmitter applies transform to payload', () => {
const module = mock.asModule();
const events: number[] = [];
const emitter = module.wsMappedEmitter<number>('raw-event', (raw: unknown) => (raw as { v: number }).v * 2);
emitter.on((value: number) => {
events.push(value);
});
mock.emit('raw-event', { v: 3 });
expect(events).toEqual([6]);
mock.emit('raw-event', { v: 5 });
expect(events).toEqual([6, 10]);
});
it('path as function is resolved with params', async () => {
mock.onGet('/api/items/xyz', () => ({ item: 'xyz' }));
const module = mock.asModule();
const result = await module
.httpGet<{ item: string }, { id: string }>((params) => `/api/items/${params.id}`)
.invoke({
id: 'xyz',
});
expect(result).toEqual({ item: 'xyz' });
expect(mock.calls[0].path).toBe('/api/items/xyz');
});
it('httpRequest direct call throws error', () => {
const module = mock.asModule();
expect(() => module.httpRequest('GET', '/api/test')).toThrow(/direct httpRequest calls not allowed/);
});
});