mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
ed553f1f19
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.
235 lines
7.7 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|