import { waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { http, HttpResponse } from 'msw'; import { vi } from 'vitest'; import { server } from '@/setup-tests/server'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { useVersionedStackFile } from './useVersionedStackFile'; describe('useVersionedStackFile', () => { const defaultStackId = 1; const defaultVersion = '2'; const mockOnLoad = vi.fn(); beforeEach(() => { vi.clearAllMocks(); setupMswHandlers(); }); describe('initial state', () => { it('should return loading state initially when version is provided', () => { const { result } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); expect(result.current.isLoading).toBe(true); expect(result.current.content).toBeUndefined(); }); it('should not fetch when version is undefined', () => { let fetchAttempted = false; server.use( http.get('/api/stacks/:id/file', () => { fetchAttempted = true; return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); renderHookWithProviders({ stackId: defaultStackId, version: undefined, onLoad: mockOnLoad, }); expect(fetchAttempted).toBe(false); expect(mockOnLoad).not.toHaveBeenCalled(); }); it('should not fetch when version is empty string', () => { let fetchAttempted = false; server.use( http.get('/api/stacks/:id/file', () => { fetchAttempted = true; return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); renderHookWithProviders({ stackId: defaultStackId, version: '', onLoad: mockOnLoad, }); expect(fetchAttempted).toBe(false); expect(mockOnLoad).not.toHaveBeenCalled(); }); }); describe('successful data fetching', () => { it('should fetch stack file content when version is provided', async () => { const stackContent = 'version: "3"\nservices:\n web:\n image: nginx'; setupMswHandlers({ stackContent }); const { result } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.content).toBe(stackContent); }); it('should call onLoad callback with content when data is fetched successfully', async () => { const stackContent = 'version: "3.8"\nservices:\n db:\n image: postgres'; setupMswHandlers({ stackContent }); renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(mockOnLoad).toHaveBeenCalledWith(stackContent); }); }); it('should call onLoad only once for the same data', async () => { const stackContent = 'version: "3"'; setupMswHandlers({ stackContent }); renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(mockOnLoad).toHaveBeenCalledTimes(1); }); // Wait a bit to ensure no additional calls await waitFor(() => expect(true).toBe(true)); expect(mockOnLoad).toHaveBeenCalledTimes(1); }); it('should include version parameter in API request', async () => { let capturedVersion: string | null = null; server.use( http.get('/api/stacks/:id/file', ({ request }) => { const url = new URL(request.url); capturedVersion = url.searchParams.get('version'); return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); renderHookWithProviders({ stackId: defaultStackId, version: '5', onLoad: mockOnLoad, }); await waitFor(() => { expect(capturedVersion).toBe('5'); }); }); it('should fetch from correct stack ID endpoint', async () => { let capturedStackId: string | null = null; server.use( http.get('/api/stacks/:id/file', ({ params }) => { capturedStackId = params.id as string; return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); renderHookWithProviders({ stackId: 42, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(capturedStackId).toBe('42'); }); }); }); describe('version changes', () => { it('should refetch when version changes', async () => { const firstContent = 'version: "3"\nservices:\n web:\n image: nginx'; const secondContent = 'version: "2"\nservices:\n web:\n image: apache'; server.use( http.get('/api/stacks/:id/file', ({ request }) => { const url = new URL(request.url); const version = url.searchParams.get('version'); if (version === '3') { return HttpResponse.json({ StackFileContent: firstContent, }); } return HttpResponse.json({ StackFileContent: secondContent, }); }) ); const { rerender } = renderHookWithProviders({ stackId: defaultStackId, version: '3', onLoad: mockOnLoad, }); await waitFor(() => { expect(mockOnLoad).toHaveBeenCalledWith(firstContent); }); expect(mockOnLoad).toHaveBeenCalledTimes(1); // Change version to 2 rerender({ stackId: defaultStackId, version: '2', onLoad: mockOnLoad, }); await waitFor(() => { expect(mockOnLoad).toHaveBeenCalledWith(secondContent); }); expect(mockOnLoad).toHaveBeenCalledTimes(2); }); it('should call onLoad with new content when version changes', async () => { server.use( http.get('/api/stacks/:id/file', ({ request }) => { const url = new URL(request.url); const version = url.searchParams.get('version'); return HttpResponse.json({ StackFileContent: `content for version ${version}`, }); }) ); const { rerender } = renderHookWithProviders({ stackId: defaultStackId, version: '1', onLoad: mockOnLoad, }); await waitFor(() => { expect(mockOnLoad).toHaveBeenCalledWith('content for version 1'); }); mockOnLoad.mockClear(); // Change to version 2 rerender({ stackId: defaultStackId, version: '2', onLoad: mockOnLoad, }); await waitFor(() => { expect(mockOnLoad).toHaveBeenCalledWith('content for version 2'); }); }); it('should stop fetching when version becomes undefined', async () => { let fetchCount = 0; server.use( http.get('/api/stacks/:id/file', () => { fetchCount++; return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); const { rerender } = renderHookWithProviders({ stackId: defaultStackId, version: '3', onLoad: mockOnLoad, }); await waitFor(() => { expect(fetchCount).toBe(1); }); const initialFetchCount = fetchCount; // Change version to undefined rerender({ stackId: defaultStackId, version: undefined, onLoad: mockOnLoad, }); // Wait to ensure no new fetch await waitFor(() => expect(true).toBe(true)); expect(fetchCount).toBe(initialFetchCount); }); }); describe('error handling', () => { it('should handle API errors gracefully', async () => { server.use( http.get('/api/stacks/:id/file', () => HttpResponse.json({ message: 'Stack not found' }, { status: 404 }) ) ); const { result } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); // onLoad should not be called on error expect(mockOnLoad).not.toHaveBeenCalled(); expect(result.current.content).toBeUndefined(); }); it('should not call onLoad when StackFileContent is empty', async () => { server.use( http.get('/api/stacks/:id/file', () => HttpResponse.json({ StackFileContent: '', }) ) ); renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => expect(true).toBe(true)); expect(mockOnLoad).not.toHaveBeenCalled(); }); it('should not call onLoad when StackFileContent is null', async () => { server.use( http.get('/api/stacks/:id/file', () => HttpResponse.json({ StackFileContent: null, }) ) ); renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => expect(true).toBe(true)); expect(mockOnLoad).not.toHaveBeenCalled(); }); it('should not call onLoad when StackFileContent is missing from response', async () => { server.use(http.get('/api/stacks/:id/file', () => HttpResponse.json({}))); renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => expect(true).toBe(true)); expect(mockOnLoad).not.toHaveBeenCalled(); }); }); describe('loading states', () => { it('should show loading state while fetching', () => { server.use( http.get('/api/stacks/:id/file', async () => { // Delay response await new Promise((resolve) => { setTimeout(resolve, 100); }); return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); const { result } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); expect(result.current.isLoading).toBe(true); }); it('should clear loading state after successful fetch', async () => { setupMswHandlers(); const { result } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); }); it('should clear loading state after failed fetch', async () => { server.use( http.get('/api/stacks/:id/file', () => HttpResponse.json({ message: 'Error' }, { status: 500 }) ) ); const { result } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: mockOnLoad, }); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); }); }); describe('onLoad callback stability', () => { it('should handle onLoad callback changes without refetching', async () => { let fetchCount = 0; server.use( http.get('/api/stacks/:id/file', () => { fetchCount++; return HttpResponse.json({ StackFileContent: 'version: "3"', }); }) ); const firstCallback = vi.fn(); const { rerender } = renderHookWithProviders({ stackId: defaultStackId, version: defaultVersion, onLoad: firstCallback, }); await waitFor(() => { expect(firstCallback).toHaveBeenCalled(); }); const initialFetchCount = fetchCount; const secondCallback = vi.fn(); // Change callback rerender({ stackId: defaultStackId, version: defaultVersion, onLoad: secondCallback, }); await waitFor(() => { // The new callback should be called with existing data expect(secondCallback).toHaveBeenCalledWith('version: "3"'); }); // But no new fetch should occur expect(fetchCount).toBe(initialFetchCount); }); }); }); /** * Setup MSW handlers for API requests */ function setupMswHandlers({ stackContent = 'version: "3"\nservices:\n web:\n image: nginx', }: { stackContent?: string } = {}) { server.use( http.get('/api/stacks/:id/file', () => HttpResponse.json({ StackFileContent: stackContent, }) ) ); } /** * Helper function to render hook with providers */ function renderHookWithProviders({ stackId, version, onLoad, }: { stackId: number; version?: string; onLoad: (content: string) => void; }) { const Wrapper = withTestQueryProvider<{ stackId: number; version?: string; onLoad: (content: string) => void; }>(({ children }) => <>{children}); return renderHook(useVersionedStackFile, { initialProps: { stackId, version, onLoad }, wrapper: Wrapper, }); }