homelab_automation/tests/frontend/dashboard_core.test.js
Bruno Charest 8affa0f8b7
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
feat: Implement container customization and add Portainer installation/removal playbooks.
2025-12-27 11:02:24 -05:00

728 lines
22 KiB
JavaScript

/**
* Tests for dashboard_core.js - DashboardCore class.
*
* This tests the real exported module for coverage.
*
* Covers:
* - Authentication flow (login, logout, checkAuthStatus)
* - API calls with auth headers
* - WebSocket connection and message handling
* - Data management (hosts, tasks, schedules, alerts)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DashboardCore } from '../../app/dashboard_core.js';
import {
createMockResponse,
MockWebSocket,
localStorageMock
} from './setup.js';
// ============================================================================
// TESTS: DashboardCore - Authentication
// ============================================================================
describe('DashboardCore - Authentication', () => {
let core;
let mockFetch;
let mockStorage;
beforeEach(() => {
mockStorage = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
};
mockFetch = vi.fn();
core = new DashboardCore({
apiBase: 'http://localhost',
storage: mockStorage,
fetch: mockFetch,
WebSocket: MockWebSocket
});
});
describe('getAuthHeaders', () => {
it('returns headers without auth when no token', () => {
core.accessToken = null;
const headers = core.getAuthHeaders();
expect(headers['Content-Type']).toBe('application/json');
expect(headers['Authorization']).toBeUndefined();
});
it('returns headers with Bearer token when authenticated', () => {
core.accessToken = 'test-token-123';
const headers = core.getAuthHeaders();
expect(headers['Authorization']).toBe('Bearer test-token-123');
});
});
describe('checkAuthStatus', () => {
it('returns true when authenticated', async () => {
mockFetch.mockResolvedValue(createMockResponse({
setup_required: false,
authenticated: true,
user: { username: 'testuser', role: 'admin' }
}));
const result = await core.checkAuthStatus();
expect(result).toBe(true);
expect(core.currentUser).toEqual({ username: 'testuser', role: 'admin' });
});
it('returns false when setup required', async () => {
mockFetch.mockResolvedValue(createMockResponse({
setup_required: true,
authenticated: false,
user: null
}));
const result = await core.checkAuthStatus();
expect(result).toBe(false);
expect(core.setupRequired).toBe(true);
});
it('returns false when not authenticated', async () => {
mockFetch.mockResolvedValue(createMockResponse({
setup_required: false,
authenticated: false,
user: null
}));
const result = await core.checkAuthStatus();
expect(result).toBe(false);
expect(core.currentUser).toBeNull();
});
it('returns false on network error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await core.checkAuthStatus();
expect(result).toBe(false);
consoleSpy.mockRestore();
});
it('calls onAuthStateChanged callback when authenticated', async () => {
const callback = vi.fn();
core.onAuthStateChanged = callback;
mockFetch.mockResolvedValue(createMockResponse({
setup_required: false,
authenticated: true,
user: { username: 'testuser' }
}));
await core.checkAuthStatus();
expect(callback).toHaveBeenCalledWith({
authenticated: true,
user: { username: 'testuser' }
});
});
});
describe('login', () => {
it('stores token on successful login', async () => {
// First call for login, second for checkAuthStatus
mockFetch
.mockResolvedValueOnce(createMockResponse({
access_token: 'new-jwt-token',
token_type: 'bearer',
expires_in: 86400
}))
.mockResolvedValueOnce(createMockResponse({
setup_required: false,
authenticated: true,
user: { username: 'testuser' }
}));
const result = await core.login('testuser', 'password123');
expect(result).toBe(true);
expect(core.accessToken).toBe('new-jwt-token');
expect(mockStorage.setItem).toHaveBeenCalledWith('accessToken', 'new-jwt-token');
});
it('returns false on invalid credentials', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockFetch.mockResolvedValue(createMockResponse({ detail: 'Invalid credentials' }, 401));
const result = await core.login('wrong', 'credentials');
expect(result).toBe(false);
consoleSpy.mockRestore();
});
it('calls onNotification callback on success', async () => {
const callback = vi.fn();
core.onNotification = callback;
mockFetch
.mockResolvedValueOnce(createMockResponse({ access_token: 'token' }))
.mockResolvedValueOnce(createMockResponse({ authenticated: true, user: {} }));
await core.login('user', 'pass');
expect(callback).toHaveBeenCalledWith('Connexion réussie', 'success');
});
it('calls onNotification callback on error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const callback = vi.fn();
core.onNotification = callback;
mockFetch.mockResolvedValue(createMockResponse({ detail: 'Bad password' }, 401));
await core.login('user', 'wrong');
expect(callback).toHaveBeenCalledWith('Bad password', 'error');
consoleSpy.mockRestore();
});
});
describe('logout', () => {
it('clears token and user data', () => {
core.accessToken = 'some-token';
core.currentUser = { username: 'test' };
core.logout();
expect(core.accessToken).toBeNull();
expect(core.currentUser).toBeNull();
expect(mockStorage.removeItem).toHaveBeenCalledWith('accessToken');
});
it('closes WebSocket connection', () => {
core.ws = new MockWebSocket('ws://localhost/ws');
const closeSpy = vi.spyOn(core.ws, 'close');
core.logout();
expect(closeSpy).toHaveBeenCalled();
expect(core.ws).toBeNull();
});
it('calls onAuthStateChanged callback', () => {
const callback = vi.fn();
core.onAuthStateChanged = callback;
core.logout();
expect(callback).toHaveBeenCalledWith({ authenticated: false, user: null });
});
});
});
// ============================================================================
// TESTS: DashboardCore - API Calls
// ============================================================================
describe('DashboardCore - API Calls', () => {
let core;
let mockFetch;
beforeEach(() => {
mockFetch = vi.fn();
core = new DashboardCore({
apiBase: 'http://localhost',
fetch: mockFetch
});
core.accessToken = 'test-token';
});
describe('apiCall', () => {
it('makes authenticated request', async () => {
mockFetch.mockResolvedValue(createMockResponse({ data: 'test' }));
const result = await core.apiCall('/api/test');
expect(result).toEqual({ data: 'test' });
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost/api/test',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer test-token'
})
})
);
});
it('throws on HTTP error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
mockFetch.mockResolvedValue(createMockResponse({ error: 'Not found' }, 404));
await expect(core.apiCall('/api/notfound')).rejects.toThrow('HTTP 404');
consoleSpy.mockRestore();
});
it('passes custom options', async () => {
mockFetch.mockResolvedValue(createMockResponse({ success: true }));
await core.apiCall('/api/test', { method: 'POST', body: '{}' });
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost/api/test',
expect.objectContaining({
method: 'POST',
body: '{}'
})
);
});
});
});
// ============================================================================
// TESTS: DashboardCore - WebSocket
// ============================================================================
describe('DashboardCore - WebSocket', () => {
let core;
beforeEach(() => {
core = new DashboardCore({
apiBase: 'http://localhost',
WebSocket: MockWebSocket
});
});
describe('connectWebSocket', () => {
it('creates WebSocket connection', () => {
core.connectWebSocket('ws://localhost/ws');
expect(core.ws).toBeInstanceOf(MockWebSocket);
expect(core.ws.url).toBe('ws://localhost/ws');
});
it('sets up event handlers', () => {
core.connectWebSocket('ws://localhost/ws');
expect(core.ws.onopen).toBeDefined();
expect(core.ws.onmessage).toBeDefined();
expect(core.ws.onclose).toBeDefined();
expect(core.ws.onerror).toBeDefined();
});
it('warns if WebSocket class not available', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const coreNoWs = new DashboardCore({
apiBase: 'http://localhost',
WebSocket: null
});
// Override the class to null after construction
coreNoWs.WebSocketClass = null;
coreNoWs.connectWebSocket('ws://localhost/ws');
expect(consoleSpy).toHaveBeenCalledWith('WebSocket not available');
consoleSpy.mockRestore();
});
it('uses default URL when none provided', () => {
core.connectWebSocket();
expect(core.ws).toBeInstanceOf(MockWebSocket);
});
it('triggers onopen handler', async () => {
core.connectWebSocket('ws://localhost/ws');
// MockWebSocket auto-connects after a tick
await new Promise(r => setTimeout(r, 10));
// onopen should have been called (check via readyState)
expect(core.ws.readyState).toBe(MockWebSocket.OPEN);
});
it('handles onmessage with valid JSON', () => {
core.hosts = [];
core.connectWebSocket('ws://localhost/ws');
// Simulate receiving a message
core.ws._receiveMessage({ type: 'host_updated', data: { id: 'h1', status: 'online' } });
expect(core.hosts.length).toBe(1);
});
it('handles onmessage with invalid JSON gracefully', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
core.connectWebSocket('ws://localhost/ws');
// Simulate receiving invalid JSON - should not throw
expect(() => {
core.ws.onmessage({ data: 'not valid json {' });
}).not.toThrow();
consoleSpy.mockRestore();
});
it('handles onclose', () => {
core.connectWebSocket('ws://localhost/ws');
// Should not throw
expect(() => {
core.ws.onclose({ code: 1000, reason: 'Normal' });
}).not.toThrow();
});
it('handles onerror', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
core.connectWebSocket('ws://localhost/ws');
// Should not throw
expect(() => {
core.ws.onerror({ error: new Error('Test error') });
}).not.toThrow();
consoleSpy.mockRestore();
});
});
describe('handleWebSocketMessage', () => {
beforeEach(() => {
core.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
core.tasks = [];
core.schedules = [{ id: 'sched-1', name: 'Daily Backup', last_status: 'pending' }];
core.alerts = [];
});
it('handles host_updated event', () => {
const callback = vi.fn();
core.onHostsUpdated = callback;
core.handleWebSocketMessage({
type: 'host_updated',
data: { id: 'host-1', status: 'online' }
});
expect(core.hosts[0].status).toBe('online');
expect(callback).toHaveBeenCalled();
});
it('handles task_started event', () => {
const callback = vi.fn();
core.onTasksUpdated = callback;
core.handleWebSocketMessage({
type: 'task_started',
data: { id: 'task-1', playbook: 'test.yml' }
});
expect(core.tasks.length).toBe(1);
expect(core.tasks[0].id).toBe('task-1');
expect(callback).toHaveBeenCalled();
});
it('handles task_updated event', () => {
core.tasks = [{ id: 'task-1', status: 'running' }];
core.handleWebSocketMessage({
type: 'task_updated',
data: { id: 'task-1', status: 'completed', progress: 100 }
});
expect(core.tasks[0].status).toBe('completed');
expect(core.tasks[0].progress).toBe(100);
});
it('handles schedule_completed event', () => {
const callback = vi.fn();
core.onSchedulesUpdated = callback;
core.handleWebSocketMessage({
type: 'schedule_completed',
data: { schedule_id: 'sched-1', status: 'success' }
});
expect(core.schedules[0].last_status).toBe('success');
expect(callback).toHaveBeenCalled();
});
it('handles alert_created event', () => {
const callback = vi.fn();
core.onAlertsUpdated = callback;
core.handleWebSocketMessage({
type: 'alert_created',
data: { id: 1, message: 'Test alert' }
});
expect(core.alerts.length).toBe(1);
expect(core.alertsUnread).toBe(1);
expect(callback).toHaveBeenCalled();
});
it('handles hosts_synced event', () => {
const callback = vi.fn();
core.onHostsUpdated = callback;
core.handleWebSocketMessage({ type: 'hosts_synced', data: {} });
expect(callback).toHaveBeenCalled();
});
it('ignores unknown message types', () => {
// Should not throw
core.handleWebSocketMessage({ type: 'unknown_type', data: {} });
});
});
});
// ============================================================================
// TESTS: DashboardCore - Data Management
// ============================================================================
describe('DashboardCore - Data Management', () => {
let core;
beforeEach(() => {
core = new DashboardCore({});
});
describe('updateHostInList', () => {
it('updates existing host', () => {
core.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
core.updateHostInList({ id: 'host-1', status: 'online' });
expect(core.hosts[0].status).toBe('online');
expect(core.hosts[0].name).toBe('server1'); // Preserved
});
it('adds new host if not found', () => {
core.hosts = [];
core.updateHostInList({ id: 'host-new', name: 'new-server' });
expect(core.hosts.length).toBe(1);
expect(core.hosts[0].id).toBe('host-new');
});
it('ignores invalid data', () => {
core.hosts = [];
core.updateHostInList(null);
core.updateHostInList({});
expect(core.hosts.length).toBe(0);
});
});
describe('handleTaskUpdate', () => {
it('updates existing task', () => {
core.tasks = [{ id: 'task-1', status: 'running' }];
core.handleTaskUpdate({ id: 'task-1', status: 'completed' });
expect(core.tasks[0].status).toBe('completed');
});
it('adds new task at beginning', () => {
core.tasks = [{ id: 'task-old' }];
core.handleTaskUpdate({ id: 'task-new', status: 'pending' });
expect(core.tasks.length).toBe(2);
expect(core.tasks[0].id).toBe('task-new');
});
});
describe('handleAlertCreated', () => {
it('adds alert and increments unread count', () => {
core.alerts = [];
core.alertsUnread = 0;
core.handleAlertCreated({ id: 1, message: 'Test' });
expect(core.alerts.length).toBe(1);
expect(core.alertsUnread).toBe(1);
});
it('limits alerts to 200', () => {
core.alerts = Array.from({ length: 200 }, (_, i) => ({ id: i }));
core.handleAlertCreated({ id: 999, message: 'New' });
expect(core.alerts.length).toBe(200);
expect(core.alerts[0].id).toBe(999);
});
});
});
// ============================================================================
// TESTS: DashboardCore - Utility
// ============================================================================
describe('DashboardCore - Utility', () => {
let core;
beforeEach(() => {
core = new DashboardCore({});
});
describe('escapeHtml', () => {
it('escapes HTML special characters', () => {
const result = core.escapeHtml('<script>alert("xss")</script>');
expect(result).not.toContain('<script>');
expect(result).toContain('&lt;');
expect(result).toContain('&gt;');
});
it('returns empty string for null/undefined', () => {
expect(core.escapeHtml(null)).toBe('');
expect(core.escapeHtml(undefined)).toBe('');
});
it('handles normal text', () => {
expect(core.escapeHtml('Hello World')).toBe('Hello World');
});
it('escapes quotes', () => {
const result = core.escapeHtml('He said "hello" & \'goodbye\'');
expect(result).toContain('&amp;');
});
});
describe('_getDefaultWsUrl', () => {
it('returns ws:// URL for http', () => {
// In jsdom, window.location.protocol is typically http:
const url = core._getDefaultWsUrl();
expect(url).toMatch(/^wss?:\/\//);
});
});
});
// ============================================================================
// TESTS: DashboardCore - Constructor
// ============================================================================
describe('DashboardCore - Constructor', () => {
it('loads token from storage on init', () => {
const mockStorage = {
getItem: vi.fn(() => 'stored-token'),
setItem: vi.fn(),
removeItem: vi.fn()
};
const core = new DashboardCore({ storage: mockStorage });
expect(core.accessToken).toBe('stored-token');
});
it('starts with null token if storage empty', () => {
const mockStorage = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn()
};
const core = new DashboardCore({ storage: mockStorage });
expect(core.accessToken).toBeNull();
});
it('uses default apiBase in browser environment', () => {
// In test environment, window.location.origin may not be set
const core = new DashboardCore({});
expect(core.apiBase).toBeDefined();
});
});
// ============================================================================
// TESTS: Dashboard UI helpers (main.js) - Ad-Hoc widget regression
// ============================================================================
describe('main.js - Ad-Hoc widget regression', () => {
it('computes counters from task log status and shows load more when more items exist locally', async () => {
// Minimal DOM for widget
document.body.innerHTML = `
<div id="adhoc-widget-history"></div>
<button id="adhoc-widget-load-more" class="hidden"></button>
<div id="adhoc-widget-success"></div>
<div id="adhoc-widget-failed"></div>
<div id="adhoc-widget-total"></div>
<div id="adhoc-widget-count"></div>
`;
// Import side-effects are heavy; instead, instantiate the real DashboardManager by requiring main.js.
// main.js defines DashboardManager on the global scope.
await import('../../app/main.js');
// Create instance and stub required methods used by widget
const dash = new window.DashboardManager();
dash.escapeHtml = (s) => String(s ?? '');
dash.formatTimeAgo = () => 'À l\'instant';
dash.viewTaskLogContent = vi.fn();
dash.showAdHocConsole = vi.fn();
// Emulate loaded adhoc task logs
dash.adhocWidgetLimit = 2;
dash.adhocWidgetOffset = 0;
dash.adhocWidgetLogs = [
{ id: 'l1', status: 'completed', task_name: 'Ad-hoc: docker version', target: 'h1', created_at: new Date().toISOString(), duration_seconds: 1.2 },
{ id: 'l2', status: 'failed', task_name: 'Ad-hoc: docker ps', target: 'h2', created_at: new Date().toISOString(), duration_seconds: 2.3 },
{ id: 'l3', status: 'completed', task_name: 'Ad-hoc: cat /etc/os-release', target: 'h3', created_at: new Date().toISOString(), duration_seconds: 0.8 },
];
dash.adhocWidgetTotalCount = 3;
dash.adhocWidgetHasMore = false;
dash.renderAdhocWidget();
expect(document.getElementById('adhoc-widget-success').textContent).toBe('2');
expect(document.getElementById('adhoc-widget-failed').textContent).toBe('1');
expect(document.getElementById('adhoc-widget-total').textContent).toBe('3');
expect(document.getElementById('adhoc-widget-count').textContent).toContain('3');
// Since only 2 are displayed but 3 are loaded, load-more must be visible.
expect(document.getElementById('adhoc-widget-load-more').classList.contains('hidden')).toBe(false);
});
it('does not crash structured playbook viewer when parsedOutput.msg is an object', async () => {
await import('../../app/main.js');
const dash = new window.DashboardManager();
dash.escapeHtml = (s) => String(s ?? '');
const parsedOutput = {
plays: [
{
name: 'Test Play',
tasks: [
{
name: 'Task 1',
hostResults: [
{
hostname: 'host1',
status: 'ok',
parsedOutput: { msg: { hello: 'world', code: 200 } }
}
]
}
]
}
],
recap: {
host1: { ok: 1, changed: 0, failed: 0, skipped: 0, unreachable: 0 }
},
metadata: { playbookName: 'dummy.yml' },
stats: { totalHosts: 1, totalTasks: 1 }
};
expect(() => dash.renderTaskHierarchy(parsedOutput)).not.toThrow();
const html = dash.renderTaskHierarchy(parsedOutput);
expect(html).toContain('host1');
expect(html).toContain('"hello"');
});
});