portainer/app/react/docker/containers/ItemView/ContainerStatusSection/NameRow.test.tsx

389 lines
11 KiB
TypeScript

import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
import { JSXElementConstructor, ReactElement } from 'react';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { createMockUser } from '@/react-tools/test-mocks';
import { server } from '@/setup-tests/server';
import { User } from '@/portainer/users/types';
import { NameRow } from './NameRow';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: 1 },
})),
}));
describe('NameRow', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Display Mode', () => {
it('displays container name with leading slash trimmed', () => {
// Test with leading slash
const { rerender } = renderComponent({ containerName: '/my-container' });
expect(screen.getByText('my-container')).toBeVisible();
expect(screen.queryByText('/my-container')).not.toBeInTheDocument();
// Test without leading slash
rerenderComponent(rerender, {
props: {
containerName: 'already-trimmed',
},
});
expect(screen.getByText('already-trimmed')).toBeVisible();
});
it('shows edit button for admin user', async () => {
const adminUser = createMockUser({ Role: 1 });
renderComponent({}, adminUser);
expect(
await screen.findByRole('button', { name: /edit container name/i })
).toBeVisible();
});
it('shows edit button for non-admin user with authorization', async () => {
const authorizedUser = createMockUser({
Role: 2,
EndpointAuthorizations: {
1: {
DockerContainerRename: true,
},
},
});
renderComponent({}, authorizedUser);
await waitFor(async () => {
expect(
screen.getByRole('button', { name: /edit container name/i })
).toBeVisible();
});
});
it('shows edit button for non-admin user without authorization (CE)', async () => {
const unauthorizedUser = createMockUser({
Role: 2,
EndpointAuthorizations: {
1: {
DockerContainerRename: false,
},
},
});
renderComponent({}, unauthorizedUser);
expect(
await screen.findByRole('button', { name: /edit container name/i })
).toBeVisible();
});
});
describe('Edit Mode', () => {
it('enters edit mode with focused input pre-filled with trimmed name', async () => {
renderComponent({ containerName: '/original-name' });
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const nameInput = screen.getByTestId('containerNameInput');
expect(nameInput).toBeVisible();
expect(nameInput).toHaveFocus();
expect(nameInput).toHaveValue('original-name');
});
it('shows cancel and confirm buttons in edit mode', async () => {
renderComponent();
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
expect(
screen.getByRole('button', { name: /cancel container name edit/i })
).toBeVisible();
expect(
screen.getByRole('button', { name: /rename container/i })
).toBeVisible();
});
it('returns to display mode when cancel button is clicked', async () => {
renderComponent({ containerName: '/my-container' });
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const cancelButton = screen.getByRole('button', {
name: /cancel container name edit/i,
});
await userEvent.click(cancelButton);
expect(screen.getByText('my-container')).toBeVisible();
expect(
screen.queryByTestId('containerNameInput')
).not.toBeInTheDocument();
});
it('allows editing the container name', async () => {
renderComponent();
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const nameInput = screen.getByTestId('containerNameInput');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'new-container-name');
expect(nameInput).toHaveValue('new-container-name');
});
});
describe('Rename Functionality', () => {
it('successfully renames container with correct API parameters and returns to display mode', async () => {
let capturedRequest: {
endpointId: string | null;
containerId: string | null;
nodeNameHeader: string | null;
name: string | null;
} | null = null;
const onSuccess = vi.fn();
server.use(
http.post(
'/api/endpoints/:endpointId/docker/containers/:id/rename',
async ({ params, request }) => {
const url = new URL(request.url);
capturedRequest = {
endpointId: params.endpointId as string,
containerId: params.id as string,
nodeNameHeader: request.headers.get('X-PortainerAgent-Target'),
name: url.searchParams.get('name'),
};
return HttpResponse.json({});
}
)
);
renderComponent({
containerId: 'container-abc',
containerName: '/original-name',
environmentId: 5,
nodeName: 'swarm-node-2',
onSuccess,
});
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const nameInput = screen.getByTestId('containerNameInput');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'updated-name');
const confirmButton = screen.getByTestId(
'container-confirm-rename-button'
);
expect(confirmButton).not.toBeDisabled();
await userEvent.click(confirmButton);
// Button should be disabled during submission
expect(confirmButton).toBeDisabled();
// Should call onSuccess callback after successful rename
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledTimes(1);
});
// Should return to display mode
await waitFor(() => {
expect(
screen.queryByTestId('containerNameInput')
).not.toBeInTheDocument();
});
// Verify API was called with correct parameters
expect(capturedRequest).toEqual({
endpointId: '5',
containerId: 'container-abc',
nodeNameHeader: 'swarm-node-2',
name: 'updated-name',
});
});
it('does not make API call if name is unchanged', async () => {
const onSuccess = vi.fn();
let apiCallCount = 0;
server.use(
http.post(
'/api/endpoints/:endpointId/docker/containers/:id/rename',
() => {
apiCallCount += 1;
return HttpResponse.json({});
}
)
);
renderComponent({
containerName: '/same-name',
onSuccess,
});
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const confirmButton = screen.getByTestId(
'container-confirm-rename-button'
);
await userEvent.click(confirmButton);
await waitFor(() => {
expect(screen.getByText('same-name')).toBeVisible();
});
expect(apiCallCount).toBe(0);
expect(onSuccess).not.toHaveBeenCalled();
});
it('handles rename API error gracefully', async () => {
// Mock console.error to suppress expected error logs
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
server.use(
http.post(
'/api/endpoints/:endpointId/docker/containers/:id/rename',
() =>
HttpResponse.json(
{ message: 'Container rename failed' },
{ status: 500 }
)
)
);
renderComponent({ containerName: '/original-name' });
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const nameInput = screen.getByTestId('containerNameInput');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'new-name');
const confirmButton = screen.getByTestId(
'container-confirm-rename-button'
);
await userEvent.click(confirmButton);
expect(confirmButton).toBeDisabled();
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
// Should still be in edit mode with the form visible (not closed on error)
expect(screen.getByTestId('containerNameInput')).toBeVisible();
expect(nameInput).toHaveValue('new-name');
consoleErrorSpy.mockRestore();
});
it('validates that container name is required', async () => {
renderComponent({ containerName: '/original-name' });
const editButton = await screen.findByRole('button', {
name: /edit container name/i,
});
await userEvent.click(editButton);
const nameInput = screen.getByTestId('containerNameInput');
await userEvent.clear(nameInput);
const confirmButton = screen.getByTestId(
'container-confirm-rename-button'
);
await userEvent.click(confirmButton);
expect(
screen.queryByText(/successfully renamed/i)
).not.toBeInTheDocument();
});
});
});
type RenderProps = Partial<{
containerId: string;
containerName: string;
environmentId: number;
nodeName: string;
onSuccess: () => void;
}>;
function createWrappedComponent(
props: RenderProps,
user = createMockUser({ Role: 1 })
) {
const defaultProps = {
containerId: 'container-123',
containerName: '/test-container',
environmentId: 1,
nodeName: 'node-1',
onSuccess: vi.fn(),
};
const mergedProps = { ...defaultProps, ...props };
const Wrapped = withTestQueryProvider(
withTestRouter(withUserProvider(NameRow, user), {
route: 'docker.containers.container',
stateConfig: [
{
name: 'docker.containers.container',
url: '/docker/:endpointId/containers/:id',
params: { endpointId: '1', id: mergedProps.containerId },
},
],
})
);
return { Wrapped, mergedProps };
}
function renderComponent(
props: RenderProps = {},
user = createMockUser({ Role: 1 })
) {
const { Wrapped, mergedProps } = createWrappedComponent(props, user);
return render(<Wrapped {...mergedProps} />);
}
function rerenderComponent(
rerender: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ui: ReactElement<any, string | JSXElementConstructor<any>>
) => void,
{ props = {}, user }: { props?: RenderProps; user?: User } = {}
) {
const { Wrapped, mergedProps } = createWrappedComponent(props, user);
rerender(<Wrapped {...mergedProps} />);
}