5bb678d3ba
- F1: test that clicking the badge/UpdateNowButton actually dispatches the update (confirm->mutate) for standalone and stack, and not on dismiss. - F2: Go test that a successful forced re-check repopulates the caches (a later non-force read hits cache, no second registry HEAD). - F3: throttle forced image-status re-checks against registry amplification — coalesce concurrent forced re-checks of the same image via singleflight, plus a 5s per-image min-interval (== remoteDigestCache TTL) caching only successes. The non-force path (daemon + background badges) is unchanged. - F4: notifications are now per-container. Stack-member containers each emit their own EventUpdated (not one aggregate stack event), Event carries the stack name (from the com.docker.compose.project label), and the new image digest is fetched best-effort by re-inspecting the container after the redeploy. Message: 'Environment | .. / Stack [<name>] / Update [<container>]: <old> -> <new>'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { vi } from 'vitest';
|
|
|
|
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
|
|
import { StackType } from '@/react/common/stacks/types';
|
|
import { ContainerImageStatusValue } from '@/react/docker/containers/queries/useContainerImageStatus';
|
|
|
|
import { UpdateNowButton } from './UpdateNowButton';
|
|
|
|
const mockUseImageStatus = vi.fn();
|
|
const mockUseStacks = vi.fn();
|
|
const mockUseAuthorizations = vi.fn();
|
|
const mockMutate = vi.fn();
|
|
const mockConfirmContainerRecreation = vi.fn();
|
|
const mockConfirmStackUpdate = vi.fn();
|
|
|
|
vi.mock('@uirouter/react', () => ({
|
|
useRouter: () => ({ stateService: { go: vi.fn() } }),
|
|
}));
|
|
|
|
// The two confirm dialogs are the async gate before the mutation dispatches;
|
|
// stub them so a test can drive the resolved (confirmed) branch.
|
|
vi.mock('@/react/docker/containers/ItemView/ConfirmRecreationModal', () => ({
|
|
confirmContainerRecreation: (...args: unknown[]) =>
|
|
mockConfirmContainerRecreation(...args),
|
|
}));
|
|
|
|
vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({
|
|
confirmStackUpdate: (...args: unknown[]) => mockConfirmStackUpdate(...args),
|
|
}));
|
|
|
|
vi.mock('@/portainer/services/notifications', () => ({
|
|
notifySuccess: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/react/docker/containers/queries/useContainerImageStatus', () => ({
|
|
useContainerImageStatus: () => mockUseImageStatus(),
|
|
}));
|
|
|
|
vi.mock('@/react/common/stacks/queries/useStacks', () => ({
|
|
useStacks: () => mockUseStacks(),
|
|
}));
|
|
|
|
vi.mock('@/react/hooks/useUser', () => ({
|
|
useAuthorizations: () => mockUseAuthorizations(),
|
|
}));
|
|
|
|
const composeStack = {
|
|
Id: 7,
|
|
Name: 'my-stack',
|
|
EndpointId: 3,
|
|
Type: StackType.DockerCompose,
|
|
};
|
|
|
|
// Mock the concrete mutation module so both the `update` index re-export and the
|
|
// shared `useApplyContainerImageUpdate` hook (which imports it directly) resolve
|
|
// to the stub.
|
|
vi.mock('@/react/docker/containers/update/useUpdateContainerImage', () => ({
|
|
useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }),
|
|
invalidateContainerUpdateQueries: vi.fn(),
|
|
}));
|
|
|
|
function setStatus(status?: ContainerImageStatusValue) {
|
|
mockUseImageStatus.mockReturnValue({
|
|
data: status ? { Status: status } : undefined,
|
|
});
|
|
}
|
|
|
|
function renderButton(
|
|
props: Partial<React.ComponentProps<typeof UpdateNowButton>> = {}
|
|
) {
|
|
return render(
|
|
<UpdateNowButton
|
|
environmentId={3}
|
|
containerId="abc"
|
|
containerImage="nginx:latest"
|
|
containerName="web"
|
|
isPortainer={false}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe('UpdateNowButton', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockUseStacks.mockReturnValue({ data: [], isLoading: false });
|
|
mockUseAuthorizations.mockReturnValue({ authorized: true });
|
|
});
|
|
|
|
it('renders when the image is outdated', () => {
|
|
setStatus('outdated');
|
|
renderButton();
|
|
expect(screen.getByTestId('update-now-button')).toBeInTheDocument();
|
|
});
|
|
|
|
it('is hidden when the image is up to date', () => {
|
|
setStatus('updated');
|
|
renderButton();
|
|
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('is hidden when the status is unknown', () => {
|
|
setStatus(undefined);
|
|
renderButton();
|
|
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('disables the action for externally-managed compose containers', () => {
|
|
setStatus('outdated');
|
|
renderButton({
|
|
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
|
|
});
|
|
expect(screen.getByTestId('update-now-button')).toBeDisabled();
|
|
});
|
|
|
|
it('enables a stack container when the user can update stacks', () => {
|
|
setStatus('outdated');
|
|
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
|
|
mockUseAuthorizations.mockReturnValue({ authorized: true });
|
|
renderButton({
|
|
environmentId: 3,
|
|
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
|
});
|
|
expect(screen.getByTestId('update-now-button')).toBeEnabled();
|
|
});
|
|
|
|
it('disables a stack container when the user lacks PortainerStackUpdate', () => {
|
|
setStatus('outdated');
|
|
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
|
|
mockUseAuthorizations.mockReturnValue({ authorized: false });
|
|
renderButton({
|
|
environmentId: 3,
|
|
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
|
});
|
|
expect(screen.getByTestId('update-now-button')).toBeDisabled();
|
|
});
|
|
|
|
// apply() dispatch: clicking confirms, then routes to the mutation exactly once
|
|
// with the resolved pullImage, via the standalone (recreate) vs stack (redeploy)
|
|
// confirm dialog.
|
|
it('applies a standalone update via the recreate confirm, mutating once with pullImage', async () => {
|
|
setStatus('outdated');
|
|
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: true });
|
|
|
|
renderButton(); // no compose label -> standalone
|
|
|
|
fireEvent.click(screen.getByTestId('update-now-button'));
|
|
|
|
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
|
|
expect(mockConfirmContainerRecreation).toHaveBeenCalledTimes(1);
|
|
expect(mockConfirmStackUpdate).not.toHaveBeenCalled();
|
|
expect(mockMutate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ pullImage: true }),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('applies a stack update via the stack confirm, mutating once with pullImage', async () => {
|
|
setStatus('outdated');
|
|
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
|
|
mockUseAuthorizations.mockReturnValue({ authorized: true });
|
|
mockConfirmStackUpdate.mockResolvedValue({ repullImageAndRedeploy: false });
|
|
|
|
renderButton({
|
|
environmentId: 3,
|
|
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId('update-now-button'));
|
|
|
|
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
|
|
expect(mockConfirmStackUpdate).toHaveBeenCalledTimes(1);
|
|
expect(mockConfirmContainerRecreation).not.toHaveBeenCalled();
|
|
expect(mockMutate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ pullImage: false }),
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('does not mutate when the confirm dialog is dismissed', async () => {
|
|
setStatus('outdated');
|
|
mockConfirmContainerRecreation.mockResolvedValue(false);
|
|
|
|
renderButton();
|
|
|
|
fireEvent.click(screen.getByTestId('update-now-button'));
|
|
|
|
await waitFor(() =>
|
|
expect(mockConfirmContainerRecreation).toHaveBeenCalledTimes(1)
|
|
);
|
|
expect(mockMutate).not.toHaveBeenCalled();
|
|
});
|
|
});
|