Files
dashcaddy/dashcaddy-api/__tests__/update-manager.test.js
Sami ea5acfa9a2 test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core
infrastructure, API routes, and error handling. Final coverage: 86.73%
statements / 80.57% branches / 85.57% functions / 87.42% lines, all above
the 80% threshold enforced by jest.config.js.

Highlights:
- Unit tests for crypto-utils, credential-manager, auth-manager, csrf,
  input-validator, state-manager, health-checker, backup-manager,
  update-manager, resource-monitor, app-templates, platform-paths,
  port-lock-manager, errors, error-handler, pagination, url-resolver
- Route tests for health, services, and containers (supertest + mocked deps)
- Shared test-utils helper for mock factories and Express app builder
- npm scripts for CI: test:ci, test:unit, test:routes, test:security,
  test:changed, test:debug
- jest.config.js: expand coverage targets, add 80% threshold gate
- routes/services.js: import ValidationError and NotFoundError from errors
- .gitignore: exclude coverage/, *.bak, *.log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:36:46 -07:00

1081 lines
38 KiB
JavaScript

// Update Manager Tests
// Validates Docker image update detection, scheduling, rollback, and auto-update
const mockDockerInstance = {
listContainers: jest.fn(),
getContainer: jest.fn(),
getImage: jest.fn(),
pull: jest.fn(),
createContainer: jest.fn(),
modem: { followProgress: jest.fn() }
};
jest.mock('dockerode', () => jest.fn(() => mockDockerInstance));
jest.mock('fs');
jest.mock('https');
const fs = require('fs');
const https = require('https');
// Setup defaults BEFORE requiring singleton
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockReturnValue('{}');
fs.writeFileSync.mockReturnValue(undefined);
const updateManager = require('../update-manager');
// Helper to create a fake https request that responds with a given statusCode/headers/body
function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) {
return (options, cb) => {
const res = {
statusCode,
headers,
on: jest.fn((event, handler) => {
if (event === 'data' && body) handler(Buffer.from(body));
if (event === 'end') handler();
})
};
if (cb) setImmediate(() => cb(res));
return {
on: jest.fn(),
end: jest.fn(),
setTimeout: jest.fn(),
destroy: jest.fn()
};
};
}
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockReturnValue('{}');
fs.writeFileSync.mockReturnValue(undefined);
// Reset docker mocks
mockDockerInstance.listContainers.mockReset();
mockDockerInstance.getContainer.mockReset();
mockDockerInstance.getImage.mockReset();
mockDockerInstance.pull.mockReset();
mockDockerInstance.createContainer.mockReset();
mockDockerInstance.modem.followProgress.mockReset();
// Reset internal state
updateManager.history = [];
updateManager.config = { autoUpdate: {} };
updateManager.availableUpdates.clear();
updateManager.checking = false;
if (updateManager.checkInterval) {
clearInterval(updateManager.checkInterval);
updateManager.checkInterval = null;
}
if (updateManager.autoUpdateInterval) {
clearInterval(updateManager.autoUpdateInterval);
updateManager.autoUpdateInterval = null;
}
});
afterEach(() => {
updateManager.checking = false;
if (updateManager.checkInterval) { clearInterval(updateManager.checkInterval); updateManager.checkInterval = null; }
if (updateManager.autoUpdateInterval) { clearInterval(updateManager.autoUpdateInterval); updateManager.autoUpdateInterval = null; }
jest.useRealTimers();
});
describe('UpdateManager — Docker image update lifecycle', () => {
describe('start/stop lifecycle', () => {
it('starts update checking', () => {
updateManager.start();
expect(updateManager.checking).toBe(true);
});
it('ignores double start', () => {
updateManager.start();
updateManager.start();
expect(updateManager.checking).toBe(true);
});
it('stops checking and clears intervals', () => {
updateManager.start();
updateManager.stop();
expect(updateManager.checking).toBe(false);
expect(updateManager.checkInterval).toBeNull();
expect(updateManager.autoUpdateInterval).toBeNull();
});
it('ignores stop when not checking', () => {
updateManager.stop();
expect(updateManager.checking).toBe(false);
});
});
describe('extractTag', () => {
it('extracts tag from image:tag', () => {
expect(updateManager.extractTag('nginx:1.25')).toBe('1.25');
});
it('returns latest for untagged images', () => {
expect(updateManager.extractTag('nginx')).toBe('latest');
});
it('handles LinuxServer images', () => {
expect(updateManager.extractTag('lscr.io/linuxserver/plex:latest')).toBe('latest');
});
});
describe('parseAuthHeader', () => {
it('parses Docker Hub Bearer auth header', () => {
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
const result = updateManager.parseAuthHeader(header);
expect(result).toContain('https://auth.docker.io/token');
expect(result).toContain('service=registry.docker.io');
expect(result).toContain('scope=repository');
});
it('returns null for missing header', () => {
expect(updateManager.parseAuthHeader(null)).toBeNull();
});
it('returns null for non-Bearer header', () => {
expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull();
});
});
describe('extractPorts', () => {
it('extracts host port mappings from inspect data', () => {
const inspect = {
NetworkSettings: {
Ports: {
'32400/tcp': [{ HostIp: '0.0.0.0', HostPort: '32400' }],
'1900/udp': [{ HostIp: '0.0.0.0', HostPort: '1900' }]
}
}
};
const ports = updateManager.extractPorts(inspect);
expect(ports).toHaveLength(2);
expect(ports[0]).toEqual({ containerPort: '32400', hostPort: '32400', protocol: 'tcp' });
expect(ports[1]).toEqual({ containerPort: '1900', hostPort: '1900', protocol: 'udp' });
});
it('returns empty for no port bindings', () => {
const inspect = { NetworkSettings: { Ports: { '8080/tcp': null } } };
expect(updateManager.extractPorts(inspect)).toEqual([]);
});
it('returns empty for missing NetworkSettings', () => {
expect(updateManager.extractPorts({})).toEqual([]);
});
});
describe('history management', () => {
it('addToHistory appends and saves', () => {
updateManager.addToHistory({ id: 'update-1', status: 'success' });
expect(updateManager.history).toHaveLength(1);
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('caps history at 100 entries', () => {
for (let i = 0; i < 110; i++) {
updateManager.addToHistory({ id: `update-${i}` });
}
expect(updateManager.history).toHaveLength(100);
});
it('getHistory returns newest first', () => {
updateManager.addToHistory({ id: 'old' });
updateManager.addToHistory({ id: 'new' });
const history = updateManager.getHistory();
expect(history[0].id).toBe('new');
});
it('getHistory respects limit', () => {
for (let i = 0; i < 10; i++) {
updateManager.addToHistory({ id: `update-${i}` });
}
expect(updateManager.getHistory(3)).toHaveLength(3);
});
});
describe('getAvailableUpdates', () => {
it('returns empty array when no updates', () => {
expect(updateManager.getAvailableUpdates()).toEqual([]);
});
it('returns list of available updates', () => {
updateManager.availableUpdates.set('abc123', {
containerId: 'abc123',
containerName: 'plex',
imageName: 'linuxserver/plex:latest'
});
const updates = updateManager.getAvailableUpdates();
expect(updates).toHaveLength(1);
expect(updates[0].containerName).toBe('plex');
});
});
describe('scheduleUpdate', () => {
it('schedules future update', () => {
const futureTime = new Date(Date.now() + 60000).toISOString();
updateManager.scheduleUpdate('abc123', futureTime);
// No error thrown, timer set
});
it('rejects past time', () => {
const pastTime = new Date(Date.now() - 60000).toISOString();
expect(() => updateManager.scheduleUpdate('abc123', pastTime))
.toThrow('must be in the future');
});
});
describe('configureAutoUpdate', () => {
it('stores auto-update config', () => {
updateManager.configureAutoUpdate('abc123', {
schedule: 'daily',
maintenanceWindow: '02:00-04:00',
autoRollback: true
});
const config = updateManager.getAutoUpdateConfig();
expect(config['abc123'].enabled).toBe(true);
expect(config['abc123'].schedule).toBe('daily');
expect(config['abc123'].maintenanceWindow).toBe('02:00-04:00');
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('defaults schedule to weekly', () => {
updateManager.configureAutoUpdate('abc123', {});
expect(updateManager.getAutoUpdateConfig()['abc123'].schedule).toBe('weekly');
});
});
describe('persistence (loadConfig/saveConfig)', () => {
it('loadConfig returns saved config', () => {
const saved = { autoUpdate: { abc123: { enabled: true, schedule: 'daily' } } };
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(JSON.stringify(saved));
const config = updateManager.loadConfig();
expect(config.autoUpdate.abc123.schedule).toBe('daily');
});
it('loadConfig returns defaults on missing file', () => {
fs.existsSync.mockReturnValue(false);
const config = updateManager.loadConfig();
expect(config.autoUpdate).toEqual({});
});
it('loadConfig returns defaults on error', () => {
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockImplementation(() => { throw new Error('read error'); });
const config = updateManager.loadConfig();
expect(config.autoUpdate).toEqual({});
});
it('loadHistory returns empty array on missing file', () => {
fs.existsSync.mockReturnValue(false);
expect(updateManager.loadHistory()).toEqual([]);
});
it('saveConfig writes JSON', () => {
updateManager.config = { autoUpdate: { test: { enabled: true } } };
updateManager.saveConfig();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('test')
);
});
it('saveConfig handles write error', () => {
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
updateManager.saveConfig(); // should not throw
});
it('saveHistory handles write error', () => {
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
updateManager.saveHistory(); // should not throw
});
});
describe('formatChangelog', () => {
it('formats repo description and tags', () => {
const repoInfo = { description: 'Plex Media Server', pull_count: 1000000 };
const tags = [
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
{ name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
];
const result = updateManager.formatChangelog(repoInfo, tags, 'latest');
expect(result).toContain('Plex Media Server');
expect(result).toContain('Recent tags');
expect(result).toContain('latest');
expect(result).toContain('1,000,000');
});
it('returns fallback for empty data', () => {
const result = updateManager.formatChangelog(null, [], 'latest');
expect(result).toBe('No changelog available');
});
});
describe('verifyContainer', () => {
it('returns true when container is running (no health check)', async () => {
const container = {
inspect: jest.fn().mockResolvedValue({
State: { Running: true }
})
};
const result = await updateManager.verifyContainer(container, 5000);
expect(result).toBe(true);
});
it('returns true when container is healthy', async () => {
const container = {
inspect: jest.fn().mockResolvedValue({
State: { Running: true, Health: { Status: 'healthy' } }
})
};
const result = await updateManager.verifyContainer(container, 5000);
expect(result).toBe(true);
});
it('throws on inspect error', async () => {
const container = {
inspect: jest.fn().mockRejectedValue(new Error('gone'))
};
await expect(updateManager.verifyContainer(container, 1000))
.rejects.toThrow('Container verification failed');
});
});
describe('checkForUpdates', () => {
it('detects updates and stores them in availableUpdates', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'abc123', Names: ['/plex'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'lscr.io/linuxserver/plex:latest' },
Image: 'sha256:current12345678'
})
});
// Custom registry returns null (not Docker Hub)
jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newdigest98765');
await updateManager.checkForUpdates();
expect(updateManager.availableUpdates.has('abc123')).toBe(true);
const update = updateManager.availableUpdates.get('abc123');
expect(update.containerName).toBe('plex');
expect(update.imageName).toBe('lscr.io/linuxserver/plex:latest');
updateManager.getLatestImageDigest.mockRestore();
});
it('removes from availableUpdates when no update needed', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'abc123', Names: ['/plex'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'plex:latest' },
Image: 'sha256:samedigest'
})
});
jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:samedigest');
// Pre-populate with stale update
updateManager.availableUpdates.set('abc123', { stale: true });
await updateManager.checkForUpdates();
expect(updateManager.availableUpdates.has('abc123')).toBe(false);
updateManager.getLatestImageDigest.mockRestore();
});
it('handles error during container inspect gracefully', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'broken', Names: ['/broken'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockRejectedValue(new Error('inspect failed'))
});
// Should not throw
await expect(updateManager.checkForUpdates()).resolves.not.toThrow();
});
it('handles error during list containers gracefully', async () => {
mockDockerInstance.listContainers.mockRejectedValue(new Error('docker down'));
// Should not throw
await expect(updateManager.checkForUpdates()).resolves.not.toThrow();
});
it('emits update-available event when new update detected', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'newupdate', Names: ['/radarr'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'linuxserver/radarr:latest' },
Image: 'sha256:oldradarr'
})
});
jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newradarr');
const events = [];
updateManager.on('update-available', e => events.push(e));
await updateManager.checkForUpdates();
expect(events).toHaveLength(1);
expect(events[0].containerName).toBe('radarr');
updateManager.removeAllListeners();
updateManager.getLatestImageDigest.mockRestore();
});
});
describe('getLatestImageDigest', () => {
it('routes Docker Hub library images to getDockerHubDigest', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:digest123');
const result = await updateManager.getLatestImageDigest('nginx:latest');
expect(result).toBe('sha256:digest123');
expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('nginx', 'latest');
updateManager.getDockerHubDigest.mockRestore();
});
it('routes Docker Hub user images to getDockerHubDigest', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:abc');
const result = await updateManager.getLatestImageDigest('linuxserver/plex:latest');
expect(result).toBe('sha256:abc');
expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('linuxserver/plex', 'latest');
updateManager.getDockerHubDigest.mockRestore();
});
it('returns null for custom registries', async () => {
const result = await updateManager.getLatestImageDigest('lscr.io/linuxserver/plex:latest');
expect(result).toBeNull();
});
it('returns null on error', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockRejectedValue(new Error('boom'));
const result = await updateManager.getLatestImageDigest('nginx:latest');
expect(result).toBeNull();
updateManager.getDockerHubDigest.mockRestore();
});
it('defaults tag to latest when not specified', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:xyz');
await updateManager.getLatestImageDigest('redis');
expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('redis', 'latest');
updateManager.getDockerHubDigest.mockRestore();
});
});
describe('getDockerHubDigest (https)', () => {
it('returns digest from successful response', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:fromregistry' },
on: jest.fn()
}));
return { on: jest.fn(), end: jest.fn() };
});
const result = await updateManager.getDockerHubDigest('nginx', 'latest');
expect(result).toBe('sha256:fromregistry');
});
it('handles 401 with no auth header by rejecting', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 401,
headers: {},
on: jest.fn()
}));
return { on: jest.fn(), end: jest.fn() };
});
await expect(updateManager.getDockerHubDigest('nginx', 'latest')).rejects.toThrow();
});
it('rejects on https request error', async () => {
https.request.mockImplementation(() => {
const req = { on: jest.fn(), end: jest.fn() };
// Trigger error event asynchronously
setImmediate(() => {
const errorHandler = req.on.mock.calls.find(c => c[0] === 'error');
if (errorHandler) errorHandler[1](new Error('connection refused'));
});
return req;
});
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
.rejects.toThrow('connection refused');
});
it('normalizes library/ prefix for official images', async () => {
let capturedPath;
https.request.mockImplementation((options, cb) => {
capturedPath = options.path;
setImmediate(() => cb({
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:digest' },
on: jest.fn()
}));
return { on: jest.fn(), end: jest.fn() };
});
await updateManager.getDockerHubDigest('nginx', 'latest');
expect(capturedPath).toBe('/v2/library/nginx/manifests/latest');
});
});
describe('pullImage', () => {
it('pulls image and resolves with output', async () => {
mockDockerInstance.pull.mockImplementation((imageName, cb) => {
cb(null, { stream: 'fake' });
});
mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => {
cb(null, [{ status: 'Downloaded' }]);
});
const result = await updateManager.pullImage('nginx:latest');
expect(result).toEqual([{ status: 'Downloaded' }]);
});
it('rejects on pull error', async () => {
mockDockerInstance.pull.mockImplementation((imageName, cb) => {
cb(new Error('image not found'));
});
await expect(updateManager.pullImage('bogus:latest')).rejects.toThrow('image not found');
});
it('rejects on followProgress error', async () => {
mockDockerInstance.pull.mockImplementation((imageName, cb) => {
cb(null, { stream: 'fake' });
});
mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => {
cb(new Error('progress error'));
});
await expect(updateManager.pullImage('nginx:latest')).rejects.toThrow('progress error');
});
});
describe('verifyContainer with timeout', () => {
it('waits for container to start running', async () => {
let inspectCount = 0;
const container = {
inspect: jest.fn().mockImplementation(() => {
inspectCount++;
if (inspectCount < 2) return Promise.resolve({ State: { Running: false } });
return Promise.resolve({ State: { Running: true } });
})
};
jest.useRealTimers();
const result = await updateManager.verifyContainer(container, 5000);
expect(result).toBe(true);
expect(inspectCount).toBeGreaterThanOrEqual(2);
jest.useFakeTimers();
});
it('throws on timeout when container never starts', async () => {
const container = {
inspect: jest.fn().mockResolvedValue({ State: { Running: false } })
};
jest.useRealTimers();
await expect(updateManager.verifyContainer(container, 100))
.rejects.toThrow();
jest.useFakeTimers();
});
});
describe('rollbackUpdate', () => {
it('rolls back container from last successful backup', async () => {
const backup = {
containerId: 'old-id',
containerName: 'plex',
imageName: 'plex:latest',
config: { Image: 'plex:latest', Env: [] },
hostConfig: { Binds: [] }
};
updateManager.history.push({
containerId: 'old-id',
status: 'success',
backup,
timestamp: new Date().toISOString()
});
mockDockerInstance.getContainer.mockReturnValue({
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.createContainer.mockResolvedValue({
start: jest.fn().mockResolvedValue()
});
const events = [];
updateManager.on('rollback-complete', e => events.push(e));
const result = await updateManager.rollbackUpdate('old-id');
expect(result).toBe(true);
expect(events).toHaveLength(1);
expect(events[0].containerName).toBe('plex');
updateManager.removeAllListeners();
});
it('throws when no backup exists', async () => {
await expect(updateManager.rollbackUpdate('no-backup-id'))
.rejects.toThrow('No backup found for rollback');
});
it('continues rollback even if old container removal fails', async () => {
const backup = {
containerName: 'plex',
imageName: 'plex:latest',
config: { Image: 'plex:latest' },
hostConfig: {}
};
updateManager.history.push({
containerId: 'gone-id',
status: 'success',
backup,
timestamp: new Date().toISOString()
});
mockDockerInstance.getContainer.mockReturnValue({
stop: jest.fn().mockRejectedValue(new Error('not found')),
remove: jest.fn().mockRejectedValue(new Error('not found'))
});
mockDockerInstance.createContainer.mockResolvedValue({
start: jest.fn().mockResolvedValue()
});
const result = await updateManager.rollbackUpdate('gone-id');
expect(result).toBe(true);
});
});
describe('updateContainer', () => {
it('completes full update pipeline successfully', async () => {
const inspectData = {
Config: { Image: 'plex:latest', Env: ['TZ=UTC'] },
HostConfig: { Binds: [] },
NetworkSettings: { Ports: {} },
Image: 'sha256:oldid',
Name: '/plex'
};
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue(inspectData),
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: ['plex@sha256:olddigest'] }),
remove: jest.fn().mockResolvedValue()
});
const newContainer = {
id: 'new-container-id',
inspect: jest.fn().mockResolvedValue({
State: { Running: true },
Image: 'sha256:newid',
NetworkSettings: { Ports: {} }
}),
start: jest.fn().mockResolvedValue()
};
mockDockerInstance.createContainer.mockResolvedValue(newContainer);
// Mock pullImage and verifyContainerExtended to skip their internals
jest.spyOn(updateManager, 'pullImage').mockResolvedValue([{ status: 'Downloaded' }]);
jest.spyOn(updateManager, 'verifyContainerExtended').mockResolvedValue(true);
const events = [];
updateManager.on('update-start', e => events.push({ type: 'start', ...e }));
updateManager.on('update-complete', e => events.push({ type: 'complete', ...e }));
const result = await updateManager.updateContainer('container-id');
expect(result.status).toBe('success');
expect(result.containerName).toBe('plex');
expect(events).toHaveLength(2);
updateManager.removeAllListeners();
updateManager.pullImage.mockRestore();
updateManager.verifyContainerExtended.mockRestore();
});
it('attempts rollback on failure', async () => {
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'plex:latest' },
HostConfig: {},
NetworkSettings: {},
Image: 'sha256:old',
Name: '/plex'
}),
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: [] })
});
jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed'));
jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true);
await expect(updateManager.updateContainer('fail-id')).rejects.toThrow('pull failed');
expect(updateManager.rollbackUpdate).toHaveBeenCalledWith('fail-id');
expect(updateManager.history[0].status).toBe('failed');
updateManager.pullImage.mockRestore();
updateManager.rollbackUpdate.mockRestore();
});
it('skips rollback when autoRollback is false', async () => {
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'plex:latest' },
HostConfig: {},
NetworkSettings: {},
Image: 'sha256:old',
Name: '/plex'
}),
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: [] })
});
jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed'));
jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true);
await expect(updateManager.updateContainer('id', { autoRollback: false }))
.rejects.toThrow('pull failed');
expect(updateManager.rollbackUpdate).not.toHaveBeenCalled();
updateManager.pullImage.mockRestore();
updateManager.rollbackUpdate.mockRestore();
});
});
describe('fetchDockerHubRepo / fetchDockerHubTags', () => {
it('fetchDockerHubRepo returns parsed response on 200', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') handler(Buffer.from(JSON.stringify({
description: 'Plex Media Server',
pull_count: 1000000,
star_count: 500
})));
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubRepo('linuxserver/plex', false);
expect(result.description).toBe('Plex Media Server');
expect(result.pull_count).toBe(1000000);
});
it('fetchDockerHubRepo returns null on 404', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 404,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubRepo('nonexistent', false);
expect(result).toBeNull();
});
it('fetchDockerHubRepo returns null on https error', async () => {
https.request.mockImplementation(() => {
const req = { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
setImmediate(() => {
const errCall = req.on.mock.calls.find(c => c[0] === 'error');
if (errCall) errCall[1](new Error('connection refused'));
});
return req;
});
const result = await updateManager.fetchDockerHubRepo('nginx', true);
expect(result).toBeNull();
});
it('fetchDockerHubTags returns results array on 200', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') handler(Buffer.from(JSON.stringify({
results: [
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
{ name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
]
})));
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubTags('linuxserver/plex', false);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('latest');
});
it('fetchDockerHubTags returns empty on non-200', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 500,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubTags('nginx', true);
expect(result).toEqual([]);
});
});
describe('getChangelog', () => {
it('returns changelog with repo info and tags', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({
description: 'Nginx web server',
pull_count: 5000000,
star_count: 1000,
last_updated: '2026-04-01T00:00:00Z'
});
jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z', digest: 'sha256:abcdef123456789', full_size: 1024 },
{ name: '1.25', last_pushed: '2026-03-15T00:00:00Z', digest: 'sha256:fedcba987654321', full_size: 2048 }
]);
const result = await updateManager.getChangelog('nginx:latest');
expect(result.imageName).toBe('nginx:latest');
expect(result.currentTag).toBe('latest');
expect(result.repository.description).toBe('Nginx web server');
expect(result.repository.pullCount).toBe(5000000);
expect(result.tags).toHaveLength(2);
expect(result.urls.dockerHub).toContain('hub.docker.com');
expect(result.changelog).toContain('Nginx web server');
updateManager.fetchDockerHubRepo.mockRestore();
updateManager.fetchDockerHubTags.mockRestore();
});
it('handles user images (namespace/repo format)', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({
description: 'Plex',
pull_count: 1000,
star_count: 50
});
jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]);
const result = await updateManager.getChangelog('linuxserver/plex:latest');
expect(result.repository.name).toBe('linuxserver/plex');
expect(result.urls.dockerHub).toContain('linuxserver/plex');
updateManager.fetchDockerHubRepo.mockRestore();
updateManager.fetchDockerHubTags.mockRestore();
});
it('returns fallback on fetch error', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockRejectedValue(new Error('network'));
const result = await updateManager.getChangelog('nginx:latest');
expect(result.imageName).toBe('nginx:latest');
expect(result.error).toBe('network');
expect(result.changelog).toContain('Unable to fetch');
updateManager.fetchDockerHubRepo.mockRestore();
});
it('defaults tag to latest when not specified', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({});
jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]);
const result = await updateManager.getChangelog('redis');
expect(result.currentTag).toBe('latest');
updateManager.fetchDockerHubRepo.mockRestore();
updateManager.fetchDockerHubTags.mockRestore();
});
});
describe('runAutoUpdates', () => {
it('skips containers not enabled', async () => {
updateManager.config.autoUpdate = {
'cont-1': { enabled: false, schedule: 'daily' }
};
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('skips outside maintenance window', async () => {
// Force time to be 10am (outside 2-4am default window)
jest.setSystemTime(new Date('2026-04-06T10:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily' }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('skips containers without available updates', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00')); // Inside default 2-4am window
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily' }
};
// No update in availableUpdates
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('runs update when in custom maintenance window', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00', autoRollback: true }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'plex' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({ status: 'success' });
const events = [];
updateManager.on('auto-update-complete', e => events.push(e));
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).toHaveBeenCalledWith('cont-1', expect.any(Object));
expect(events).toHaveLength(1);
updateManager.removeAllListeners();
updateManager.updateContainer.mockRestore();
});
it('emits auto-update-failed on update error', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00' }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'plex' });
jest.spyOn(updateManager, 'updateContainer').mockRejectedValue(new Error('update failed'));
const events = [];
updateManager.on('auto-update-failed', e => events.push(e));
await updateManager.runAutoUpdates();
expect(events).toHaveLength(1);
expect(events[0].error).toBe('update failed');
updateManager.removeAllListeners();
updateManager.updateContainer.mockRestore();
});
it('skips weekly schedule when not Sunday', async () => {
// Monday April 6 2026
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'weekly', maintenanceWindow: '02:00-05:00' }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('skips when already ran today', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': {
enabled: true,
schedule: 'daily',
maintenanceWindow: '02:00-05:00',
lastAutoUpdate: new Date('2026-04-06T02:30:00').toISOString()
}
};
updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
});
describe('DashCaddy scenarios', () => {
it('Radarr auto-update: weekly at 3AM', () => {
updateManager.configureAutoUpdate('radarr-id', {
schedule: 'weekly',
maintenanceWindow: '02:00-05:00',
autoRollback: true
});
const config = updateManager.getAutoUpdateConfig()['radarr-id'];
expect(config.schedule).toBe('weekly');
expect(config.maintenanceWindow).toBe('02:00-05:00');
expect(config.autoRollback).toBe(true);
});
it('tracks update for Plex container', () => {
updateManager.availableUpdates.set('plex-id', {
containerId: 'plex-id',
containerName: 'plex',
imageName: 'lscr.io/linuxserver/plex:latest',
currentDigest: 'sha256:abc12',
latestDigest: 'sha256:def34',
detectedAt: new Date().toISOString()
});
const updates = updateManager.getAvailableUpdates();
expect(updates[0].imageName).toBe('lscr.io/linuxserver/plex:latest');
});
});
});