f7cb0f3241
Add a discoverable per-container "Update now" action, shown only when the
image status is `outdated`, plus a bulk "Update selected" action in the
containers list.
Both manual paths share ONE apply primitive (applyContainerUpdate /
useUpdateContainerImage) that also backs the future M4 auto-update job:
- standalone container -> recreate-with-pull (existing recreate endpoint)
- stack-managed -> stack redeploy-with-pull (existing git/file stack
update mutations), so the container stays in its
stack and is never recreated out-of-band
- externally-managed -> refused; the details button is disabled with an
compose explanatory tooltip and the bulk action skips it
Decision logic lives in the pure, unit-tested resolveContainerUpdatePath /
groupContainersForUpdate helpers. The bulk action filters to outdated
containers and redeploys each owning stack exactly once even when several of
its containers are selected, reporting per-item success/failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
6.2 KiB
TypeScript
228 lines
6.2 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { vi } from 'vitest';
|
|
import { http, HttpResponse } from 'msw';
|
|
|
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
|
import { ContainerEngine } from '@/react/portainer/environments/types';
|
|
import { server } from '@/setup-tests/server';
|
|
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
|
|
|
import { SecondaryActions } from './SecondaryActions';
|
|
|
|
const mockRecreateMutate = vi.fn();
|
|
|
|
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
|
...(await importOriginal()),
|
|
useRouter: vi.fn(() => ({
|
|
stateService: {
|
|
go: vi.fn(),
|
|
},
|
|
})),
|
|
useCurrentStateAndParams: vi.fn(() => ({
|
|
params: { endpointId: 1 }, // Must match environmentId in test props and user authorizations
|
|
})),
|
|
}));
|
|
|
|
vi.mock('./queries/useRecreateContainer', () => ({
|
|
useRecreateContainer: () => ({
|
|
mutate: mockRecreateMutate,
|
|
isLoading: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock(
|
|
'@/react/hooks/useUser',
|
|
async (importOriginal: () => Promise<object>) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...actual,
|
|
useIsEdgeAdmin: () => ({
|
|
isAdmin: true,
|
|
}),
|
|
};
|
|
}
|
|
);
|
|
|
|
vi.mock('@/react/docker/proxy/queries/useInfo', () => ({
|
|
useIsSwarm: () => false,
|
|
}));
|
|
|
|
const mockConfirmContainerRecreation = vi.fn();
|
|
vi.mock('../ConfirmRecreationModal', () => ({
|
|
confirmContainerRecreation: (cannotPullImage: boolean) =>
|
|
mockConfirmContainerRecreation(cannotPullImage),
|
|
}));
|
|
|
|
describe('SecondaryActions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
// Setup MSW handler for environment endpoint
|
|
server.use(
|
|
http.get('/api/endpoints/:id', () =>
|
|
HttpResponse.json(
|
|
createMockEnvironment({
|
|
Id: 1,
|
|
ContainerEngine: ContainerEngine.Docker,
|
|
SecuritySettings: {
|
|
allowContainerCapabilitiesForRegularUsers: true,
|
|
allowBindMountsForRegularUsers: true,
|
|
allowDeviceMappingForRegularUsers: true,
|
|
allowSysctlSettingForRegularUsers: true,
|
|
allowHostNamespaceForRegularUsers: true,
|
|
allowPrivilegedModeForRegularUsers: true,
|
|
allowVolumeBrowserForRegularUsers: true,
|
|
allowStackManagementForRegularUsers: true,
|
|
allowSecurityOptForRegularUsers: true,
|
|
enableHostManagementFeatures: false,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
it('should render component when authorized', async () => {
|
|
renderComponent();
|
|
|
|
// Wait for component to load environment data and render
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByTestId('recreate-container-button')
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show recreate button for Docker containers', async () => {
|
|
renderComponent();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('recreate-container-button')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
it('should show duplicate/edit button', async () => {
|
|
renderComponent();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByTestId('duplicate-edit-container-button')
|
|
).toBeVisible();
|
|
});
|
|
});
|
|
|
|
it('should hide recreate button when container has AutoRemove enabled', () => {
|
|
renderComponent({ containerAutoRemove: true });
|
|
|
|
expect(
|
|
screen.queryByTestId('recreate-container-button')
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should show confirmation modal and call recreate mutation when confirmed', async () => {
|
|
const user = userEvent.setup();
|
|
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: true });
|
|
|
|
renderComponent();
|
|
|
|
const recreateButton = await screen.findByTestId(
|
|
'recreate-container-button'
|
|
);
|
|
await user.click(recreateButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockConfirmContainerRecreation).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockRecreateMutate).toHaveBeenCalledWith(
|
|
{
|
|
environmentId: 1,
|
|
containerId: 'test-container-id',
|
|
pullImage: true,
|
|
nodeName: 'node1',
|
|
},
|
|
expect.objectContaining({
|
|
onSuccess: expect.any(Function),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should detect cannotPullImage when image starts with sha256', async () => {
|
|
const user = userEvent.setup();
|
|
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: false });
|
|
|
|
renderComponent({
|
|
containerImage: 'sha256:1234567890abcdef',
|
|
});
|
|
|
|
const recreateButton = await screen.findByTestId(
|
|
'recreate-container-button'
|
|
);
|
|
await user.click(recreateButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockConfirmContainerRecreation).toHaveBeenCalledWith(true);
|
|
});
|
|
});
|
|
|
|
it('should not call recreate mutation when confirmation is cancelled', async () => {
|
|
const user = userEvent.setup();
|
|
mockConfirmContainerRecreation.mockResolvedValue(undefined);
|
|
|
|
renderComponent();
|
|
|
|
const recreateButton = await screen.findByTestId(
|
|
'recreate-container-button'
|
|
);
|
|
await user.click(recreateButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockConfirmContainerRecreation).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(mockRecreateMutate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function renderComponent(
|
|
props: Partial<React.ComponentProps<typeof SecondaryActions>> = {}
|
|
) {
|
|
const defaultProps: React.ComponentProps<typeof SecondaryActions> = {
|
|
environmentId: 1,
|
|
containerId: 'test-container-id',
|
|
nodeName: 'node1',
|
|
containerImage: 'nginx:latest',
|
|
containerName: 'test-container',
|
|
containerAutoRemove: false,
|
|
isPortainer: false,
|
|
partOfSwarmService: false,
|
|
...props,
|
|
};
|
|
|
|
const mockUser = {
|
|
EndpointAuthorizations: {
|
|
1: { DockerContainerCreate: true },
|
|
},
|
|
PortainerAuthorizations: {},
|
|
Id: 1,
|
|
Role: 1,
|
|
Username: 'mock',
|
|
UseCache: false,
|
|
ThemeSettings: {
|
|
color: 'auto' as const,
|
|
subtleUpgradeButton: false,
|
|
},
|
|
};
|
|
|
|
const Wrapper = withTestQueryProvider(
|
|
withUserProvider(withTestRouter(SecondaryActions), mockUser)
|
|
);
|
|
|
|
return render(<Wrapper {...defaultProps} />);
|
|
}
|