Files
portainer/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx
T
claude code agent f7cb0f3241 feat(automation): "Update now" action (stack-aware) + bulk update (#10, epic #3 M3)
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>
2026-06-29 09:24:10 +03:00

82 lines
2.3 KiB
TypeScript

import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { ContainerImageStatusValue } from '@/react/docker/containers/queries/useContainerImageStatus';
import { UpdateNowButton } from './UpdateNowButton';
const mockUseImageStatus = vi.fn();
const mockUseStacks = vi.fn();
const mockMutate = vi.fn();
vi.mock('@uirouter/react', () => ({
useRouter: () => ({ stateService: { go: vi.fn() } }),
}));
vi.mock('@/react/docker/containers/queries/useContainerImageStatus', () => ({
useContainerImageStatus: () => mockUseImageStatus(),
}));
vi.mock('@/react/common/stacks/queries/useStacks', () => ({
useStacks: () => mockUseStacks(),
}));
vi.mock(
'@/react/docker/containers/update',
async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }),
})
);
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 });
});
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();
});
});