From a854d3a185ce20c44103c4dc80f6b2ea51e56867 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:36:32 +0000 Subject: [PATCH] Add component tree renderer, hooks, and tests Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com> --- src/hooks/index.ts | 7 + src/hooks/useApiCall.ts | 62 ++++ src/hooks/useColumnManagement.ts | 104 +++++++ src/hooks/useTableData.ts | 38 +++ src/hooks/useTables.ts | 110 +++++++ src/utils/componentTreeRenderer.tsx | 339 ++++++++++++++++++++++ tests/e2e/playbooks.spec.ts | 169 +++++++++++ tests/unit/componentTreeRenderer.test.tsx | 195 +++++++++++++ tests/unit/useApiCall.test.ts | 141 +++++++++ 9 files changed, 1165 insertions(+) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useApiCall.ts create mode 100644 src/hooks/useColumnManagement.ts create mode 100644 src/hooks/useTableData.ts create mode 100644 src/hooks/useTables.ts create mode 100644 src/utils/componentTreeRenderer.tsx create mode 100644 tests/e2e/playbooks.spec.ts create mode 100644 tests/unit/componentTreeRenderer.test.tsx create mode 100644 tests/unit/useApiCall.test.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..c07f0df --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * Hooks exports + */ +export { useApiCall } from './useApiCall'; +export { useTableData } from './useTableData'; +export { useTables } from './useTables'; +export { useColumnManagement } from './useColumnManagement'; diff --git a/src/hooks/useApiCall.ts b/src/hooks/useApiCall.ts new file mode 100644 index 0000000..b007223 --- /dev/null +++ b/src/hooks/useApiCall.ts @@ -0,0 +1,62 @@ +/** + * Generic hook for API calls with loading and error states + */ +import { useState, useCallback } from 'react'; + +type ApiCallState = { + data: T | null; + loading: boolean; + error: string | null; +}; + +type ApiCallOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + headers?: HeadersInit; + body?: any; +}; + +export function useApiCall() { + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }); + + const execute = useCallback(async (url: string, options: ApiCallOptions = {}) => { + setState({ data: null, loading: true, error: null }); + + try { + const response = await fetch(url, { + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || `Request failed with status ${response.status}`); + } + + setState({ data, loading: false, error: null }); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred'; + setState({ data: null, loading: false, error: errorMessage }); + throw err; + } + }, []); + + const reset = useCallback(() => { + setState({ data: null, loading: false, error: null }); + }, []); + + return { + ...state, + execute, + reset, + }; +} diff --git a/src/hooks/useColumnManagement.ts b/src/hooks/useColumnManagement.ts new file mode 100644 index 0000000..1f6320d --- /dev/null +++ b/src/hooks/useColumnManagement.ts @@ -0,0 +1,104 @@ +/** + * Hook for column management operations + */ +import { useState, useCallback } from 'react'; + +export function useColumnManagement() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const addColumn = useCallback(async (tableName: string, columnData: any) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/admin/column-manage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName, ...columnData }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to add column'); + } + + return result; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to add column'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, []); + + const modifyColumn = useCallback(async (tableName: string, columnData: any) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/admin/column-manage', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName, ...columnData }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to modify column'); + } + + return result; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to modify column'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, []); + + const dropColumn = useCallback(async (tableName: string, columnData: any) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/admin/column-manage', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName, ...columnData }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to drop column'); + } + + return result; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to drop column'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, []); + + return { + loading, + error, + addColumn, + modifyColumn, + dropColumn, + }; +} diff --git a/src/hooks/useTableData.ts b/src/hooks/useTableData.ts new file mode 100644 index 0000000..b49035d --- /dev/null +++ b/src/hooks/useTableData.ts @@ -0,0 +1,38 @@ +/** + * Hook for fetching and managing table data + */ +import { useState, useCallback, useEffect } from 'react'; +import { useApiCall } from './useApiCall'; + +export function useTableData(tableName?: string) { + const { data, loading, error, execute } = useApiCall(); + const [queryResult, setQueryResult] = useState(null); + + const fetchTableData = useCallback(async (table: string) => { + try { + const result = await execute('/api/admin/table-data', { + method: 'POST', + body: { tableName: table }, + }); + setQueryResult(result); + return result; + } catch (err) { + console.error('Failed to fetch table data:', err); + throw err; + } + }, [execute]); + + useEffect(() => { + if (tableName) { + fetchTableData(tableName); + } + }, [tableName, fetchTableData]); + + return { + data: queryResult, + loading, + error, + refresh: () => tableName && fetchTableData(tableName), + fetchTableData, + }; +} diff --git a/src/hooks/useTables.ts b/src/hooks/useTables.ts new file mode 100644 index 0000000..9712eed --- /dev/null +++ b/src/hooks/useTables.ts @@ -0,0 +1,110 @@ +/** + * Hook for managing database tables + */ +import { useState, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +export function useTables() { + const router = useRouter(); + const [tables, setTables] = useState>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchTables = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/admin/tables'); + + if (!response.ok) { + if (response.status === 401) { + router.push('/admin/login'); + return; + } + throw new Error('Failed to fetch tables'); + } + + const data = await response.json(); + setTables(data.tables); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch tables'; + setError(errorMessage); + } finally { + setLoading(false); + } + }, [router]); + + const createTable = useCallback(async (tableName: string, columns: any[]) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/admin/table-manage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName, columns }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create table'); + } + + await fetchTables(); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create table'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, [fetchTables]); + + const dropTable = useCallback(async (tableName: string) => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/admin/table-manage', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to drop table'); + } + + await fetchTables(); + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to drop table'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, [fetchTables]); + + useEffect(() => { + fetchTables(); + }, [fetchTables]); + + return { + tables, + loading, + error, + fetchTables, + createTable, + dropTable, + }; +} diff --git a/src/utils/componentTreeRenderer.tsx b/src/utils/componentTreeRenderer.tsx new file mode 100644 index 0000000..2a8478b --- /dev/null +++ b/src/utils/componentTreeRenderer.tsx @@ -0,0 +1,339 @@ +/** + * Component Tree Renderer + * Dynamically renders React component trees from JSON configuration + */ + +import React from 'react'; +import type { ComponentNode } from './featureConfig'; + +// Import all atomic components +import { + Alert, + AppBar, + Box, + Button, + Card, + CardContent, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + FormControl, + Grid, + IconButton, + InputLabel, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + MenuItem, + Paper, + Select, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + TextField, + Toolbar, + Typography, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Pagination, +} from '@mui/material'; + +// Import Material Icons +import * as Icons from '@mui/icons-material'; + +// Component registry - maps component names to actual components +const componentRegistry: Record> = { + Alert, + AppBar, + Box, + Button, + Card, + CardContent, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + FormControl, + Grid, + IconButton, + InputLabel, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + MenuItem, + Paper, + Select, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + TextField, + Toolbar, + Typography, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Pagination, +}; + +type RenderContext = { + data?: Record; + actions?: Record any>; + state?: Record; +}; + +/** + * Interpolate template strings like {{variable}} with actual values + */ +function interpolateValue(value: any, context: RenderContext): any { + if (typeof value !== 'string') { + return value; + } + + // Check if it's a template string + const templateMatch = value.match(/^{{(.+)}}$/); + if (templateMatch) { + const path = templateMatch[1].trim(); + return getNestedValue(context, path); + } + + // Replace inline templates + return value.replace(/{{(.+?)}}/g, (_, path) => { + const val = getNestedValue(context, path.trim()); + return val !== undefined ? String(val) : ''; + }); +} + +/** + * Get nested value from object using dot notation + */ +function getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((current, key) => { + // Handle array access like array[0] + const arrayMatch = key.match(/(.+)\[(\d+)\]/); + if (arrayMatch) { + const [, arrayKey, index] = arrayMatch; + return current?.[arrayKey]?.[Number.parseInt(index, 10)]; + } + return current?.[key]; + }, obj); +} + +/** + * Evaluate condition expressions + */ +function evaluateCondition(condition: string, context: RenderContext): boolean { + try { + // Simple condition evaluation - can be extended + const value = getNestedValue(context, condition); + + // Handle boolean checks + if (typeof value === 'boolean') { + return value; + } + + // Handle truthy checks + return Boolean(value); + } catch { + return false; + } +} + +/** + * Process props and replace template variables + */ +function processProps(props: Record = {}, context: RenderContext): Record { + const processed: Record = {}; + + for (const [key, value] of Object.entries(props)) { + // Handle special props + if (key === 'onClick' || key === 'onChange' || key === 'onClose') { + // Map to action functions + if (typeof value === 'string') { + processed[key] = context.actions?.[value]; + } else { + processed[key] = value; + } + } else if (key === 'startIcon' || key === 'endIcon' || key === 'icon') { + // Handle icon props + if (typeof value === 'string') { + const iconValue = interpolateValue(value, context); + const IconComponent = (Icons as any)[iconValue]; + if (IconComponent) { + processed[key] = React.createElement(IconComponent); + } + } + } else if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + // Recursively process nested objects + processed[key] = processProps(value, context); + } else { + // Interpolate template strings + processed[key] = interpolateValue(value, context); + } + } + + return processed; +} + +/** + * Render Icon component + */ +function renderIcon(iconName: string, props?: Record): React.ReactElement | null { + const IconComponent = (Icons as any)[iconName]; + if (!IconComponent) { + return null; + } + return React.createElement(IconComponent, props); +} + +/** + * Render a single component node + */ +export function renderComponentNode( + node: ComponentNode, + context: RenderContext, + key?: string | number, +): React.ReactElement | null { + // Check condition if present + if (node.condition && !evaluateCondition(node.condition, context)) { + return null; + } + + // Handle forEach loops + if (node.forEach) { + const dataArray = getNestedValue(context, node.forEach); + if (!Array.isArray(dataArray)) { + console.warn(`forEach data is not an array: ${node.forEach}`); + return null; + } + + return ( + + {dataArray.map((item, index) => { + // Create context for this iteration + const itemContext: RenderContext = { + ...context, + data: { + ...context.data, + item, + index, + }, + }; + + // Render children with item context + if (node.children) { + return node.children.map((child, childIndex) => + renderComponentNode(child, itemContext, `${key}-${index}-${childIndex}`) + ); + } + + return null; + })} + + ); + } + + // Get component from registry + const Component = componentRegistry[node.component]; + if (!Component) { + console.warn(`Component not found in registry: ${node.component}`); + return null; + } + + // Process props + const props = processProps(node.props, context); + + // Handle special text prop for Typography and similar components + let children: React.ReactNode = null; + if (props.text) { + children = props.text; + delete props.text; + } + + // Render children + if (node.children && node.children.length > 0) { + children = node.children.map((child, index) => + renderComponentNode(child, context, `${key}-child-${index}`) + ); + } + + // Handle Icon component specially + if (node.component === 'Icon' && props.name) { + return renderIcon(props.name, { ...props, key }); + } + + return React.createElement(Component, { ...props, key }, children); +} + +/** + * Main component tree renderer + */ +export function ComponentTreeRenderer({ + tree, + context, +}: { + tree: ComponentNode; + context: RenderContext; +}): React.ReactElement | null { + return renderComponentNode(tree, context, 'root'); +} + +/** + * Hook to use component tree with state management + */ +export function useComponentTree( + tree: ComponentNode, + initialData?: Record, + actions?: Record any>, +) { + const [data, setData] = React.useState(initialData || {}); + const [state, setState] = React.useState>({}); + + const context: RenderContext = React.useMemo( + () => ({ + data, + actions, + state, + }), + [data, actions, state], + ); + + const updateData = React.useCallback((newData: Record) => { + setData(prev => ({ ...prev, ...newData })); + }, []); + + const updateState = React.useCallback((newState: Record) => { + setState(prev => ({ ...prev, ...newState })); + }, []); + + return { + render: () => , + data, + state, + updateData, + updateState, + }; +} diff --git a/tests/e2e/playbooks.spec.ts b/tests/e2e/playbooks.spec.ts new file mode 100644 index 0000000..e91d607 --- /dev/null +++ b/tests/e2e/playbooks.spec.ts @@ -0,0 +1,169 @@ +/** + * Playwright E2E tests using playbooks from features.json + */ +import { test, expect } from '@playwright/test'; +import { getAllPlaywrightPlaybooks, type PlaywrightPlaybook, type PlaywrightStep } from '@/utils/featureConfig'; + +// Execute a single playbook step +async function executeStep(page: any, step: PlaywrightStep, variables: Record = {}) { + // Replace variables in step values + const replaceVars = (value?: string) => { + if (!value) return value; + return Object.entries(variables).reduce((acc, [key, val]) => { + return acc.replace(new RegExp(`{{${key}}}`, 'g'), val); + }, value); + }; + + switch (step.action) { + case 'goto': + await page.goto(replaceVars(step.url)); + break; + + case 'click': + await page.click(replaceVars(step.selector)); + break; + + case 'fill': + await page.fill(replaceVars(step.selector), replaceVars(step.value) || ''); + break; + + case 'select': + await page.selectOption(replaceVars(step.selector), replaceVars(step.value) || ''); + break; + + case 'wait': + await page.waitForTimeout(step.timeout || 1000); + break; + + case 'expect': + if (step.text === 'visible' && step.selector) { + await expect(page.locator(replaceVars(step.selector))).toBeVisible(); + } else if (step.text === 'redirected' && step.url) { + await expect(page).toHaveURL(replaceVars(step.url)); + } else if (step.text && step.selector) { + await expect(page.locator(replaceVars(step.selector))).toHaveText(replaceVars(step.text) || ''); + } else if (step.text === '401') { + // Check for 401 status + const response = await page.waitForResponse((resp: any) => resp.status() === 401); + expect(response.status()).toBe(401); + } + break; + + case 'screenshot': + if (step.selector) { + await page.locator(replaceVars(step.selector)).screenshot(); + } else { + await page.screenshot(); + } + break; + + default: + console.warn(`Unknown action: ${step.action}`); + } +} + +// Execute a full playbook +async function executePlaybook(page: any, playbook: PlaywrightPlaybook, variables: Record = {}) { + for (const step of playbook.steps) { + await executeStep(page, step, variables); + } +} + +// Execute playbook cleanup steps +async function cleanupPlaybook(page: any, playbook: PlaywrightPlaybook, variables: Record = {}) { + if (playbook.cleanup) { + for (const step of playbook.cleanup) { + try { + await executeStep(page, step, variables); + } catch (err) { + console.warn('Cleanup step failed:', err); + } + } + } +} + +// Load all playbooks +const playbooks = getAllPlaywrightPlaybooks(); + +// Test: API Security Check +test.describe('API Security', () => { + const playbook = playbooks.securityCheck; + + if (playbook) { + test(playbook.name, async ({ page }) => { + await executePlaybook(page, playbook); + }); + } +}); + +// Test: Query Builder +test.describe('Query Builder', () => { + const playbook = playbooks.queryBuilder; + + if (playbook) { + test.skip(playbook.name, async ({ page }) => { + // This test requires authentication, skipping for now + const variables = { + tableName: 'users', + columnName: 'id', + }; + + await executePlaybook(page, playbook, variables); + }); + } +}); + +// Test: Create Table +test.describe('Table Management', () => { + const playbook = playbooks.createTable; + + if (playbook) { + test.skip(playbook.name, async ({ page }) => { + // This test requires authentication, skipping for now + const variables = { + tableName: 'test_table_' + Date.now(), + }; + + await executePlaybook(page, playbook, variables); + + // Cleanup + await cleanupPlaybook(page, playbook, variables); + }); + } +}); + +// Test: Add Column +test.describe('Column Management', () => { + const playbook = playbooks.addColumn; + + if (playbook) { + test.skip(playbook.name, async ({ page }) => { + // This test requires authentication and an existing table, skipping for now + const variables = { + tableName: 'users', + columnName: 'test_column_' + Date.now(), + dataType: 'VARCHAR', + }; + + await executePlaybook(page, playbook, variables); + }); + } +}); + +// Test: Create Index +test.describe('Index Management', () => { + const playbook = playbooks.createIndex; + + if (playbook) { + test.skip(playbook.name, async ({ page }) => { + // This test requires authentication and an existing table, skipping for now + const variables = { + tableName: 'users', + columnName: 'id', + indexName: 'idx_test_' + Date.now(), + }; + + await executePlaybook(page, playbook, variables); + }); + } +}); diff --git a/tests/unit/componentTreeRenderer.test.tsx b/tests/unit/componentTreeRenderer.test.tsx new file mode 100644 index 0000000..158bcd5 --- /dev/null +++ b/tests/unit/componentTreeRenderer.test.tsx @@ -0,0 +1,195 @@ +/** + * Unit tests for component tree renderer + */ +import { describe, it, expect, vi } from 'vitest'; +import { renderComponentNode } from '@/utils/componentTreeRenderer'; +import type { ComponentNode } from '@/utils/featureConfig'; + +describe('componentTreeRenderer', () => { + it('should render a simple Box component', () => { + const node: ComponentNode = { + component: 'Box', + props: { + sx: { p: 2 }, + }, + }; + + const context = { data: {}, actions: {}, state: {} }; + const result = renderComponentNode(node, context); + + expect(result).toBeTruthy(); + expect(result?.type.toString()).toContain('Box'); + }); + + it('should render Typography with text prop', () => { + const node: ComponentNode = { + component: 'Typography', + props: { + variant: 'h5', + text: 'Hello World', + }, + }; + + const context = { data: {}, actions: {}, state: {} }; + const result = renderComponentNode(node, context); + + expect(result).toBeTruthy(); + expect(result?.props.variant).toBe('h5'); + }); + + it('should interpolate template variables', () => { + const node: ComponentNode = { + component: 'Typography', + props: { + text: '{{data.message}}', + }, + }; + + const context = { + data: { message: 'Test Message' }, + actions: {}, + state: {}, + }; + + const result = renderComponentNode(node, context); + expect(result).toBeTruthy(); + }); + + it('should render children', () => { + const node: ComponentNode = { + component: 'Box', + children: [ + { + component: 'Typography', + props: { + text: 'Child 1', + }, + }, + { + component: 'Typography', + props: { + text: 'Child 2', + }, + }, + ], + }; + + const context = { data: {}, actions: {}, state: {} }; + const result = renderComponentNode(node, context); + + expect(result).toBeTruthy(); + expect(Array.isArray(result?.props.children)).toBe(true); + }); + + it('should handle condition and not render when false', () => { + const node: ComponentNode = { + component: 'Box', + condition: 'data.show', + props: { + sx: { p: 2 }, + }, + }; + + const context = { + data: { show: false }, + actions: {}, + state: {}, + }; + + const result = renderComponentNode(node, context); + expect(result).toBeNull(); + }); + + it('should handle condition and render when true', () => { + const node: ComponentNode = { + component: 'Box', + condition: 'data.show', + props: { + sx: { p: 2 }, + }, + }; + + const context = { + data: { show: true }, + actions: {}, + state: {}, + }; + + const result = renderComponentNode(node, context); + expect(result).toBeTruthy(); + }); + + it('should handle forEach loops', () => { + const node: ComponentNode = { + component: 'Box', + forEach: 'data.items', + children: [ + { + component: 'Typography', + props: { + text: '{{item.name}}', + }, + }, + ], + }; + + const context = { + data: { + items: [ + { name: 'Item 1' }, + { name: 'Item 2' }, + { name: 'Item 3' }, + ], + }, + actions: {}, + state: {}, + }; + + const result = renderComponentNode(node, context); + expect(result).toBeTruthy(); + }); + + it('should map onClick to action function', () => { + const mockAction = vi.fn(); + const node: ComponentNode = { + component: 'Button', + props: { + text: 'Click Me', + onClick: 'handleClick', + }, + }; + + const context = { + data: {}, + actions: { handleClick: mockAction }, + state: {}, + }; + + const result = renderComponentNode(node, context); + expect(result).toBeTruthy(); + expect(result?.props.onClick).toBe(mockAction); + }); + + it('should handle nested template interpolation', () => { + const node: ComponentNode = { + component: 'Typography', + props: { + text: 'User: {{data.user.name}}, Age: {{data.user.age}}', + }, + }; + + const context = { + data: { + user: { + name: 'John Doe', + age: 30, + }, + }, + actions: {}, + state: {}, + }; + + const result = renderComponentNode(node, context); + expect(result).toBeTruthy(); + }); +}); diff --git a/tests/unit/useApiCall.test.ts b/tests/unit/useApiCall.test.ts new file mode 100644 index 0000000..1b8c8a6 --- /dev/null +++ b/tests/unit/useApiCall.test.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for useApiCall hook + */ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useApiCall } from '@/hooks/useApiCall'; + +// Mock fetch +global.fetch = vi.fn(); + +describe('useApiCall', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with correct default state', () => { + const { result } = renderHook(() => useApiCall()); + + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should handle successful API call', async () => { + const mockData = { message: 'Success', result: 42 }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }); + + const { result } = renderHook(() => useApiCall()); + + await act(async () => { + await result.current.execute('/api/test'); + }); + + expect(result.current.data).toEqual(mockData); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should handle API error', async () => { + const errorMessage = 'Request failed'; + + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: errorMessage }), + }); + + const { result } = renderHook(() => useApiCall()); + + await act(async () => { + try { + await result.current.execute('/api/test'); + } catch (err) { + // Expected to throw + } + }); + + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(errorMessage); + }); + + it('should set loading state during API call', async () => { + (global.fetch as any).mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ + ok: true, + json: async () => ({ data: 'test' }), + }), 100)) + ); + + const { result } = renderHook(() => useApiCall()); + + act(() => { + result.current.execute('/api/test'); + }); + + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('should handle POST request with body', async () => { + const requestBody = { name: 'test', value: 123 }; + const mockData = { success: true }; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockData, + }); + + const { result } = renderHook(() => useApiCall()); + + await act(async () => { + await result.current.execute('/api/test', { + method: 'POST', + body: requestBody, + }); + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + expect(result.current.data).toEqual(mockData); + }); + + it('should reset state', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: 'test' }), + }); + + const { result } = renderHook(() => useApiCall()); + + await act(async () => { + await result.current.execute('/api/test'); + }); + + expect(result.current.data).not.toBeNull(); + + act(() => { + result.current.reset(); + }); + + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); +});