/**
* 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('