/** * Unit tests for TemplateService: compose YAML generation, * env string generation, conditional env_file, and cache clearing. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import YAML from 'yaml'; import { TemplateService } from '../services/TemplateService'; // Parse the generated compose and return the single service block keyed by // `name`. Asserting on the parsed structure (rather than exact text lines) // keeps the tests stable across the YAML emitter's quoting choices. function serviceOf(yaml: string, name = 'app'): Record { const parsed = YAML.parse(yaml) as { services?: Record> }; expect(parsed.services).toBeDefined(); return parsed.services![name]; } describe('TemplateService', () => { let service: TemplateService; beforeEach(() => { service = new TemplateService(); }); // ─── generateComposeFromTemplate ───────────────────────────────────── describe('generateComposeFromTemplate', () => { it('generates minimal compose with just image and restart policy', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'nginx', description: 'Web server', image: 'nginx:latest', }, 'app')); expect(svc.image).toBe('nginx:latest'); expect(svc.restart).toBe('unless-stopped'); expect(svc.ports).toBeUndefined(); expect(svc.volumes).toBeUndefined(); expect(svc.env_file).toBeUndefined(); }); it('includes ports when template has port mappings', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'nginx', description: 'Web server', image: 'nginx:latest', ports: ['80:80', '443:443/tcp'], }, 'app')); expect(svc.ports).toEqual(['80:80', '443:443/tcp']); }); it('handles string volumes with host:container format', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: ['/host/data:/container/data'], }, 'app')); expect(svc.volumes).toEqual(['/host/data:/container/data']); }); it('handles string volumes with single path (named volume)', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: ['/data'], }, 'app')); expect(svc.volumes).toEqual(['/data']); }); it('handles object volumes with container and bind', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/config', bind: './config' }], }, 'app')); expect(svc.volumes).toEqual(['./config:/config']); }); it('generates bind path from container folder when bind is not specified', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/app/data' }], }, 'app')); expect(svc.volumes).toEqual(['./data:/app/data']); }); it('adds :ro suffix for readonly volumes', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/config', bind: './config', readonly: true }], }, 'app')); expect(svc.volumes).toEqual(['./config:/config:ro']); }); it('generates valid compose for fail2ban-shaped template volumes', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'fail2ban', description: 'Ban IPs', image: 'lscr.io/linuxserver/fail2ban:latest', volumes: [ { container: '/config', bind: './config' }, { container: '/var/log', bind: '/var/log', readonly: true }, ], }, 'fail2ban'), 'fail2ban'); expect(svc.volumes).toEqual(['./config:/config', '/var/log:/var/log:ro']); }); it('repairs stale LSIO volume paths that still embed :ro in container and bind', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'fail2ban', description: 'Ban IPs', image: 'lscr.io/linuxserver/fail2ban:latest', volumes: [{ container: '/var/log:ro', bind: './log:ro' }], }, 'fail2ban'), 'fail2ban'); expect(svc.volumes).toEqual(['./log:/var/log:ro']); }); it('includes env_file only when env vars are present', () => { const withEnv = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', env: [{ name: 'TZ', default: 'UTC' }], }, 'app')); const withoutEnv = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', env: [], }, 'app')); expect(withEnv.env_file).toEqual(['.env']); expect(withoutEnv.env_file).toBeUndefined(); }); it('does not include env_file when env is undefined', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', }, 'app')); expect(svc.env_file).toBeUndefined(); }); it('handles string volumes with options (e.g., host:container:ro)', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: ['/host/config:/config:ro'], }, 'app')); expect(svc.volumes).toEqual(['/host/config:/config:ro']); }); it('skips object volumes without container path', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '' }], }, 'app')); // Empty container is skipped, leaving no volume entries; the key is // omitted entirely rather than emitted as an empty list. expect(svc.volumes).toBeUndefined(); }); it('uses the supplied service name as the compose service key', () => { const yaml = service.generateComposeFromTemplate({ title: 'Plex', description: 'Media server', image: 'plex:latest', }, 'plex'); const parsed = YAML.parse(yaml) as { services: Record }; expect(Object.keys(parsed.services)).toEqual(['plex']); }); it('escapes values that would break hand-built YAML (round-trips intact)', () => { // Registry values containing YAML structural characters (newlines, // indentation, anchors, flow collectors, colon-space) must survive a // serialize/parse round-trip rather than injecting sibling keys. const hostileImage = 'evil:latest\n privileged: true'; const yaml = service.generateComposeFromTemplate({ title: 'x', description: 'Test', image: hostileImage, ports: ['*anchor', '8080:80\n cap_add: [ALL]'], volumes: ['/h: weird:/c # not-a-comment'], }, 'app'); const svc = serviceOf(yaml); expect(svc.image).toBe(hostileImage); expect(svc.ports).toEqual(['*anchor', '8080:80\n cap_add: [ALL]']); expect(svc.volumes).toEqual(['/h: weird:/c # not-a-comment']); // None of the injected sibling keys may escape into the service block. expect(svc).not.toHaveProperty('privileged'); expect(svc).not.toHaveProperty('cap_add'); }); it('keeps later valid volumes when an earlier entry is skipped', () => { const svc = serviceOf(service.generateComposeFromTemplate({ title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '' }, { container: '/config', bind: './config' }], }, 'app')); expect(svc.volumes).toEqual(['./config:/config']); }); }); // ─── generateEnvString ─────────────────────────────────────────────── describe('generateEnvString', () => { it('converts key-value pairs to env file format', () => { const result = service.generateEnvString({ TZ: 'America/New_York', PUID: '1000', PGID: '1000', }); expect(result).toBe('TZ=America/New_York\nPUID=1000\nPGID=1000'); }); it('returns empty string for empty object', () => { expect(service.generateEnvString({})).toBe(''); }); it('handles values with special characters', () => { const result = service.generateEnvString({ PASSWORD: 'p@ss=word!', URL: 'http://localhost:1852', }); expect(result).toContain('PASSWORD=p@ss=word!'); expect(result).toContain('URL=http://localhost:1852'); }); }); // ─── clearCache ────────────────────────────────────────────────────── describe('clearCache', () => { it('calls CacheService.invalidate with the correct key', () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); // clearCache should not throw even when cache is empty expect(() => service.clearCache()).not.toThrow(); expect(consoleSpy).toHaveBeenCalledWith('[Templates] Cache invalidated'); consoleSpy.mockRestore(); }); }); });