// 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'); }); }); });