Files
MetonaSqlark/tests/integrations/react.test.ts
T

257 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});