/** * 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(); const effectQueue: Array<() => void | Promise> = []; // v0.7.3: 依赖数组指纹 + cleanup 支持(useDatabase config 变更重建测试用) const effectDeps = new Map(); const effectCleanups = new Map 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>; __mockRender: (fn: () => any) => any; __mockReset: () => void; }; beforeEach(() => { reactMock.__mockReset(); }); /** 模拟组件挂载:执行 useEffect 中注册的回调 */ async function flushEffects(): Promise { const fns = reactMock.__mockEffects.splice(0); for (const fn of fns) { await fn(); } } /** 模拟组件渲染(cursor 归零,读取最新状态) */ function render(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(); }); });