Files
agent_coder 6aecdfbe46 feat(containers): interactive image-status badge (click to update / re-check)
Make the container image-status badge actionable, matching native Portainer:
- Clicking "Update available" opens the update confirm dialog and runs the
  existing update flow (standalone recreate-with-pull / stack redeploy), gated
  and disabled while in flight to avoid a double submit. The confirm+apply logic
  is extracted from UpdateNowButton into a shared useApplyContainerImageUpdate
  hook so the details button and the list badge share one implementation.
- Clicking "Up to date" re-queries the registry. Because the server caches image
  status (statusCache 5m + remoteDigestCache 5s), a plain refetch was a no-op, so
  the endpoint gains an optional ?force=true that bypasses BOTH caches for a
  manual re-check while still repopulating them; the default (auto badges + the
  auto-update daemon) keeps using the caches unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:04:49 +03:00

127 lines
3.7 KiB
TypeScript

import { render, screen, fireEvent } from '@testing-library/react';
import { vi } from 'vitest';
import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus';
import { UpdateStatusBadge } from './UpdateStatusBadge';
describe('UpdateStatusBadge', () => {
it('renders "Update available" when the image is outdated', () => {
render(<UpdateStatusBadge status="outdated" />);
expect(screen.getByText('Update available')).toBeInTheDocument();
});
it('renders an up-to-date indicator when the image is updated', () => {
render(<UpdateStatusBadge status="updated" />);
expect(screen.getByText('Up to date')).toBeInTheDocument();
});
it('shows a spinner while loading', () => {
render(<UpdateStatusBadge isLoading />);
expect(
screen.getByLabelText('Checking for image updates')
).toBeInTheDocument();
});
it.each<ContainerImageStatusValue>([
'skipped',
'error',
'processing',
'preparing',
])('renders nothing for the neutral status "%s"', (status) => {
const { container } = render(<UpdateStatusBadge status={status} />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when status is undefined and not loading', () => {
const { container } = render(<UpdateStatusBadge />);
expect(container).toBeEmptyDOMElement();
});
describe('interactive behavior', () => {
it('renders "Update available" as a plain span when no handler is passed', () => {
render(<UpdateStatusBadge status="outdated" />);
expect(
screen.queryByRole('button', { name: /update available/i })
).not.toBeInTheDocument();
});
it('renders "Update available" as a button and fires the handler on click', () => {
const onUpdateClick = vi.fn();
render(
<UpdateStatusBadge status="outdated" onUpdateClick={onUpdateClick} />
);
const button = screen.getByRole('button', {
name: /update available/i,
});
fireEvent.click(button);
expect(onUpdateClick).toHaveBeenCalledTimes(1);
});
it('renders "Up to date" as a plain span when no handler is passed', () => {
render(<UpdateStatusBadge status="updated" />);
expect(
screen.queryByRole('button', { name: /up to date/i })
).not.toBeInTheDocument();
});
it('renders "Up to date" as a button and fires the recheck handler on click', () => {
const onRecheckClick = vi.fn();
render(
<UpdateStatusBadge status="updated" onRecheckClick={onRecheckClick} />
);
const button = screen.getByRole('button', { name: /up to date/i });
fireEvent.click(button);
expect(onRecheckClick).toHaveBeenCalledTimes(1);
});
it('shows a pending affordance and disables the update button while updating', () => {
const onUpdateClick = vi.fn();
render(
<UpdateStatusBadge
status="outdated"
onUpdateClick={onUpdateClick}
isUpdating
/>
);
const button = screen.getByRole('button', {
name: /updating the image/i,
});
expect(button).toBeDisabled();
expect(screen.getByText('Updating...')).toBeInTheDocument();
fireEvent.click(button);
expect(onUpdateClick).not.toHaveBeenCalled();
});
it('shows a spinner and disables the recheck button while rechecking', () => {
const onRecheckClick = vi.fn();
render(
<UpdateStatusBadge
status="updated"
onRecheckClick={onRecheckClick}
isRechecking
/>
);
const button = screen.getByRole('button', { name: /up to date/i });
expect(button).toBeDisabled();
fireEvent.click(button);
expect(onRecheckClick).not.toHaveBeenCalled();
});
});
});