/** * 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 { TemplateService, Template } from '../services/TemplateService'; describe('TemplateService', () => { let service: TemplateService; beforeEach(() => { service = new TemplateService(); }); // ─── generateComposeFromTemplate ───────────────────────────────────── describe('generateComposeFromTemplate', () => { it('generates minimal compose with just image and restart policy', () => { const template: Template = { title: 'nginx', description: 'Web server', image: 'nginx:latest', }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('image: nginx:latest'); expect(yaml).toContain('restart: unless-stopped'); expect(yaml).not.toContain('ports:'); expect(yaml).not.toContain('volumes:'); expect(yaml).not.toContain('env_file:'); }); it('includes ports when template has port mappings', () => { const template: Template = { title: 'nginx', description: 'Web server', image: 'nginx:latest', ports: ['80:80', '443:443/tcp'], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('ports:'); expect(yaml).toContain('"80:80"'); expect(yaml).toContain('"443:443/tcp"'); }); it('handles string volumes with host:container format', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: ['/host/data:/container/data'], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('volumes:'); expect(yaml).toContain('/host/data:/container/data'); }); it('handles string volumes with single path (named volume)', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: ['/data'], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('- /data'); }); it('handles object volumes with container and bind', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/config', bind: './config' }], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('./config:/config'); }); it('generates bind path from container folder when bind is not specified', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/app/data' }], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('./data:/app/data'); }); it('adds :ro suffix for readonly volumes', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '/config', bind: './config', readonly: true }], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('./config:/config:ro'); }); it('includes env_file only when env vars are present', () => { const withEnv: Template = { title: 'app', description: 'Test', image: 'test:latest', env: [{ name: 'TZ', default: 'UTC' }], }; const withoutEnv: Template = { title: 'app', description: 'Test', image: 'test:latest', env: [], }; expect(service.generateComposeFromTemplate(withEnv, 'app')).toContain('env_file:'); expect(service.generateComposeFromTemplate(withoutEnv, 'app')).not.toContain('env_file:'); }); it('does not include env_file when env is undefined', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', }; expect(service.generateComposeFromTemplate(template, 'app')).not.toContain('env_file:'); }); it('handles string volumes with options (e.g., host:container:ro)', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: ['/host/config:/config:ro'], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toContain('/host/config:/config:ro'); }); it('skips object volumes without container path', () => { const template: Template = { title: 'app', description: 'Test', image: 'test:latest', volumes: [{ container: '' }], }; const yaml = service.generateComposeFromTemplate(template, 'app'); // Empty container means `continue` is hit, no volume line emitted expect(yaml).toContain('volumes:'); // The volume header is added but no actual volume entry const volumeLines = yaml.split('\n').filter(l => l.trim().startsWith('- ')); expect(volumeLines).toHaveLength(0); }); it('produces valid YAML structure starting with services key', () => { const template: Template = { title: 'full', description: 'Full template', image: 'app:v1', ports: ['8080:80'], volumes: [{ container: '/data', bind: './data' }], env: [{ name: 'KEY', default: 'val' }], }; const yaml = service.generateComposeFromTemplate(template, 'app'); expect(yaml).toMatch(/^services:\n/); expect(yaml).toContain(' app:'); }); it('uses the supplied service name as the compose service key', () => { const template: Template = { title: 'Plex', description: 'Media server', image: 'plex:latest', }; const yaml = service.generateComposeFromTemplate(template, 'plex'); expect(yaml).toMatch(/^services:\n {2}plex:\n/); expect(yaml).not.toContain(' app:'); }); }); // ─── 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(); }); }); });