/** * 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(); }); });