257 lines
8.7 KiB
TypeScript
257 lines
8.7 KiB
TypeScript
/**
|
||
* React 集成 hooks 测试(v0.2.6 补强)
|
||
* @module tests/integrations/react
|
||
*
|
||
* 项目零运行时依赖(react 为 peer dependency),
|
||
* 使用最小 React mock 验证 hooks 的真实逻辑:
|
||
* - mount 时执行查询并更新状态
|
||
* - 错误路径
|
||
* - refresh 重新执行
|
||
* - 表名校验(SQL 注入防护)
|
||
*/
|
||
import { MetonaSqlark } from '../../src/core';
|
||
|
||
// ---- 最小 React mock(自包含:jest.mock 工厂不能引用外部变量) ----
|
||
jest.mock('react', () => {
|
||
const stateStore: any[] = [];
|
||
const registeredEffects = new Set<number>();
|
||
const effectQueue: Array<() => void | Promise<void>> = [];
|
||
// v0.7.3: 依赖数组指纹 + cleanup 支持(useDatabase config 变更重建测试用)
|
||
const effectDeps = new Map<number, string>();
|
||
const effectCleanups = new Map<number, () => void>();
|
||
let cursor = 0;
|
||
return {
|
||
useState: (init: any) => {
|
||
const idx = cursor++;
|
||
if (!(idx in stateStore)) stateStore[idx] = init;
|
||
return [
|
||
stateStore[idx],
|
||
(v: any) => {
|
||
stateStore[idx] = typeof v === 'function' ? v(stateStore[idx]) : v;
|
||
},
|
||
];
|
||
},
|
||
// 按 hook 调用位置注册:同一位置首次注册入队;deps 指纹变化时
|
||
// 先执行旧 cleanup 再重跑 effect(模拟 React 依赖更新语义)
|
||
useEffect: (fn: any, _deps: any[] = []) => {
|
||
const idx = cursor;
|
||
const key = JSON.stringify(_deps ?? []);
|
||
if (!registeredEffects.has(idx)) {
|
||
registeredEffects.add(idx);
|
||
effectDeps.set(idx, key);
|
||
effectQueue.push(() => {
|
||
const cleanup = fn();
|
||
if (typeof cleanup === 'function') effectCleanups.set(idx, cleanup);
|
||
});
|
||
} else if (effectDeps.get(idx) !== key) {
|
||
effectDeps.set(idx, key);
|
||
effectQueue.push(() => {
|
||
const c = effectCleanups.get(idx);
|
||
if (c) { c(); effectCleanups.delete(idx); }
|
||
const cleanup = fn();
|
||
if (typeof cleanup === 'function') effectCleanups.set(idx, cleanup);
|
||
});
|
||
}
|
||
},
|
||
useCallback: (fn: any) => fn,
|
||
useRef: (init: any) => ({ current: init }),
|
||
/** 模拟一次组件渲染:cursor 归零后执行 hook 函数 */
|
||
__mockRender: (fn: () => any): any => {
|
||
cursor = 0;
|
||
return fn();
|
||
},
|
||
__mockEffects: effectQueue,
|
||
__mockReset: () => {
|
||
stateStore.length = 0;
|
||
cursor = 0;
|
||
effectQueue.length = 0;
|
||
registeredEffects.clear();
|
||
effectDeps.clear();
|
||
effectCleanups.clear();
|
||
},
|
||
};
|
||
}, { virtual: true });
|
||
|
||
import { useQuery, useTable, useDatabase } from '../../src/integrations/react';
|
||
|
||
/** mock 模块内的 effect 队列与渲染控制(自包含作用域) */
|
||
const reactMock = jest.requireMock('react') as {
|
||
__mockEffects: Array<() => void | Promise<void>>;
|
||
__mockRender: (fn: () => any) => any;
|
||
__mockReset: () => void;
|
||
};
|
||
|
||
beforeEach(() => {
|
||
reactMock.__mockReset();
|
||
});
|
||
|
||
/** 模拟组件挂载:执行 useEffect 中注册的回调 */
|
||
async function flushEffects(): Promise<void> {
|
||
const fns = reactMock.__mockEffects.splice(0);
|
||
for (const fn of fns) {
|
||
await fn();
|
||
}
|
||
}
|
||
|
||
/** 模拟组件渲染(cursor 归零,读取最新状态) */
|
||
function render<T>(fn: () => T): T {
|
||
return reactMock.__mockRender(fn);
|
||
}
|
||
|
||
describe('useQuery', () => {
|
||
test('mount 时执行 SQL 查询并更新 data/loading', async () => {
|
||
const db = { query: jest.fn().mockResolvedValue([{ id: '1', name: 'Alice' }]) } as any;
|
||
const mount = () => useQuery(db, 'SELECT * FROM users');
|
||
const hook = render(mount);
|
||
|
||
expect(hook.loading).toBe(true);
|
||
expect(db.query).not.toHaveBeenCalled();
|
||
|
||
await flushEffects();
|
||
const after = render(mount); // 模拟重渲染读取最新状态
|
||
|
||
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
|
||
expect(after.loading).toBe(false);
|
||
expect(after.data).toEqual([{ id: '1', name: 'Alice' }]);
|
||
expect(after.error).toBeNull();
|
||
});
|
||
|
||
test('查询失败时设置 error 且 data 保持空', async () => {
|
||
const db = { query: jest.fn().mockRejectedValue(new Error('query boom')) } as any;
|
||
const mount = () => useQuery(db, 'SELECT * FROM users');
|
||
render(mount);
|
||
|
||
await flushEffects();
|
||
const after = render(mount);
|
||
|
||
expect(after.error).toBeInstanceOf(Error);
|
||
expect((after.error as Error).message).toBe('query boom');
|
||
expect(after.data).toEqual([]);
|
||
expect(after.loading).toBe(false);
|
||
});
|
||
|
||
test('refresh 可重新执行查询', async () => {
|
||
let call = 0;
|
||
const db = { query: jest.fn().mockImplementation(async () => [{ n: ++call }]) } as any;
|
||
const mount = () => useQuery(db, 'SELECT * FROM users');
|
||
const hook = render(mount);
|
||
|
||
await flushEffects();
|
||
expect(render(mount).data).toEqual([{ n: 1 }]);
|
||
|
||
await hook.refresh();
|
||
await flushEffects();
|
||
expect(db.query).toHaveBeenCalledTimes(2);
|
||
expect(render(mount).data).toEqual([{ n: 2 }]);
|
||
});
|
||
|
||
test('不同 SQL 使用各自独立的 hook 状态', async () => {
|
||
const db = { query: jest.fn().mockResolvedValue([]) } as any;
|
||
const hook1 = useQuery(db, 'SELECT * FROM a');
|
||
const hook2 = useQuery(db, 'SELECT * FROM b');
|
||
await flushEffects();
|
||
|
||
expect(hook1).not.toBe(hook2);
|
||
expect(db.query).toHaveBeenCalledWith('SELECT * FROM a');
|
||
expect(db.query).toHaveBeenCalledWith('SELECT * FROM b');
|
||
});
|
||
});
|
||
|
||
describe('useTable', () => {
|
||
test('合法表名执行全表查询', async () => {
|
||
const db = { query: jest.fn().mockResolvedValue([{ id: 1 }]) } as any;
|
||
const mount = () => useTable(db, 'users');
|
||
render(mount);
|
||
|
||
await flushEffects();
|
||
const after = render(mount);
|
||
|
||
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
|
||
expect(after.data).toEqual([{ id: 1 }]);
|
||
expect(after.loading).toBe(false);
|
||
});
|
||
|
||
test('非法表名抛出校验错误(SQL 注入防护)', () => {
|
||
const db = { query: jest.fn() } as any;
|
||
expect(() => useTable(db, 'users; DROP TABLE orders')).toThrow(/Invalid table name/);
|
||
expect(() => useTable(db, "users' OR '1'='1")).toThrow(/Invalid table name/);
|
||
expect(() => useTable(db, '1users')).toThrow(/Invalid table name/);
|
||
expect(db.query).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('useDatabase', () => {
|
||
test('创建数据库实例并初始化', async () => {
|
||
const initSpy = jest.spyOn(MetonaSqlark.prototype, 'init').mockResolvedValue();
|
||
const closeSpy = jest.spyOn(MetonaSqlark.prototype, 'close').mockResolvedValue();
|
||
|
||
const mount = () => useDatabase({ name: 'hook-test' });
|
||
const hook = render(mount);
|
||
expect(hook.ready).toBe(false);
|
||
|
||
await flushEffects();
|
||
const after = render(mount);
|
||
|
||
expect(initSpy).toHaveBeenCalled();
|
||
expect(after.db).toBeInstanceOf(MetonaSqlark);
|
||
expect(after.ready).toBe(true);
|
||
expect(after.error).toBeNull();
|
||
|
||
initSpy.mockRestore();
|
||
closeSpy.mockRestore();
|
||
});
|
||
|
||
test('初始化失败时设置 error', async () => {
|
||
const initSpy = jest.spyOn(MetonaSqlark.prototype, 'init').mockRejectedValue(new Error('init fail'));
|
||
|
||
const mount = () => useDatabase({ name: 'hook-fail' });
|
||
render(mount);
|
||
await flushEffects();
|
||
const after = render(mount);
|
||
|
||
expect(after.db).toBeNull();
|
||
expect(after.ready).toBe(false);
|
||
expect(after.error).toBeInstanceOf(Error);
|
||
expect((after.error as Error).message).toBe('init fail');
|
||
|
||
initSpy.mockRestore();
|
||
});
|
||
|
||
test('config 变更时关闭旧实例并重建(v0.7.3)', async () => {
|
||
const closeSpy = jest.spyOn(MetonaSqlark.prototype, 'close').mockResolvedValue();
|
||
|
||
// 首次挂载(真实异步 init)
|
||
const mount = (config: { name: string }) => useDatabase(config);
|
||
render(() => mount({ name: 'hook-a' }));
|
||
await flushEffects();
|
||
// 轮询等待真实 init 完成(stateStore 经 re-render 读取最新值)
|
||
for (let i = 0; i < 100 && render(() => mount({ name: 'hook-a' })).db === null; i++) {
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
}
|
||
const first = render(() => mount({ name: 'hook-a' }));
|
||
expect(first.db).not.toBeNull();
|
||
const firstDb = first.db;
|
||
|
||
// 同名 config 再渲染 → 不重建(相同实例)
|
||
const same = render(() => mount({ name: 'hook-a' }));
|
||
expect(same.db).toBe(firstDb);
|
||
|
||
// config 变更渲染 → cleanup 关闭旧实例 + 重建新实例
|
||
render(() => mount({ name: 'hook-b' }));
|
||
await flushEffects();
|
||
for (let i = 0; i < 100; i++) {
|
||
const cur = render(() => mount({ name: 'hook-b' }));
|
||
if (cur.db !== null && cur.db !== firstDb && cur.ready) break;
|
||
await new Promise((r) => setTimeout(r, 5));
|
||
}
|
||
const afterChange = render(() => mount({ name: 'hook-b' }));
|
||
|
||
expect(closeSpy).toHaveBeenCalled();
|
||
expect(afterChange.db).not.toBeNull();
|
||
expect(afterChange.db).not.toBe(firstDb);
|
||
expect(afterChange.ready).toBe(true);
|
||
|
||
closeSpy.mockRestore();
|
||
});
|
||
});
|