/** * 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> = []; 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 调用位置去重:同一位置的 effect 只在首次 render 注册 useEffect: (fn: any, _deps: any[]) => { const idx = cursor; if (!registeredEffects.has(idx)) { registeredEffects.add(idx); effectQueue.push(fn); } }, 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(); }, }; }, { 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(); }); });