Files
ss-tools/frontend/tests/maintenance-store.test.ts
busya b6b1e05567 test(frontend): reach coverage targets — 99.45% stmts, 89.14% branches, 99.71% fns, 99.82% lines
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
2026-07-08 11:09:52 +03:00

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