import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock child_process for Docker discovery vi.mock('node:child_process', () => ({ execFile: vi.fn() })); vi.mock('node:util', () => ({ promisify: (fn: unknown) => fn })); // Mock appService for discoverAll vi.mock('../appService.js', () => ({ findAll: vi.fn() })); import { execFile } from 'node:child_process'; import { findAll as findAllApps } from '../appService.js'; import { discoverDocker, discoverTraefik, discoverAll } from '../discoveryService.js'; const mockExecFile = execFile as unknown as ReturnType; const mockFindAllApps = findAllApps as ReturnType; describe('discoveryService', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('discoverDocker', () => { it('returns services from running Docker containers', async () => { const containers = [ { Id: 'abc123', Names: ['/gitea'], Image: 'gitea/gitea:latest', Ports: [{ IP: '0.0.0.0', PrivatePort: 3000, PublicPort: 3000, Type: 'tcp' }], Labels: { 'org.opencontainers.image.description': 'Self-hosted Git' }, State: 'running' } ]; mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) }); const result = await discoverDocker('/var/run/docker.sock'); expect(result.services).toHaveLength(1); expect(result.services[0].name).toBe('gitea'); expect(result.services[0].url).toBe('http://localhost:3000'); expect(result.services[0].source).toBe('docker'); expect(result.error).toBeUndefined(); }); it('extracts URL from Traefik labels in Docker containers', async () => { const containers = [ { Id: 'def456', Names: ['/myapp'], Image: 'myapp:latest', Ports: [], Labels: { 'traefik.http.routers.myapp.rule': 'Host(`myapp.example.com`)', 'traefik.http.routers.myapp.entrypoints': 'websecure' }, State: 'running' } ]; mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) }); const result = await discoverDocker('/var/run/docker.sock'); expect(result.services).toHaveLength(1); expect(result.services[0].url).toBe('https://myapp.example.com'); }); it('skips containers without accessible URLs', async () => { const containers = [ { Id: 'nop123', Names: ['/background-worker'], Image: 'worker:latest', Ports: [], Labels: {}, State: 'running' } ]; mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) }); const result = await discoverDocker('/var/run/docker.sock'); expect(result.services).toHaveLength(0); }); it('returns error when Docker socket is inaccessible', async () => { mockExecFile.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock')); const result = await discoverDocker('/var/run/docker.sock'); expect(result.services).toHaveLength(0); expect(result.error).toContain('ENOENT'); }); }); describe('discoverTraefik', () => { // Build a Response-like object compatible with both raw fetch tests and // the SafeResponse wrapper used by safeFetch. function mockResponse(body: unknown, ok: boolean, status: number) { const bodyString = JSON.stringify(body); const bodyBytes = new TextEncoder().encode(bodyString); const stream = new ReadableStream({ start(controller) { controller.enqueue(bodyBytes); controller.close(); } }); return { ok, status, headers: new Headers({ 'content-type': 'application/json' }), body: stream, json: () => Promise.resolve(body), text: () => Promise.resolve(bodyString) }; } it('returns services from Traefik routers', async () => { const routers = [ { name: 'myapp@docker', rule: 'Host(`myapp.example.com`)', service: 'myapp@docker', entryPoints: ['websecure'] } ]; const services = [ { name: 'myapp@docker', loadBalancer: { servers: [{ url: 'http://172.17.0.2:8080' }] } } ]; vi.stubGlobal( 'fetch', vi.fn((url: string) => { if (url.includes('/api/http/routers')) { return Promise.resolve(mockResponse(routers, true, 200)); } return Promise.resolve(mockResponse(services, true, 200)); }) ); const result = await discoverTraefik('http://traefik.local:8080'); expect(result.services).toHaveLength(1); expect(result.services[0].name).toBe('myapp'); expect(result.services[0].url).toBe('https://myapp.example.com'); expect(result.services[0].source).toBe('traefik'); vi.unstubAllGlobals(); }); it('returns error on Traefik API failure', async () => { vi.stubGlobal( 'fetch', vi.fn(() => Promise.resolve(mockResponse({}, false, 500))) ); const result = await discoverTraefik('http://traefik.local:8080'); expect(result.services).toHaveLength(0); expect(result.error).toContain('500'); vi.unstubAllGlobals(); }); it('returns error when fetch throws', async () => { vi.stubGlobal( 'fetch', vi.fn(() => Promise.reject(new Error('Network error'))) ); const result = await discoverTraefik('http://traefik.local:8080'); expect(result.services).toHaveLength(0); expect(result.error).toContain('Network error'); vi.unstubAllGlobals(); }); }); describe('discoverAll', () => { it('marks already-registered services', async () => { mockExecFile.mockResolvedValue({ stdout: JSON.stringify([ { Id: 'c1', Names: ['/gitea'], Image: 'gitea/gitea', Ports: [{ IP: '0.0.0.0', PrivatePort: 3000, PublicPort: 3000, Type: 'tcp' }], Labels: {}, State: 'running' } ]) }); mockFindAllApps.mockResolvedValue([ { id: 'a1', name: 'Gitea', url: 'http://localhost:3000' } ]); const result = await discoverAll({ dockerSocketPath: '/var/run/docker.sock' }); expect(result.services).toHaveLength(1); expect(result.services[0].alreadyRegistered).toBe(true); }); it('deduplicates by URL preferring Traefik', async () => { mockExecFile.mockResolvedValue({ stdout: JSON.stringify([ { Id: 'c1', Names: ['/app'], Image: 'app:latest', Ports: [], Labels: { 'traefik.http.routers.app.rule': 'Host(`app.example.com`)' }, State: 'running' } ]) }); vi.stubGlobal( 'fetch', vi.fn((url: string) => { if (url.includes('/api/http/routers')) { return Promise.resolve({ ok: true, json: () => Promise.resolve([ { name: 'app@docker', rule: 'Host(`app.example.com`)', service: 'app@docker', entryPoints: ['web'] } ]) }); } return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); }) ); mockFindAllApps.mockResolvedValue([]); const result = await discoverAll({ dockerSocketPath: '/var/run/docker.sock', traefikApiUrl: 'http://traefik.local:8080' }); // Should deduplicate: both Docker (via label) and Traefik discover http://app.example.com const urls = result.services.map((s) => s.url); const unique = new Set(urls); expect(urls.length).toBe(unique.size); vi.unstubAllGlobals(); }); it('returns empty when no sources configured', async () => { mockFindAllApps.mockResolvedValue([]); const result = await discoverAll({}); expect(result.services).toHaveLength(0); expect(result.errors).toHaveLength(0); }); }); });