import test from 'brittle' import { parseComposeFile, validateComposeFile, removeComposeStack, } from '../server/utils/composeManager.js' /** * Minimal dockerode-shaped mock for removeComposeStack. * @param {{ containers?: object[], networks?: object[] }} seed */ function mockDocker(seed = {}) { const containers = [...(seed.containers || [])] const networks = [...(seed.networks || [])] const removedNetworks = [] const removedContainers = [] return { removedNetworks, removedContainers, listContainers: async () => containers, listNetworks: async () => networks, getContainer: (id) => ({ stop: async () => {}, remove: async () => { removedContainers.push(id) const i = containers.findIndex((c) => c.Id === id) if (i >= 0) containers.splice(i, 1) }, }), getNetwork: (id) => ({ remove: async () => { const n = networks.find((x) => x.Id === id) if (!n) throw new Error(`network ${id} not found`) removedNetworks.push({ id, name: n.Name }) const i = networks.findIndex((x) => x.Id === id) if (i >= 0) networks.splice(i, 1) }, }), } } const SAMPLE = ` version: "3.8" services: web: image: nginx:alpine ports: - "8080:80" environment: FOO: bar BAZ: "1" volumes: - ./data:/data:ro depends_on: - db restart: unless-stopped db: image: postgres:16 environment: - POSTGRES_PASSWORD=secret networks: - backend networks: backend: volumes: pgdata: ` test('parseComposeFile uses js-yaml and normalizes services', (t) => { const parsed = parseComposeFile(SAMPLE) t.is(parsed.version, '3.8') t.ok(parsed.services.web) t.ok(parsed.services.db) t.is(parsed.services.web.image, 'nginx:alpine') t.alike(parsed.services.web.ports, ['8080:80']) t.ok(parsed.services.web.environment.includes('FOO=bar')) t.ok(parsed.services.web.depends_on.includes('db')) t.is(parsed.services.web.restart, 'unless-stopped') t.ok(parsed.services.db.environment.some((e) => e.includes('POSTGRES_PASSWORD'))) t.ok(Object.prototype.hasOwnProperty.call(parsed.networks, 'backend')) }) test('parseComposeFile rejects invalid YAML', (t) => { try { parseComposeFile('services: [') t.fail('should throw') } catch (err) { t.ok(/Invalid compose YAML/i.test(err.message)) } }) test('parseComposeFile requires services mapping', (t) => { try { parseComposeFile('version: "3"\n') t.fail('should throw') } catch (err) { t.ok(/services/i.test(err.message)) } }) test('validateComposeFile requires image or build', (t) => { try { validateComposeFile(` services: bad: command: echo hi `) t.fail('should throw') } catch (err) { t.ok(/image or build/i.test(err.message)) } }) test('validateComposeFile accepts build-only service', (t) => { const parsed = validateComposeFile(` services: app: build: . `) t.ok(parsed.services.app.build) }) test('removeComposeStack removes project networks after containers', async (t) => { const docker = mockDocker({ containers: [ { Id: 'c1', State: 'running', Labels: { 'com.docker.compose.project': 'mystack' }, }, ], networks: [ { Id: 'n1', Name: 'mystack_default', Labels: { 'com.docker.compose.project': 'mystack' }, }, { Id: 'n2', Name: 'other_default', Labels: { 'com.docker.compose.project': 'other' }, }, { Id: 'bridge', Name: 'bridge', Labels: {}, }, ], }) const result = await removeComposeStack(docker, 'mystack') t.is(result.removed, 1) t.is(result.networksRemoved, 1) t.alike(docker.removedContainers, ['c1']) t.is(docker.removedNetworks.length, 1) t.is(docker.removedNetworks[0].name, 'mystack_default') }) test('removeComposeStack cleans orphaned project networks with no containers', async (t) => { const docker = mockDocker({ containers: [], networks: [ { Id: 'n1', Name: 'orphan_backend', Labels: { 'com.docker.compose.project': 'orphan' }, }, ], }) const result = await removeComposeStack(docker, 'orphan') t.is(result.removed, 0) t.is(result.networksRemoved, 1) t.is(docker.removedNetworks[0].name, 'orphan_backend') }) test('removeComposeStack reports already removed when nothing matches', async (t) => { const docker = mockDocker({ containers: [], networks: [{ Id: 'bridge', Name: 'bridge', Labels: {} }], }) const result = await removeComposeStack(docker, 'missing') t.is(result.removed, 0) t.is(result.networksRemoved, 0) t.ok(/not found|already removed/i.test(result.message)) })