Files
sencho/backend/src/__tests__/template-service.test.ts
T
Anso ed553f1f19 feat: change default listen port from 3000 to 1852 (#756)
Updates the backend listen port, Vite dev proxy target, Docker EXPOSE,
compose port mapping, .env.example default, GitHub Actions smoke-test
default, healthcheck URLs, and every doc/example reference. Test fixtures
that include example URLs were updated for consistency, though their
assertions are port-agnostic.

The rate-limit value of 3000 in middleware/rateLimiters.ts and the
3000 entry in WEB_UI_PORTS (which detects user containers like Grafana)
are intentionally untouched.
2026-04-24 22:23:31 -04:00

235 lines
7.7 KiB
TypeScript

/**
* 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();
});
});
});