homelab_automation/tests/frontend/containers_page.test.js

415 lines
15 KiB
JavaScript

/**
* Unit tests for containers_page.js filter/sort functions
* Run with: npx vitest run tests/frontend/containers_page.test.js
*
* These tests verify the pure functions used for filtering, sorting, and
* searching containers in the containers page.
*/
import { describe, it, expect } from 'vitest';
// Pure filter/sort functions extracted for testing (same logic as containers_page.js)
const containerUtils = {
parseSearchTokens(query) {
const filters = [];
let freeText = query;
const tokenRegex = /(\w+):(\S+)/g;
let match;
while ((match = tokenRegex.exec(query)) !== null) {
filters.push({ key: match[1], value: match[2] });
freeText = freeText.replace(match[0], '').trim();
}
return { filters, freeText };
},
containerHasPort(container, port) {
if (!container.ports) return false;
const portStr = container.ports.raw || JSON.stringify(container.ports);
return portStr.includes(port);
},
sortContainers(containers, sortBy) {
const [field, direction] = sortBy.split('-');
const mult = direction === 'desc' ? -1 : 1;
return [...containers].sort((a, b) => {
let valA, valB;
switch (field) {
case 'name':
valA = a.name.toLowerCase();
valB = b.name.toLowerCase();
break;
case 'host':
valA = a.host_name.toLowerCase();
valB = b.host_name.toLowerCase();
break;
case 'status':
const statusOrder = { running: 0, restarting: 1, paused: 2, created: 3, exited: 4, dead: 5 };
valA = statusOrder[a.state] ?? 99;
valB = statusOrder[b.state] ?? 99;
break;
case 'updated':
valA = new Date(a.last_update_at || 0).getTime();
valB = new Date(b.last_update_at || 0).getTime();
break;
default:
valA = a.name.toLowerCase();
valB = b.name.toLowerCase();
}
if (valA < valB) return -1 * mult;
if (valA > valB) return 1 * mult;
return 0;
});
},
smartSearch(containers, query) {
const tokens = this.parseSearchTokens(query);
return containers.filter(c => {
for (const token of tokens.filters) {
switch (token.key) {
case 'host':
if (!c.host_name.toLowerCase().includes(token.value.toLowerCase())) return false;
break;
case 'status':
case 'state':
if (c.state !== token.value.toLowerCase()) return false;
break;
case 'health':
if (c.health !== token.value.toLowerCase()) return false;
break;
case 'image':
if (!c.image?.toLowerCase().includes(token.value.toLowerCase())) return false;
break;
case 'port':
if (!this.containerHasPort(c, token.value)) return false;
break;
case 'project':
case 'stack':
if (!c.compose_project?.toLowerCase().includes(token.value.toLowerCase())) return false;
break;
}
}
if (tokens.freeText) {
const searchStr = tokens.freeText.toLowerCase();
const searchable = [
c.name,
c.host_name,
c.image,
c.compose_project,
c.container_id?.substring(0, 12)
].filter(Boolean).join(' ').toLowerCase();
if (!searchable.includes(searchStr)) return false;
}
return true;
});
},
formatRelativeTime(date) {
if (!date) return '—';
const now = new Date();
const d = new Date(date);
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'À l\'instant';
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
return `il y a ${Math.floor(diff / 86400)} j`;
},
escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
};
// Test data
const mockContainers = [
{
id: 1,
host_id: 'host1',
host_name: 'dev.lab.home',
host_ip: '192.168.1.10',
container_id: 'abc123def456',
name: 'nginx-proxy',
image: 'nginx:latest',
state: 'running',
status: 'Up 2 hours',
health: 'healthy',
ports: { raw: '0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp' },
labels: { 'com.docker.compose.project': 'webstack' },
compose_project: 'webstack',
last_update_at: new Date().toISOString()
},
{
id: 2,
host_id: 'host1',
host_name: 'dev.lab.home',
host_ip: '192.168.1.10',
container_id: 'xyz789abc012',
name: 'postgres-db',
image: 'postgres:15',
state: 'running',
status: 'Up 3 hours',
health: 'healthy',
ports: { raw: '0.0.0.0:5432->5432/tcp' },
labels: { 'com.docker.compose.project': 'database' },
compose_project: 'database',
last_update_at: new Date(Date.now() - 3600000).toISOString()
},
{
id: 3,
host_id: 'host2',
host_name: 'prod.lab.home',
host_ip: '192.168.1.20',
container_id: 'mno456pqr789',
name: 'api-server',
image: 'myapp:v1.2.0',
state: 'exited',
status: 'Exited (1) 5 minutes ago',
health: null,
ports: null,
labels: {},
compose_project: null,
last_update_at: new Date(Date.now() - 7200000).toISOString()
},
{
id: 4,
host_id: 'host2',
host_name: 'prod.lab.home',
host_ip: '192.168.1.20',
container_id: 'stu012vwx345',
name: 'redis-cache',
image: 'redis:7-alpine',
state: 'paused',
status: 'Paused',
health: 'none',
ports: { raw: '0.0.0.0:6379->6379/tcp' },
labels: {},
compose_project: 'cache',
last_update_at: new Date(Date.now() - 1800000).toISOString()
}
];
describe('containerUtils', () => {
describe('parseSearchTokens', () => {
it('should parse single token', () => {
const result = containerUtils.parseSearchTokens('host:dev');
expect(result.filters).toHaveLength(1);
expect(result.filters[0]).toEqual({ key: 'host', value: 'dev' });
expect(result.freeText).toBe('');
});
it('should parse multiple tokens', () => {
const result = containerUtils.parseSearchTokens('host:dev status:running');
expect(result.filters).toHaveLength(2);
expect(result.filters[0]).toEqual({ key: 'host', value: 'dev' });
expect(result.filters[1]).toEqual({ key: 'status', value: 'running' });
});
it('should parse mixed tokens and free text', () => {
const result = containerUtils.parseSearchTokens('nginx host:dev');
expect(result.filters).toHaveLength(1);
expect(result.filters[0]).toEqual({ key: 'host', value: 'dev' });
expect(result.freeText).toBe('nginx');
});
it('should handle empty query', () => {
const result = containerUtils.parseSearchTokens('');
expect(result.filters).toHaveLength(0);
expect(result.freeText).toBe('');
});
it('should handle free text only', () => {
const result = containerUtils.parseSearchTokens('nginx proxy');
expect(result.filters).toHaveLength(0);
expect(result.freeText).toBe('nginx proxy');
});
});
describe('containerHasPort', () => {
it('should find port in raw ports string', () => {
const container = { ports: { raw: '0.0.0.0:8080->80/tcp' } };
expect(containerUtils.containerHasPort(container, '8080')).toBe(true);
});
it('should not find missing port', () => {
const container = { ports: { raw: '0.0.0.0:8080->80/tcp' } };
expect(containerUtils.containerHasPort(container, '9999')).toBe(false);
});
it('should handle null ports', () => {
const container = { ports: null };
expect(containerUtils.containerHasPort(container, '8080')).toBe(false);
});
it('should handle container without ports property', () => {
const container = {};
expect(containerUtils.containerHasPort(container, '8080')).toBe(false);
});
});
describe('sortContainers', () => {
it('should sort by name ascending', () => {
const sorted = containerUtils.sortContainers(mockContainers, 'name-asc');
expect(sorted[0].name).toBe('api-server');
expect(sorted[1].name).toBe('nginx-proxy');
expect(sorted[2].name).toBe('postgres-db');
expect(sorted[3].name).toBe('redis-cache');
});
it('should sort by name descending', () => {
const sorted = containerUtils.sortContainers(mockContainers, 'name-desc');
expect(sorted[0].name).toBe('redis-cache');
expect(sorted[3].name).toBe('api-server');
});
it('should sort by host ascending', () => {
const sorted = containerUtils.sortContainers(mockContainers, 'host-asc');
expect(sorted[0].host_name).toBe('dev.lab.home');
expect(sorted[1].host_name).toBe('dev.lab.home');
expect(sorted[2].host_name).toBe('prod.lab.home');
});
it('should sort by status (running first)', () => {
const sorted = containerUtils.sortContainers(mockContainers, 'status-asc');
expect(sorted[0].state).toBe('running');
expect(sorted[1].state).toBe('running');
expect(sorted[2].state).toBe('paused');
expect(sorted[3].state).toBe('exited');
});
it('should not mutate original array', () => {
const original = [...mockContainers];
containerUtils.sortContainers(mockContainers, 'name-desc');
expect(mockContainers[0].name).toBe(original[0].name);
});
});
describe('smartSearch', () => {
it('should filter by host token', () => {
const result = containerUtils.smartSearch(mockContainers, 'host:dev');
expect(result).toHaveLength(2);
expect(result.every(c => c.host_name.includes('dev'))).toBe(true);
});
it('should filter by status token', () => {
const result = containerUtils.smartSearch(mockContainers, 'status:running');
expect(result).toHaveLength(2);
expect(result.every(c => c.state === 'running')).toBe(true);
});
it('should filter by health token', () => {
const result = containerUtils.smartSearch(mockContainers, 'health:healthy');
expect(result).toHaveLength(2);
expect(result.every(c => c.health === 'healthy')).toBe(true);
});
it('should filter by image token', () => {
const result = containerUtils.smartSearch(mockContainers, 'image:postgres');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('postgres-db');
});
it('should filter by port token', () => {
const result = containerUtils.smartSearch(mockContainers, 'port:5432');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('postgres-db');
});
it('should filter by project token', () => {
const result = containerUtils.smartSearch(mockContainers, 'project:webstack');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('nginx-proxy');
});
it('should filter by free text (name)', () => {
const result = containerUtils.smartSearch(mockContainers, 'nginx');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('nginx-proxy');
});
it('should combine multiple tokens', () => {
const result = containerUtils.smartSearch(mockContainers, 'host:dev status:running');
expect(result).toHaveLength(2);
});
it('should combine token and free text', () => {
const result = containerUtils.smartSearch(mockContainers, 'nginx host:dev');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('nginx-proxy');
});
it('should return all containers for empty query', () => {
const result = containerUtils.smartSearch(mockContainers, '');
expect(result).toHaveLength(4);
});
it('should be case insensitive', () => {
const result = containerUtils.smartSearch(mockContainers, 'NGINX');
expect(result).toHaveLength(1);
});
});
describe('formatRelativeTime', () => {
it('should return "À l\'instant" for recent times', () => {
const now = new Date();
expect(containerUtils.formatRelativeTime(now)).toBe('À l\'instant');
});
it('should format minutes', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
expect(containerUtils.formatRelativeTime(fiveMinAgo)).toBe('il y a 5 min');
});
it('should format hours', () => {
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
expect(containerUtils.formatRelativeTime(twoHoursAgo)).toBe('il y a 2 h');
});
it('should format days', () => {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
expect(containerUtils.formatRelativeTime(threeDaysAgo)).toBe('il y a 3 j');
});
it('should handle null', () => {
expect(containerUtils.formatRelativeTime(null)).toBe('—');
});
});
describe('escapeHtml', () => {
it('should escape HTML entities', () => {
expect(containerUtils.escapeHtml('<script>')).toBe('&lt;script&gt;');
});
it('should escape ampersands', () => {
expect(containerUtils.escapeHtml('foo & bar')).toBe('foo &amp; bar');
});
it('should escape quotes', () => {
expect(containerUtils.escapeHtml('"quoted"')).toBe('&quot;quoted&quot;');
});
it('should handle empty string', () => {
expect(containerUtils.escapeHtml('')).toBe('');
});
it('should handle null', () => {
expect(containerUtils.escapeHtml(null)).toBe('');
});
});
});