Clean up test files and debug artifacts, add node_modules to gitignore, export DashboardManager for testing, and enhance pytest configuration with comprehensive test markers and settings
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

This commit is contained in:
2025-12-15 08:15:49 -05:00
parent 6c83ada7d1
commit ecefbc8611
123 changed files with 18425 additions and 1287 deletions
+640
View File
@@ -0,0 +1,640 @@
/**
* 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();
});
});
+408
View File
@@ -0,0 +1,408 @@
/**
* Tests for DOM rendering and user interactions.
*
* Covers:
* - Status badge rendering
* - Host list rendering
* - Task list rendering
* - Loading/empty/error states
* - Navigation between pages
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent } from '@testing-library/dom';
import { setupMinimalDOM, waitForDOM } from './setup.js';
// ============================================================================
// UTILITY FUNCTIONS (extracted from main.js patterns)
// ============================================================================
/**
* Get status badge class based on status.
*/
function getStatusBadgeClass(status) {
const statusMap = {
'online': 'badge-success',
'offline': 'badge-danger',
'unknown': 'badge-warning',
'running': 'badge-info',
'success': 'badge-success',
'failed': 'badge-danger',
'pending': 'badge-secondary',
};
return statusMap[status] || 'badge-secondary';
}
/**
* Get status display text.
*/
function getStatusText(status) {
const textMap = {
'online': 'En ligne',
'offline': 'Hors ligne',
'unknown': 'Inconnu',
'running': 'En cours',
'success': 'Réussi',
'failed': 'Échoué',
'pending': 'En attente',
};
return textMap[status] || status;
}
/**
* Format duration in seconds to human readable.
*/
function formatDuration(seconds) {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
}
/**
* Format date to locale string.
*/
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Render a host card.
*/
function renderHostCard(host) {
return `
<div class="host-card" data-host-id="${host.id}">
<div class="host-header">
<h3 class="host-name">${host.name}</h3>
<span class="badge ${getStatusBadgeClass(host.status)}">${getStatusText(host.status)}</span>
</div>
<div class="host-info">
<p class="host-ip">${host.ip || '-'}</p>
<p class="host-groups">${(host.groups || []).join(', ') || '-'}</p>
</div>
</div>
`;
}
/**
* Render a task row.
*/
function renderTaskRow(task) {
return `
<tr class="task-row" data-task-id="${task.id}">
<td class="task-playbook">${task.playbook}</td>
<td class="task-target">${task.target}</td>
<td class="task-status">
<span class="badge ${getStatusBadgeClass(task.status)}">${getStatusText(task.status)}</span>
</td>
<td class="task-duration">${task.duration ? formatDuration(task.duration) : '-'}</td>
<td class="task-date">${formatDate(task.created_at)}</td>
</tr>
`;
}
// ============================================================================
// TESTS: Status Badge Functions
// ============================================================================
describe('Status Badge Functions', () => {
describe('getStatusBadgeClass', () => {
it('returns success class for online status', () => {
expect(getStatusBadgeClass('online')).toBe('badge-success');
});
it('returns danger class for offline status', () => {
expect(getStatusBadgeClass('offline')).toBe('badge-danger');
});
it('returns warning class for unknown status', () => {
expect(getStatusBadgeClass('unknown')).toBe('badge-warning');
});
it('returns info class for running status', () => {
expect(getStatusBadgeClass('running')).toBe('badge-info');
});
it('returns secondary class for unknown status', () => {
expect(getStatusBadgeClass('something-else')).toBe('badge-secondary');
});
});
describe('getStatusText', () => {
it('returns French text for known statuses', () => {
expect(getStatusText('online')).toBe('En ligne');
expect(getStatusText('offline')).toBe('Hors ligne');
expect(getStatusText('running')).toBe('En cours');
});
it('returns original status for unknown values', () => {
expect(getStatusText('custom-status')).toBe('custom-status');
});
});
});
// ============================================================================
// TESTS: Formatting Functions
// ============================================================================
describe('Formatting Functions', () => {
describe('formatDuration', () => {
it('formats seconds only', () => {
expect(formatDuration(45)).toBe('45s');
});
it('formats minutes and seconds', () => {
expect(formatDuration(125)).toBe('2m 5s');
});
it('formats hours and minutes', () => {
expect(formatDuration(3725)).toBe('1h 2m');
});
it('rounds seconds', () => {
expect(formatDuration(45.7)).toBe('46s');
});
});
describe('formatDate', () => {
it('returns dash for null/undefined', () => {
expect(formatDate(null)).toBe('-');
expect(formatDate(undefined)).toBe('-');
});
it('formats ISO date string', () => {
const result = formatDate('2024-01-15T14:30:00Z');
// Should contain date parts (format varies by locale)
expect(result).toMatch(/\d{2}/);
});
});
});
// ============================================================================
// TESTS: Host Card Rendering
// ============================================================================
describe('Host Card Rendering', () => {
beforeEach(() => {
setupMinimalDOM();
});
it('renders host card with all information', () => {
const host = {
id: 'host-1',
name: 'server1.local',
ip: '192.168.1.10',
status: 'online',
groups: ['env_prod', 'role_web'],
};
document.body.innerHTML = renderHostCard(host);
expect(document.querySelector('.host-name').textContent).toBe('server1.local');
expect(document.querySelector('.host-ip').textContent).toBe('192.168.1.10');
expect(document.querySelector('.badge').classList.contains('badge-success')).toBe(true);
expect(document.querySelector('.host-groups').textContent).toBe('env_prod, role_web');
});
it('renders host card with unknown status', () => {
const host = {
id: 'host-2',
name: 'server2.local',
status: 'unknown',
groups: [],
};
document.body.innerHTML = renderHostCard(host);
expect(document.querySelector('.badge').classList.contains('badge-warning')).toBe(true);
expect(document.querySelector('.host-groups').textContent).toBe('-');
});
it('sets correct data attribute', () => {
const host = { id: 'test-host-id', name: 'test', status: 'online' };
document.body.innerHTML = renderHostCard(host);
expect(document.querySelector('.host-card').dataset.hostId).toBe('test-host-id');
});
});
// ============================================================================
// TESTS: Task Row Rendering
// ============================================================================
describe('Task Row Rendering', () => {
beforeEach(() => {
setupMinimalDOM();
});
it('renders task row with all information', () => {
const task = {
id: 'task-1',
playbook: 'backup.yml',
target: 'all',
status: 'success',
duration: 125,
created_at: '2024-01-15T10:30:00Z',
};
document.body.innerHTML = `<table><tbody>${renderTaskRow(task)}</tbody></table>`;
expect(document.querySelector('.task-playbook').textContent).toBe('backup.yml');
expect(document.querySelector('.task-target').textContent).toBe('all');
expect(document.querySelector('.badge').classList.contains('badge-success')).toBe(true);
expect(document.querySelector('.task-duration').textContent).toBe('2m 5s');
});
it('renders running task', () => {
const task = {
id: 'task-2',
playbook: 'deploy.yml',
target: 'webservers',
status: 'running',
created_at: '2024-01-15T10:30:00Z',
};
document.body.innerHTML = `<table><tbody>${renderTaskRow(task)}</tbody></table>`;
expect(document.querySelector('.badge').classList.contains('badge-info')).toBe(true);
expect(document.querySelector('.task-duration').textContent).toBe('-');
});
it('renders failed task', () => {
const task = {
id: 'task-3',
playbook: 'update.yml',
target: 'all',
status: 'failed',
duration: 5,
};
document.body.innerHTML = `<table><tbody>${renderTaskRow(task)}</tbody></table>`;
expect(document.querySelector('.badge').classList.contains('badge-danger')).toBe(true);
});
});
// ============================================================================
// TESTS: Loading/Empty/Error States
// ============================================================================
describe('UI States', () => {
beforeEach(() => {
setupMinimalDOM();
});
it('shows loading state', () => {
document.body.innerHTML = `
<div class="loading-spinner active">
<span>Chargement...</span>
</div>
`;
const spinner = document.querySelector('.loading-spinner');
expect(spinner.classList.contains('active')).toBe(true);
});
it('shows empty state', () => {
document.body.innerHTML = `
<div class="empty-state">
<p>Aucun hôte configuré</p>
<button class="btn btn-primary">Ajouter un hôte</button>
</div>
`;
expect(document.querySelector('.empty-state')).toBeTruthy();
expect(document.querySelector('.empty-state p').textContent).toContain('Aucun');
});
it('shows error state', () => {
document.body.innerHTML = `
<div class="error-state">
<p class="error-message">Erreur de connexion au serveur</p>
<button class="btn btn-secondary">Réessayer</button>
</div>
`;
expect(document.querySelector('.error-state')).toBeTruthy();
expect(document.querySelector('.error-message').textContent).toContain('Erreur');
});
});
// ============================================================================
// TESTS: Navigation
// ============================================================================
describe('Page Navigation', () => {
beforeEach(() => {
setupMinimalDOM();
});
it('shows dashboard page by default', () => {
const dashboard = document.getElementById('page-dashboard');
expect(dashboard.classList.contains('active')).toBe(true);
});
it('hides other pages by default', () => {
const hosts = document.getElementById('page-hosts');
const tasks = document.getElementById('page-tasks');
expect(hosts.classList.contains('active')).toBe(false);
expect(tasks.classList.contains('active')).toBe(false);
});
it('can switch pages', () => {
const dashboard = document.getElementById('page-dashboard');
const hosts = document.getElementById('page-hosts');
// Simulate navigation
dashboard.classList.remove('active');
hosts.classList.add('active');
expect(dashboard.classList.contains('active')).toBe(false);
expect(hosts.classList.contains('active')).toBe(true);
});
});
// ============================================================================
// TESTS: Login Screen
// ============================================================================
describe('Login Screen', () => {
beforeEach(() => {
setupMinimalDOM();
});
it('login screen is hidden by default', () => {
const loginScreen = document.getElementById('login-screen');
expect(loginScreen.classList.contains('hidden')).toBe(true);
});
it('main content is visible by default', () => {
const mainContent = document.getElementById('main-content');
expect(mainContent.classList.contains('hidden')).toBe(false);
});
});
+393
View File
@@ -0,0 +1,393 @@
/**
* Tests for main.js - DashboardManager class.
*
* Covers:
* - Authentication flow (login, logout, checkAuthStatus)
* - API calls with auth headers
* - WebSocket connection
* - Data loading and state management
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
setupFetchMock,
createMockResponse,
setupMinimalDOM,
waitForDOM,
MockWebSocket,
localStorageMock
} from './setup.js';
// ============================================================================
// TestDashboardManager - Mock class for testing
// ============================================================================
// Note: main.js is a browser script with side effects (auto-instantiation on DOMContentLoaded).
// We test the logic using this mock class that mirrors the real implementation.
// This ensures tests are fast and don't depend on DOM state.
class TestDashboardManager {
constructor() {
this.apiBase = 'http://localhost';
this.accessToken = localStorage.getItem('accessToken') || null;
this.currentUser = null;
this.setupRequired = false;
this.hosts = [];
this.tasks = [];
this.schedules = [];
this.ws = null;
}
getAuthHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
return headers;
}
async checkAuthStatus() {
try {
const response = await fetch(`${this.apiBase}/api/auth/status`, {
headers: this.getAuthHeaders()
});
if (!response.ok) return false;
const data = await response.json();
this.setupRequired = data.setup_required;
if (data.setup_required) return false;
if (data.authenticated && data.user) {
this.currentUser = data.user;
return true;
}
return false;
} catch {
return false;
}
}
async login(username, password) {
try {
const response = await fetch(`${this.apiBase}/api/auth/login/json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Login failed');
}
const data = await response.json();
this.accessToken = data.access_token;
localStorage.setItem('accessToken', data.access_token);
await this.checkAuthStatus();
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
}
logout() {
this.accessToken = null;
this.currentUser = null;
localStorage.removeItem('accessToken');
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
connectWebSocket() {
const protocol = this.apiBase.startsWith('https') ? 'wss' : 'ws';
const wsUrl = `${protocol}://localhost/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket closed');
};
}
handleWebSocketMessage(message) {
switch (message.type) {
case 'host_updated':
this.updateHostInList(message.data);
break;
case 'task_started':
this.addTaskToList(message.data);
break;
case 'schedule_completed':
this.updateScheduleStatus(message.data);
break;
}
}
updateHostInList(hostData) {
const idx = this.hosts.findIndex(h => h.id === hostData.id);
if (idx >= 0) {
this.hosts[idx] = { ...this.hosts[idx], ...hostData };
}
}
addTaskToList(taskData) {
this.tasks.unshift(taskData);
}
updateScheduleStatus(scheduleData) {
const idx = this.schedules.findIndex(s => s.id === scheduleData.schedule_id);
if (idx >= 0) {
this.schedules[idx].last_status = scheduleData.status;
}
}
}
// ============================================================================
// TESTS: Authentication
// ============================================================================
describe('DashboardManager - Authentication', () => {
let manager;
beforeEach(() => {
setupFetchMock();
manager = new TestDashboardManager();
});
describe('getAuthHeaders', () => {
it('returns headers without auth when no token', () => {
manager.accessToken = null;
const headers = manager.getAuthHeaders();
expect(headers['Content-Type']).toBe('application/json');
expect(headers['Authorization']).toBeUndefined();
});
it('returns headers with Bearer token when authenticated', () => {
manager.accessToken = 'test-token-123';
const headers = manager.getAuthHeaders();
expect(headers['Authorization']).toBe('Bearer test-token-123');
});
});
describe('checkAuthStatus', () => {
it('returns true when authenticated', async () => {
setupFetchMock({
'/api/auth/status': {
setup_required: false,
authenticated: true,
user: { username: 'testuser', role: 'admin' }
}
});
const result = await manager.checkAuthStatus();
expect(result).toBe(true);
expect(manager.currentUser).toEqual({ username: 'testuser', role: 'admin' });
});
it('returns false when setup required', async () => {
setupFetchMock({
'/api/auth/status': {
setup_required: true,
authenticated: false,
user: null
}
});
const result = await manager.checkAuthStatus();
expect(result).toBe(false);
expect(manager.setupRequired).toBe(true);
});
it('returns false when not authenticated', async () => {
setupFetchMock({
'/api/auth/status': {
setup_required: false,
authenticated: false,
user: null
}
});
const result = await manager.checkAuthStatus();
expect(result).toBe(false);
expect(manager.currentUser).toBeNull();
});
it('returns false on network error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const result = await manager.checkAuthStatus();
expect(result).toBe(false);
});
});
describe('login', () => {
it('stores token on successful login', async () => {
setupFetchMock({
'/api/auth/login/json': {
access_token: 'new-jwt-token',
token_type: 'bearer',
expires_in: 86400
},
'/api/auth/status': {
setup_required: false,
authenticated: true,
user: { username: 'testuser' }
}
});
const result = await manager.login('testuser', 'password123');
expect(result).toBe(true);
expect(manager.accessToken).toBe('new-jwt-token');
expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', 'new-jwt-token');
});
it('returns false on invalid credentials', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({ detail: 'Invalid credentials' }, 401)
);
const result = await manager.login('wrong', 'credentials');
expect(result).toBe(false);
expect(manager.accessToken).toBeNull();
consoleSpy.mockRestore();
});
});
describe('logout', () => {
it('clears token and user data', () => {
manager.accessToken = 'some-token';
manager.currentUser = { username: 'test' };
manager.logout();
expect(manager.accessToken).toBeNull();
expect(manager.currentUser).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken');
});
it('closes WebSocket connection', () => {
manager.ws = new MockWebSocket('ws://localhost/ws');
manager.ws.readyState = MockWebSocket.OPEN;
const closeSpy = vi.spyOn(manager.ws, 'close');
manager.logout();
expect(closeSpy).toHaveBeenCalled();
});
});
});
// ============================================================================
// TESTS: WebSocket
// ============================================================================
describe('DashboardManager - WebSocket', () => {
let manager;
beforeEach(() => {
manager = new TestDashboardManager();
});
describe('connectWebSocket', () => {
it('creates WebSocket connection', () => {
manager.connectWebSocket();
expect(manager.ws).toBeInstanceOf(MockWebSocket);
expect(manager.ws.url).toBe('ws://localhost/ws');
});
it('sets up event handlers', async () => {
manager.connectWebSocket();
await waitForDOM(10);
expect(manager.ws.onopen).toBeDefined();
expect(manager.ws.onmessage).toBeDefined();
expect(manager.ws.onclose).toBeDefined();
});
});
describe('handleWebSocketMessage', () => {
beforeEach(() => {
manager.hosts = [{ id: 'host-1', name: 'server1', status: 'unknown' }];
manager.tasks = [];
manager.schedules = [{ id: 'sched-1', name: 'Daily Backup', last_status: 'pending' }];
});
it('handles host_updated event', () => {
manager.handleWebSocketMessage({
type: 'host_updated',
data: { id: 'host-1', status: 'online' }
});
expect(manager.hosts[0].status).toBe('online');
});
it('handles task_started event', () => {
manager.handleWebSocketMessage({
type: 'task_started',
data: { id: 'task-1', playbook: 'test.yml' }
});
expect(manager.tasks.length).toBe(1);
expect(manager.tasks[0].id).toBe('task-1');
});
it('handles schedule_completed event', () => {
manager.handleWebSocketMessage({
type: 'schedule_completed',
data: { schedule_id: 'sched-1', status: 'success' }
});
expect(manager.schedules[0].last_status).toBe('success');
});
});
});
// ============================================================================
// TESTS: Token Persistence
// ============================================================================
describe('DashboardManager - Token Persistence', () => {
it('loads token from localStorage on init', () => {
localStorageMock.setItem('accessToken', 'stored-token');
const manager = new TestDashboardManager();
expect(manager.accessToken).toBe('stored-token');
});
it('starts with null token if localStorage empty', () => {
localStorageMock.clear();
const manager = new TestDashboardManager();
expect(manager.accessToken).toBeNull();
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Setup file for frontend tests with Vitest + jsdom.
*
* This file:
* - Configures the DOM environment
* - Mocks fetch API
* - Mocks WebSocket
* - Mocks localStorage
* - Provides utility functions for tests
*/
import { vi, beforeEach, afterEach } from 'vitest';
// ============================================================================
// MOCK: localStorage
// ============================================================================
const localStorageMock = (() => {
let store = {};
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = String(value);
}),
removeItem: vi.fn((key) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((i) => Object.keys(store)[i] || null),
};
})();
Object.defineProperty(global, 'localStorage', {
value: localStorageMock,
writable: true,
});
// ============================================================================
// MOCK: fetch API
// ============================================================================
/**
* Create a mock fetch response.
* @param {object} data - Response data
* @param {number} status - HTTP status code
* @returns {Response} Mock Response object
*/
export function createMockResponse(data, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(JSON.stringify(data)),
headers: new Headers({ 'Content-Type': 'application/json' }),
};
}
/**
* Setup fetch mock with default responses.
* @param {object} responses - Map of URL patterns to responses
*/
export function setupFetchMock(responses = {}) {
const defaultResponses = {
'/api/auth/status': { setup_required: false, authenticated: true, user: { username: 'test' } },
'/api/hosts': [],
'/api/tasks': [],
'/api/schedules': [],
'/api/playbooks': [],
'/api/health': { status: 'healthy' },
...responses,
};
global.fetch = vi.fn((url, options = {}) => {
const urlPath = new URL(url, 'http://localhost').pathname;
for (const [pattern, response] of Object.entries(defaultResponses)) {
if (urlPath.includes(pattern) || urlPath === pattern) {
return Promise.resolve(createMockResponse(response));
}
}
// Default 404 for unmatched URLs
return Promise.resolve(createMockResponse({ detail: 'Not found' }, 404));
});
return global.fetch;
}
// ============================================================================
// MOCK: WebSocket
// ============================================================================
export class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url) {
this.url = url;
this.readyState = MockWebSocket.CONNECTING;
this.onopen = null;
this.onclose = null;
this.onmessage = null;
this.onerror = null;
this._messageQueue = [];
// Auto-connect after a tick
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) {
this.onopen({ type: 'open' });
}
}, 0);
}
send(data) {
if (this.readyState !== MockWebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
this._messageQueue.push(data);
}
close(code = 1000, reason = '') {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) {
this.onclose({ type: 'close', code, reason });
}
}
// Test helper: simulate receiving a message
_receiveMessage(data) {
if (this.onmessage) {
this.onmessage({
type: 'message',
data: typeof data === 'string' ? data : JSON.stringify(data),
});
}
}
// Test helper: simulate an error
_triggerError(error) {
if (this.onerror) {
this.onerror({ type: 'error', error });
}
}
}
global.WebSocket = MockWebSocket;
// ============================================================================
// DOM UTILITIES
// ============================================================================
/**
* Create a minimal DOM structure for testing.
*/
export function setupMinimalDOM() {
document.body.innerHTML = `
<div id="main-content">
<nav class="nav-sidebar"></nav>
<main class="main-content">
<div id="page-dashboard" class="page-section active"></div>
<div id="page-hosts" class="page-section"></div>
<div id="page-tasks" class="page-section"></div>
<div id="page-schedules" class="page-section"></div>
</main>
</div>
<div id="login-screen" class="hidden"></div>
<div id="setup-screen" class="hidden"></div>
<div id="notification-container"></div>
`;
}
/**
* Wait for DOM updates.
* @param {number} ms - Milliseconds to wait
*/
export function waitForDOM(ms = 0) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ============================================================================
// HOOKS
// ============================================================================
beforeEach(() => {
// Clear localStorage
localStorageMock.clear();
// Reset fetch mock
if (global.fetch) {
global.fetch.mockClear?.();
}
// Clear DOM
document.body.innerHTML = '';
});
afterEach(() => {
vi.clearAllMocks();
});
// ============================================================================
// EXPORTS
// ============================================================================
export { localStorageMock };