// Container Routes Tests // Validates container lifecycle operations (start/stop/restart/update/delete/discover) const express = require('express'); const request = require('supertest'); // Build a test app with the containers route function buildApp(mockDeps) { const app = express(); app.use(express.json()); const { errorMiddleware } = require('../../error-handler'); const containersRouteFactory = require('../../routes/containers'); app.use('/api/containers', containersRouteFactory(mockDeps)); app.use(errorMiddleware); return app; } // Mock container factory function mockContainer(overrides = {}) { return { inspect: jest.fn().mockResolvedValue({ Id: 'abc123def456', Name: '/plex', Config: { Image: 'lscr.io/linuxserver/plex:latest', Env: ['TZ=America/New_York', 'PLEX_CLAIM='], ExposedPorts: { '32400/tcp': {} }, Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' } }, Image: 'sha256:abc123', HostConfig: { Binds: ['E:/dockerdata/plex:/config'], PortBindings: { '32400/tcp': [{ HostPort: '32400' }] }, RestartPolicy: { Name: 'unless-stopped' }, NetworkMode: 'bridge', ExtraHosts: [], Privileged: false, CapAdd: null, CapDrop: null, Devices: [], LogConfig: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '3' } }, Memory: 2147483648, // 2GB MemoryReservation: 1073741824, // 1GB NanoCpus: 2000000000, // 2 cores }, NetworkSettings: { Networks: { bridge: {} } } }), start: jest.fn().mockResolvedValue(), stop: jest.fn().mockResolvedValue(), restart: jest.fn().mockResolvedValue(), remove: jest.fn().mockResolvedValue(), update: jest.fn().mockResolvedValue(), logs: jest.fn().mockResolvedValue(Buffer.from('2026-04-05T10:00:00Z Plex server started')), ...overrides }; } function createMockDeps(containerInstance) { const container = containerInstance || mockContainer(); return { docker: { client: { getContainer: jest.fn().mockReturnValue(container), createContainer: jest.fn().mockResolvedValue({ start: jest.fn().mockResolvedValue(), inspect: jest.fn().mockResolvedValue({ Id: 'new123' }), remove: jest.fn().mockResolvedValue(), }), getImage: jest.fn().mockReturnValue({ inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:olddigest'] }) }), listContainers: jest.fn().mockResolvedValue([]), pruneImages: jest.fn().mockResolvedValue({ SpaceReclaimed: 0 }), }, pull: jest.fn().mockResolvedValue([]), }, log: { info: jest.fn(), error: jest.fn(), debug: jest.fn(), }, asyncHandler: (fn, name) => async (req, res, next) => { try { await fn(req, res, next); } catch (err) { next(err); } }, }; } describe('Container Routes — DashCaddy container lifecycle', () => { describe('POST /:id/start', () => { it('starts a stopped container', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/start'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.message).toContain('started'); }); it('returns 404 for missing container', async () => { const container = mockContainer(); const notFound = new Error('no such container'); notFound.statusCode = 404; container.inspect.mockRejectedValue(notFound); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app).post('/api/containers/missing123/start'); expect(res.status).toBe(404); }); }); describe('POST /:id/stop', () => { it('stops a running container', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/stop'); expect(res.status).toBe(200); expect(res.body.message).toContain('stopped'); }); }); describe('POST /:id/restart', () => { it('restarts a container', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/restart'); expect(res.status).toBe(200); expect(res.body.message).toContain('restarted'); }); }); describe('GET /:id/logs', () => { it('returns last 100 log lines', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/logs'); expect(res.status).toBe(200); expect(res.body.logs).toContain('Plex server started'); }); }); describe('PUT /:id/resources', () => { it('updates memory and CPU limits', async () => { const container = mockContainer(); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app) .put('/api/containers/abc123/resources') .send({ memory: 4096, cpus: 4 }); expect(res.status).toBe(200); expect(container.update).toHaveBeenCalledWith( expect.objectContaining({ Memory: 4096 * 1024 * 1024, NanoCpus: 4 * 1e9, }) ); }); it('sets 0 for unlimited', async () => { const container = mockContainer(); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app) .put('/api/containers/abc123/resources') .send({ memory: 0, cpus: 0 }); expect(res.status).toBe(200); expect(container.update).toHaveBeenCalledWith( expect.objectContaining({ Memory: 0, NanoCpus: 0, }) ); }); }); describe('GET /:id/resources', () => { it('returns current resource limits in human units', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/resources'); expect(res.status).toBe(200); expect(res.body.memory).toBe(2048); // 2GB in MB expect(res.body.memoryReservation).toBe(1024); // 1GB in MB expect(res.body.cpus).toBe(2); // 2 cores }); }); describe('DELETE /:id', () => { it('force-removes a container', async () => { const container = mockContainer(); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app).delete('/api/containers/abc123'); expect(res.status).toBe(200); expect(container.remove).toHaveBeenCalledWith({ force: true }); }); }); describe('GET /discover', () => { it('returns only sami.managed containers', async () => { const deps = createMockDeps(); deps.docker.client.listContainers.mockResolvedValue([ { Id: 'abc123', Names: ['/plex'], Image: 'linuxserver/plex', State: 'running', Status: 'Up 3 days', Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' }, Ports: [{ PrivatePort: 32400, PublicPort: 32400 }] }, { Id: 'xyz789', Names: ['/random-container'], Image: 'nginx', State: 'running', Status: 'Up 1 hour', Labels: {}, Ports: [{ PrivatePort: 80, PublicPort: 80 }] } ]); const app = buildApp(deps); const res = await request(app).get('/api/containers/discover'); expect(res.status).toBe(200); expect(res.body.containers).toHaveLength(1); expect(res.body.containers[0].appTemplate).toBe('plex'); }); it('returns empty array when no managed containers', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).get('/api/containers/discover'); expect(res.body.containers).toEqual([]); }); }); describe('POST /:id/update — error and edge cases', () => { it('preserves custom network mode (non-bridge/host/none)', async () => { const container = mockContainer(); container.inspect.mockResolvedValue({ Id: 'abc123', Name: '/plex', Config: { Image: 'plex:latest', Env: [], ExposedPorts: {}, Labels: {} }, Image: 'sha256:abc', HostConfig: { Binds: [], PortBindings: {}, RestartPolicy: { Name: 'unless-stopped' }, NetworkMode: 'my-custom-network', ExtraHosts: [], Privileged: false, CapAdd: null, CapDrop: null, Devices: [] }, NetworkSettings: { Networks: { 'my-custom-network': { IPAddress: '172.20.0.5' } } } }); const newContainer = { start: jest.fn().mockResolvedValue(), inspect: jest.fn().mockResolvedValue({ Id: 'new123' }) }; const deps = createMockDeps(container); deps.docker.client.createContainer.mockResolvedValue(newContainer); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBe(200); const createCall = deps.docker.client.createContainer.mock.calls[0][0]; expect(createCall.NetworkingConfig.EndpointsConfig['my-custom-network']) .toEqual({ IPAddress: '172.20.0.5' }); }); it('cleans up failed new container when start fails', async () => { const container = mockContainer(); const newContainer = { start: jest.fn().mockRejectedValue(new Error('port already allocated')), remove: jest.fn().mockResolvedValue() }; const deps = createMockDeps(container); deps.docker.client.createContainer.mockResolvedValue(newContainer); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBeGreaterThanOrEqual(500); expect(newContainer.remove).toHaveBeenCalledWith({ force: true }); }); it('handles new container remove cleanup failure gracefully', async () => { const container = mockContainer(); const newContainer = { start: jest.fn().mockRejectedValue(new Error('start failed')), remove: jest.fn().mockRejectedValue(new Error('already gone')) }; const deps = createMockDeps(container); deps.docker.client.createContainer.mockResolvedValue(newContainer); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBeGreaterThanOrEqual(500); }); it('logs space reclaimed when image prune frees disk', async () => { const container = mockContainer(); const deps = createMockDeps(container); deps.docker.client.pruneImages.mockResolvedValue({ SpaceReclaimed: 50 * 1024 * 1024 }); // 50MB const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBe(200); expect(deps.log.info).toHaveBeenCalledWith( 'docker', 'Pruned dangling images after update', expect.objectContaining({ spaceReclaimed: '50MB' }) ); }); it('continues if image prune fails', async () => { const container = mockContainer(); const deps = createMockDeps(container); deps.docker.client.pruneImages.mockRejectedValue(new Error('prune failed')); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBe(200); expect(deps.log.debug).toHaveBeenCalledWith( 'docker', 'Image prune after update failed', expect.any(Object) ); }); it('ignores already-stopped error when stopping container', async () => { const container = mockContainer(); container.stop.mockRejectedValue(new Error('container already stopped')); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBe(200); }); }); describe('GET /:id/check-update', () => { it('reports no updates when local and new digests match', async () => { const container = mockContainer(); const deps = createMockDeps(container); deps.docker.client.getImage.mockReturnValue({ inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:samedigest'] }) }); deps.docker.pull.mockResolvedValue([]); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/check-update'); expect(res.status).toBe(200); expect(res.body.updateAvailable).toBe(false); }); it('reports update available when downloads occur', async () => { const container = mockContainer(); const deps = createMockDeps(container); deps.docker.pull.mockResolvedValue([ { status: 'Downloading', id: 'layer1' }, { status: 'Download complete', id: 'layer2' } ]); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/check-update'); expect(res.body.updateAvailable).toBe(true); }); it('reports update available when digests differ', async () => { const container = mockContainer(); const deps = createMockDeps(container); let callCount = 0; deps.docker.client.getImage.mockImplementation(() => { callCount++; return { inspect: jest.fn().mockResolvedValue({ RepoDigests: callCount === 1 ? ['sha256:olddigest'] : ['sha256:newdigest'] }) }; }); deps.docker.pull.mockResolvedValue([]); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/check-update'); expect(res.body.updateAvailable).toBe(true); }); it('returns false when pull throws (registry unreachable)', async () => { const container = mockContainer(); const deps = createMockDeps(container); deps.docker.pull.mockRejectedValue(new Error('registry timeout')); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/check-update'); expect(res.status).toBe(200); expect(res.body.updateAvailable).toBe(false); }); it('handles missing local repo digests gracefully', async () => { const container = mockContainer(); const deps = createMockDeps(container); deps.docker.client.getImage.mockReturnValue({ inspect: jest.fn().mockResolvedValue({ RepoDigests: null }) }); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/check-update'); expect(res.status).toBe(200); expect(res.body.currentDigest).toBeNull(); }); }); describe('getVerifiedContainer error paths', () => { it('returns 404 when error message includes "no such container"', async () => { const container = mockContainer(); container.inspect.mockRejectedValue(new Error('Error: no such container: missing')); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app).post('/api/containers/missing/start'); expect(res.status).toBe(404); }); it('rethrows non-404 errors from inspect', async () => { const container = mockContainer(); container.inspect.mockRejectedValue(new Error('docker daemon not running')); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/start'); expect(res.status).toBeGreaterThanOrEqual(500); }); }); describe('PUT /:id/resources — partial updates', () => { it('updates only memory when cpus omitted', async () => { const container = mockContainer(); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app) .put('/api/containers/abc123/resources') .send({ memory: 2048 }); expect(res.status).toBe(200); const call = container.update.mock.calls[0][0]; expect(call.Memory).toBe(2048 * 1024 * 1024); expect(call.NanoCpus).toBeUndefined(); }); it('updates only cpus when memory omitted', async () => { const container = mockContainer(); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app) .put('/api/containers/abc123/resources') .send({ cpus: 1.5 }); expect(res.status).toBe(200); const call = container.update.mock.calls[0][0]; expect(call.NanoCpus).toBe(1.5 * 1e9); expect(call.Memory).toBeUndefined(); }); }); describe('GET /:id/resources — zero values', () => { it('returns 0 when no limits set', async () => { const container = mockContainer(); container.inspect.mockResolvedValue({ Id: 'abc', Name: '/test', Config: { Image: 'test:latest' }, HostConfig: { Memory: 0, MemoryReservation: 0, NanoCpus: 0 } }); const deps = createMockDeps(container); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/resources'); expect(res.body.memory).toBe(0); expect(res.body.memoryReservation).toBe(0); expect(res.body.cpus).toBe(0); }); }); describe('GET /discover — pagination', () => { it('paginates results when paginate query params provided', async () => { const containers = Array.from({ length: 25 }, (_, i) => ({ Id: `id${i}`, Names: [`/svc${i}`], Image: 'test:latest', State: 'running', Status: 'Up', Labels: { 'sami.managed': 'true', 'sami.app': 'test', 'sami.subdomain': `svc${i}` }, Ports: [] })); const deps = createMockDeps(); deps.docker.client.listContainers.mockResolvedValue(containers); const app = buildApp(deps); const res = await request(app).get('/api/containers/discover?page=1&limit=10'); expect(res.status).toBe(200); expect(res.body.containers.length).toBeLessThanOrEqual(10); }); }); describe('DashCaddy-specific scenarios', () => { it('Plex container: verifies correct resource read (2GB, 2 cores)', async () => { const deps = createMockDeps(); const app = buildApp(deps); const res = await request(app).get('/api/containers/abc123/resources'); expect(res.body.memory).toBe(2048); expect(res.body.cpus).toBe(2); }); it('container update: preserves Env, PortBindings, RestartPolicy', async () => { const container = mockContainer(); const newContainer = { start: jest.fn().mockResolvedValue(), inspect: jest.fn().mockResolvedValue({ Id: 'new456' }), remove: jest.fn().mockResolvedValue(), }; const deps = createMockDeps(container); deps.docker.client.createContainer.mockResolvedValue(newContainer); const app = buildApp(deps); const res = await request(app).post('/api/containers/abc123/update'); expect(res.status).toBe(200); const createCall = deps.docker.client.createContainer.mock.calls[0][0]; expect(createCall.Env).toContain('TZ=America/New_York'); expect(createCall.HostConfig.PortBindings['32400/tcp']).toEqual([{ HostPort: '32400' }]); expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: 'unless-stopped' }); }); }); });