// #region MaintenanceStoreTests [C:2] [TYPE Module] [SEMANTICS test, store, maintenance] // @BRIEF Unit tests for MaintenanceStore runes-based store. // @RELATION DEPENDS_ON -> [MaintenanceStore] import { describe, it, expect, vi, beforeEach } from 'vitest'; // Use vi.hoisted to create mock functions before the mock factories const { mockListEvents, mockGetSettings, mockUpdateSettings, mockEndMaintenance, mockEndAllMaintenance, mockListDashboardBanners, mockGetMaintenanceEventsWsUrl } = vi.hoisted(() => ({ mockListEvents: vi.fn(), mockGetSettings: vi.fn(), mockUpdateSettings: vi.fn(), mockEndMaintenance: vi.fn(), mockEndAllMaintenance: vi.fn(), mockListDashboardBanners: vi.fn(), mockGetMaintenanceEventsWsUrl: vi.fn(), })); // Mock WebSocket for connectWs/init tests class MockWebSocket { onopen: (() => void) | null = null; onclose: (() => void) | null = null; onerror: (() => void) | null = null; onmessage: ((event: any) => void) | null = null; readyState = 0; close() { if (this.onclose) this.onclose(); } constructor(_url: string) { setTimeout(() => { if (this.onopen) this.onopen(); }, 0); } } vi.stubGlobal('WebSocket', MockWebSocket); // Mock toasts vi.mock('$lib/toasts.svelte.js', () => ({ addToast: vi.fn() })); // Mock i18n vi.mock('$lib/i18n/index.svelte.js', () => ({ t: { maintenance: {} } })); vi.mock('$lib/cot-logger', () => ({ log: vi.fn() })); vi.mock('$lib/api', () => ({ getMaintenanceEventsWsUrl: mockGetMaintenanceEventsWsUrl, })); vi.mock('$lib/api/maintenance.js', () => ({ listEvents: mockListEvents, getSettings: mockGetSettings, updateSettings: mockUpdateSettings, endMaintenance: mockEndMaintenance, endAllMaintenance: mockEndAllMaintenance, listDashboardBanners: mockListDashboardBanners, })); import { createMaintenanceStore } from '$lib/stores/maintenance.svelte.js'; describe('MaintenanceStore', () => { let store; beforeEach(() => { vi.clearAllMocks(); mockListEvents.mockResolvedValue({ active: [], completed: [] }); mockGetSettings.mockResolvedValue({ target_environment_id: 'prod', display_timezone: 'UTC', banner_template: 'test template', dashboard_scope: 'published_only', excluded_dashboard_ids: [], forced_dashboard_ids: [], updated_at: null }); mockListDashboardBanners.mockResolvedValue([]); store = createMaintenanceStore(); }); it('initializes with empty state', () => { expect(store.activeEvents).toEqual([]); expect(store.completedEvents).toEqual([]); expect(store.settings).toBeNull(); expect(store.dashboardBanners).toEqual([]); expect(store.isLoading).toBe(false); expect(store.error).toBeNull(); }); it('loadEvents calls API and updates state', async () => { mockListEvents.mockResolvedValue({ active: [{ id: 'ev-1', tables: ['raw.sales'], status: 'active' }], completed: [{ id: 'ev-2', tables: ['raw.inventory'], status: 'completed' }] }); await store.loadEvents(); expect(mockListEvents).toHaveBeenCalledTimes(1); expect(store.activeEvents).toHaveLength(1); expect(store.activeEvents[0].id).toBe('ev-1'); expect(store.completedEvents).toHaveLength(1); expect(store.completedEvents[0].id).toBe('ev-2'); }); it('loadEvents sets error on API failure', async () => { mockListEvents.mockRejectedValue(new Error('Network error')); await store.loadEvents(); expect(store.error).toBeTruthy(); expect(store.activeEvents).toEqual([]); }); it('loadSettings calls API and updates settings', async () => { const testSettings = { target_environment_id: 'staging', display_timezone: 'Europe/Moscow', banner_template: 'custom template', dashboard_scope: 'all', excluded_dashboard_ids: [1, 2], forced_dashboard_ids: [3], updated_at: null }; mockGetSettings.mockResolvedValue(testSettings); await store.loadSettings(); expect(store.settings).toEqual(testSettings); }); it('loadSettings does not throw on API failure', async () => { mockGetSettings.mockRejectedValue(new Error('API error')); await expect(store.loadSettings()).resolves.not.toThrow(); expect(store.settings).toBeNull(); }); it('loadDashboardBanners updates state', async () => { const banners = [ { dashboard_id: 42, active: true, events: ['ev-1'], start_time: '2026-05-21T22:00:00Z', end_time: null }, { dashboard_id: 57, active: false, events: [], start_time: null, end_time: null } ]; mockListDashboardBanners.mockResolvedValue(banners); await store.loadDashboardBanners(); expect(mockListDashboardBanners).toHaveBeenCalledTimes(1); expect(store.dashboardBanners).toHaveLength(2); }); it('loadDashboardBanners handles non-array response', async () => { mockListDashboardBanners.mockResolvedValue(null); await store.loadDashboardBanners(); expect(store.dashboardBanners).toEqual([]); }); it('endEvent calls API and refreshes events', async () => { mockEndMaintenance.mockResolvedValue({ task_id: 'task-1', status: 'pending' }); mockListEvents.mockResolvedValue({ active: [], completed: [] }); await store.endEvent('ev-1'); expect(mockEndMaintenance).toHaveBeenCalledWith('ev-1'); expect(mockListEvents).toHaveBeenCalled(); }); it('endAllEvents calls API and refreshes events', async () => { mockEndAllMaintenance.mockResolvedValue({ task_id: 'task-bulk', status: 'pending' }); mockListEvents.mockResolvedValue({ active: [], completed: [] }); await store.endAllEvents(); expect(mockEndAllMaintenance).toHaveBeenCalled(); expect(mockListEvents).toHaveBeenCalled(); }); it('updateSettings calls API and updates settings', async () => { const newSettings = { dashboard_scope: 'all' }; mockUpdateSettings.mockResolvedValue({ ...newSettings, target_environment_id: 'prod', display_timezone: 'UTC', banner_template: 'test', excluded_dashboard_ids: [], forced_dashboard_ids: [], updated_at: '2026-05-22T10:00:00Z' }); await store.updateSettings(newSettings); expect(mockUpdateSettings).toHaveBeenCalledWith(newSettings); expect(store.settings).toBeTruthy(); expect(store.settings.dashboard_scope).toBe('all'); }); it('endEvent handles error without throwing', async () => { mockEndMaintenance.mockRejectedValue(new Error('Maintenance end failed')); await expect(store.endEvent('ev-fail')).rejects.toThrow('Maintenance end failed'); expect(mockListEvents).not.toHaveBeenCalled(); expect(store.error).toBeNull(); // error is only set by loadEvents, not endEvent }); it('endAllEvents handles error without throwing', async () => { mockEndAllMaintenance.mockRejectedValue(new Error('Bulk end failed')); await expect(store.endAllEvents()).rejects.toThrow('Bulk end failed'); expect(mockListEvents).not.toHaveBeenCalled(); }); it('updateSettings handles API failure', async () => { mockUpdateSettings.mockRejectedValue(new Error('Update failed')); store = createMaintenanceStore(); store.isLoading; // initial await expect(store.updateSettings({ dashboard_scope: 'all' })).rejects.toThrow('Update failed'); expect(store.isLoading).toBe(false); }); it('loadDashboardBanners handles API failure gracefully', async () => { mockListDashboardBanners.mockRejectedValue(new Error('Banner API down')); await store.loadDashboardBanners(); expect(store.dashboardBanners).toEqual([]); }); it('loadEvents handles null/undefined active/completed', async () => { mockListEvents.mockResolvedValue({}); await store.loadEvents(); expect(store.activeEvents).toEqual([]); expect(store.completedEvents).toEqual([]); }); it('disconnectWs is safe to call without init', () => { // disconnectWs when _ws is null — should not throw expect(() => store.disconnectWs()).not.toThrow(); }); it('connectWs early return when _ws already exists', async () => { let wsCount = 0; class SingleWs { close = vi.fn(); constructor() { wsCount++; } set onopen(_fn: any) {} get onopen() { return null; } set onclose(_fn: any) {} get onclose() { return null; } set onmessage(_fn: any) {} get onmessage() { return null; } set onerror(_fn: any) {} get onerror() { return null; } } globalThis.WebSocket = SingleWs as any; mockGetMaintenanceEventsWsUrl.mockReturnValue('ws://test/ws'); const freshStore = createMaintenanceStore(); // init is async — await it so connectWs runs before we check wsCount await freshStore.init(); expect(wsCount).toBe(1); // second init should not create a new WS (early return in connectWs) await freshStore.init(); expect(wsCount).toBe(1); }); it('connectWs handles WebSocket constructor throwing', () => { class BrokenWs { constructor() { throw new Error('WS creation failed'); } } globalThis.WebSocket = BrokenWs as any; mockGetMaintenanceEventsWsUrl.mockReturnValue('ws://bad/ws'); // Creating a new store and calling init should not throw const freshStore = createMaintenanceStore(); expect(() => { freshStore.init(); }).not.toThrow(); }); it('loadEvents handles non-Error rejection in catch', async () => { mockListEvents.mockRejectedValue('string error'); await store.loadEvents(); expect(store.error).toBe('Failed to load events'); expect(store.isLoading).toBe(false); }); it('loadDashboardBanners handles non-Error rejection in catch', async () => { mockListDashboardBanners.mockRejectedValue({ code: 500 }); await store.loadDashboardBanners(); expect(store.dashboardBanners).toEqual([]); }); it('loadSettings handles non-Error rejection in catch', async () => { mockGetSettings.mockRejectedValue(null); await store.loadSettings(); expect(store.settings).toBeNull(); }); it('endEvent handles non-Error rejection with fallback message', async () => { const { addToast } = await import('$lib/toasts.svelte.js'); mockEndMaintenance.mockRejectedValue('string error'); try { await store.endEvent('ev-fail'); } catch {} expect(addToast).toHaveBeenCalledWith('Failed to end maintenance event', 'error'); }); it('endAllEvents handles non-Error rejection with fallback message', async () => { const { addToast } = await import('$lib/toasts.svelte.js'); mockEndAllMaintenance.mockRejectedValue('string error'); try { await store.endAllEvents(); } catch {} expect(addToast).toHaveBeenCalledWith('Failed to end all events', 'error'); }); it('WebSocket onerror and onclose callbacks fire', async () => { // Capture WS instance so we can trigger callbacks let wsInstance: any = null; class CallbackWs { close = vi.fn(); onopen: (() => void) | null = null; onclose: ((e: any) => void) | null = null; onerror: (() => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; constructor(_url: string) { wsInstance = this; } } globalThis.WebSocket = CallbackWs as any; // Fake timers so setTimeout doesn't leak vi.useFakeTimers(); const freshStore = createMaintenanceStore(); mockGetMaintenanceEventsWsUrl.mockReturnValue('ws://test/ws'); await freshStore.init(); // Trigger onerror callback expect(() => { wsInstance?.onerror?.(); }).not.toThrow(); // Trigger onclose callback — this also schedules reconnect expect(() => { wsInstance?.onclose?.({ code: 1006 } as CloseEvent); }).not.toThrow(); // After onclose, _ws is set to null internally vi.useRealTimers(); }); it('WebSocket onmessage callback fires and reloads data', async () => { let wsInstance: any = null; class MessageWs { close = vi.fn(); onopen: (() => void) | null = null; onclose: ((e: any) => void) | null = null; onerror: (() => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; constructor(_url: string) { wsInstance = this; } } globalThis.WebSocket = MessageWs as any; mockGetMaintenanceEventsWsUrl.mockReturnValue('ws://test/ws'); const freshStore = createMaintenanceStore(); // Re-setup event mocks for loadEvents/loadDashboardBanners mockListEvents.mockResolvedValue({ active: [{ id: 'ws-event' }], completed: [] }); mockListDashboardBanners.mockResolvedValue([{ dashboard_id: 1, active: true }]); await freshStore.init(); // Simulate a WS message await wsInstance?.onmessage?.({ data: JSON.stringify({ type: 'update' }) } as MessageEvent); // Data should be reloaded after message expect(mockListEvents).toHaveBeenCalled(); expect(mockListDashboardBanners).toHaveBeenCalled(); }); it('endEvent without task_id does not call addToast', async () => { const { addToast } = await import('$lib/toasts.svelte.js'); mockEndMaintenance.mockResolvedValue({ status: 'pending' }); // no task_id mockListEvents.mockResolvedValue({ active: [], completed: [] }); await store.endEvent('ev-no-task'); expect(addToast).not.toHaveBeenCalled(); }); it('endAllEvents without task_id does not call addToast', async () => { const { addToast } = await import('$lib/toasts.svelte.js'); mockEndAllMaintenance.mockResolvedValue({ status: 'completed' }); // no task_id mockListEvents.mockResolvedValue({ active: [], completed: [] }); await store.endAllEvents(); expect(addToast).not.toHaveBeenCalled(); }); }); // #endregion MaintenanceStoreTests