Files
portainer/app/react/docker/containers/update/applyContainerUpdate.test.ts
claude code agent 922f506fe5 feat(automation): guard update→rollback loop; name Settings types; tests & doc fixes (F1-F7)
F1: record rolled-back targets per service (endpointID/containerName + remote
    digest) and skip auto-update during a 24h cooldown unless the remote digest
    changes — breaks the infinite update→rollback loop on a persistently
    unhealthy image, without blocking a genuinely new image.
F2: unit-test applyContainerUpdate dispatch/payload mapping.
F3: settings_update.go comments mention auto-heal AND auto-update.
F4: drop stale '(future M4)' TS docs; primitives are frontend-only.
F5: replace the anonymous ContainerAutomation settings struct with named
    types (identical JSON tags).
F6: drop parseEnable (duplicate of boolLabel).
F7: remove the unused gitService dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:57 +03:00

166 lines
5.2 KiB
TypeScript

import { vi, describe, it, expect, beforeEach } from 'vitest';
import { Stack, StackType } from '@/react/common/stacks/types';
import {
applyContainerUpdate,
EXTERNAL_STACK_UPDATE_ERROR,
} from './applyContainerUpdate';
import { ContainerUpdateContext } from './types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
// Mock the side-effecting mutations and the file fetch so we assert the dispatch
// and payload mapping without touching the network.
const recreateContainer = vi.fn();
const updateStack = vi.fn();
const updateGitStack = vi.fn();
const getStackFile = vi.fn();
vi.mock('../containers.service', () => ({
recreateContainer: (...args: unknown[]) => recreateContainer(...args),
}));
vi.mock('@/react/docker/stacks/useUpdateStack', () => ({
updateStack: (...args: unknown[]) => updateStack(...args),
}));
vi.mock('@/react/portainer/gitops/queries/useUpdateGitStack', () => ({
updateGitStack: (...args: unknown[]) => updateGitStack(...args),
}));
vi.mock('@/react/common/stacks/queries/useStackFile', () => ({
getStackFile: (...args: unknown[]) => getStackFile(...args),
}));
// Drive the standalone/stack/external dispatch deterministically; the resolver's
// own routing logic is covered by resolveContainerUpdatePath.test.ts.
vi.mock('./resolveContainerUpdatePath', () => ({
resolveContainerUpdatePath: vi.fn(),
}));
const resolveMock = vi.mocked(resolveContainerUpdatePath);
function buildContext(
overrides: Partial<ContainerUpdateContext> = {}
): ContainerUpdateContext {
return {
id: 'abc123',
name: 'my-container',
image: 'nginx:latest',
environmentId: 3,
nodeName: 'node-1',
...overrides,
};
}
function buildStack(overrides: Partial<Stack>): Stack {
return {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
Env: [{ name: 'FOO', value: 'bar' }],
Option: { Prune: true },
...overrides,
} as Stack;
}
describe('applyContainerUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('recreates a standalone container with pull and node, returning "standalone"', async () => {
resolveMock.mockReturnValue({ kind: 'standalone' });
const context = buildContext();
const result = await applyContainerUpdate(context, []);
expect(result).toBe('standalone');
expect(recreateContainer).toHaveBeenCalledWith(3, 'abc123', true, {
nodeName: 'node-1',
});
expect(updateStack).not.toHaveBeenCalled();
expect(updateGitStack).not.toHaveBeenCalled();
});
it('passes pullImage=false through to the standalone recreate', async () => {
resolveMock.mockReturnValue({ kind: 'standalone' });
await applyContainerUpdate(buildContext(), [], { pullImage: false });
expect(recreateContainer).toHaveBeenCalledWith(3, 'abc123', false, {
nodeName: 'node-1',
});
});
it('redeploys a git stack via updateGitStack with RepullImageAndRedeploy', async () => {
const stack = buildStack({
Id: 9,
GitConfig: { URL: 'https://example.com/repo.git' } as Stack['GitConfig'],
});
resolveMock.mockReturnValue({
kind: 'stack',
stackId: 9,
isGitStack: true,
});
const result = await applyContainerUpdate(buildContext(), [stack]);
expect(result).toBe('stack');
expect(updateGitStack).toHaveBeenCalledWith(9, 3, {
RepullImageAndRedeploy: true,
Env: [{ name: 'FOO', value: 'bar' }],
Prune: true,
});
expect(getStackFile).not.toHaveBeenCalled();
expect(updateStack).not.toHaveBeenCalled();
});
it('redeploys a file stack via updateStack, preserving its current file content', async () => {
const stack = buildStack({ Id: 7 });
resolveMock.mockReturnValue({
kind: 'stack',
stackId: 7,
isGitStack: false,
});
getStackFile.mockResolvedValue({ StackFileContent: 'version: "3"\n' });
const result = await applyContainerUpdate(buildContext(), [stack]);
expect(result).toBe('stack');
expect(getStackFile).toHaveBeenCalledWith({ stackId: 7 });
expect(updateStack).toHaveBeenCalledWith({
stackId: 7,
environmentId: 3,
payload: {
stackFileContent: 'version: "3"\n',
env: [{ name: 'FOO', value: 'bar' }],
prune: true,
repullImageAndRedeploy: true,
},
});
expect(updateGitStack).not.toHaveBeenCalled();
});
it('throws for an externally-managed compose container', async () => {
resolveMock.mockReturnValue({ kind: 'external' });
await expect(applyContainerUpdate(buildContext(), [])).rejects.toThrow(
EXTERNAL_STACK_UPDATE_ERROR
);
expect(recreateContainer).not.toHaveBeenCalled();
expect(updateStack).not.toHaveBeenCalled();
expect(updateGitStack).not.toHaveBeenCalled();
});
it('throws rather than bare-recreating when the resolved stack is missing', async () => {
// Guard: resolve routed to a stack, but no stack with that id is present.
resolveMock.mockReturnValue({ kind: 'stack', stackId: 999 });
await expect(applyContainerUpdate(buildContext(), [])).rejects.toThrow(
EXTERNAL_STACK_UPDATE_ERROR
);
expect(recreateContainer).not.toHaveBeenCalled();
expect(updateStack).not.toHaveBeenCalled();
expect(updateGitStack).not.toHaveBeenCalled();
});
});