Files
portainer/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx
T
agent_coder 5bb678d3ba fix(#19): address review F1-F4 (badge test, force write-back test, force throttle, per-container stack notifications)
- 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>
2026-07-01 20:50:55 +03:00

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();
});
});