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
This commit is contained in:
@@ -5,15 +5,28 @@
|
||||
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 } = vi.hoisted(() => ({
|
||||
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()
|
||||
@@ -24,6 +37,14 @@ 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,
|
||||
@@ -170,5 +191,197 @@ describe('MaintenanceStore', () => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user