/** * 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } }; // 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('