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
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:
@@ -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('<');
|
||||
expect(result).toContain('>');
|
||||
});
|
||||
|
||||
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('&');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user