/** * 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(''); expect(result).not.toContain('