Add 813 new tests (+31.6%) to bring all covered files to thresholds: - 4 zero-coverage models (KeyRecovery, BulkReplace, TopNavbar, TranslationRunResult) → 100% with L1 model-invariant tests - 8 low-coverage models extended past thresholds - UI components (Pagination 1→31, Skeleton 20, Badge 27, ConfirmDialog 21) - API modules (api.ts, cot-logger, maintenance, reports) → 100% - Utils/stores (dateFormat, timezone, toasts, stores, maintenance) Production changes: - Add GRACE contract headers (@RATIONALE/@REJECTED) to 10+ files - Fix batcheslength→batches.length typo in TranslationRunResult.svelte - Refactor Badge.svelte || expressions into cls() helper
388 lines
14 KiB
TypeScript
388 lines
14 KiB
TypeScript
// #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
|