mirror of https://github.com/portainer/portainer
525 lines
13 KiB
TypeScript
525 lines
13 KiB
TypeScript
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,
|
|
});
|
|
}
|