Files
portainer/app/react/docker/containers/update/groupContainersForUpdate.test.ts
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

66 lines
1.8 KiB
TypeScript

import { Stack } from '@/react/common/stacks/types';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { groupContainersForUpdate } from './groupContainersForUpdate';
import { ContainerUpdateContext } from './types';
function buildContext(
overrides: Partial<ContainerUpdateContext>
): ContainerUpdateContext {
return {
id: 'c1',
name: 'container',
image: 'nginx:latest',
environmentId: 3,
...overrides,
};
}
const stack = {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
} as Stack;
describe('groupContainersForUpdate', () => {
it('redeploys a stack only once when several of its containers are selected', () => {
const contexts = [
buildContext({
id: 'a',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
buildContext({
id: 'b',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
];
const result = groupContainersForUpdate(contexts, [stack]);
expect(result.stacks).toHaveLength(1);
expect(result.stacks[0].stackId).toBe(7);
expect(result.standalone).toHaveLength(0);
expect(result.external).toHaveLength(0);
});
it('partitions standalone, stack and external containers', () => {
const contexts = [
buildContext({ id: 'standalone', labels: {} }),
buildContext({
id: 'stack',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
buildContext({
id: 'external',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
}),
];
const result = groupContainersForUpdate(contexts, [stack]);
expect(result.standalone.map((c) => c.id)).toEqual(['standalone']);
expect(result.stacks.map((s) => s.stackId)).toEqual([7]);
expect(result.external.map((c) => c.id)).toEqual(['external']);
});
});