feat(notifications): add structured category enum to dispatcher and history (#774)

Introduce a NotificationCategory string-literal union (11 values) and
thread it through dispatchAlert as a required second argument. All
callers (DockerEventService, AutoHealService, ImageUpdateService,
MonitorService, PolicyEnforcement, policyGate, SchedulerService,
imageUpdates route) pass an explicit category at every call site,
giving TypeScript compile-time enforcement that no new emit site can
be added without choosing a category.

DatabaseService gains an idempotent migration that adds a nullable
category TEXT column to notification_history; existing rows keep
category=NULL (displayed as Uncategorized in the UI). The
getNotificationHistory method accepts an optional category filter
that is forwarded from the GET /api/notifications/history route via
a ?category= query param.

NotificationPanel gains a category Select dropdown so users can
filter history by category. The frontend types mirror the backend
union so API responses are type-safe end-to-end.

All 75 test files (1410 tests) updated to the new 4-arg dispatchAlert
signature and passing.
This commit is contained in:
Anso
2026-04-25 13:55:07 -04:00
committed by GitHub
parent a74564fd61
commit 44dba59cab
20 changed files with 250 additions and 134 deletions
@@ -124,9 +124,9 @@ describe('DockerEventService - die classification', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'web',
expect.objectContaining({ containerName: 'web' }),
);
});
@@ -149,7 +149,7 @@ describe('DockerEventService - die classification', () => {
await vi.advanceTimersByTimeAsync(600);
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('Crash'));
typeof c[2] === 'string' && c[2].includes('Crash'));
expect(crashCall).toBeUndefined();
});
@@ -174,7 +174,7 @@ describe('DockerEventService - die classification', () => {
await vi.advanceTimersByTimeAsync(400); // total > 500ms
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('Crash'));
typeof c[2] === 'string' && c[2].includes('Crash'));
expect(crashCall).toBeUndefined();
});
@@ -192,9 +192,9 @@ describe('DockerEventService - die classification', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('OOM Kill'),
undefined,
'hog',
expect.objectContaining({ containerName: 'hog' }),
);
});
@@ -225,9 +225,9 @@ describe('DockerEventService - die classification', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Healthcheck failed'),
undefined,
'api',
expect.objectContaining({ containerName: 'api' }),
);
});
@@ -291,15 +291,15 @@ describe('DockerEventService - rate limiting', () => {
await vi.advanceTimersByTimeAsync(600);
const crashCalls = mockDispatchAlert.mock.calls.filter(c =>
typeof c[1] === 'string' && c[1].includes('Crash'));
typeof c[2] === 'string' && c[2].includes('Crash'));
expect(crashCalls).toHaveLength(20);
// After the rate window, a summary warning fires.
await vi.advanceTimersByTimeAsync(61_000);
const summaryCalls = mockDispatchAlert.mock.calls.filter(c =>
typeof c[1] === 'string' && c[1].includes('additional containers crashed'));
typeof c[2] === 'string' && c[2].includes('additional containers crashed'));
expect(summaryCalls).toHaveLength(1);
expect(summaryCalls[0][1]).toContain('2 additional');
expect(summaryCalls[0][2]).toContain('2 additional');
});
});
@@ -320,9 +320,9 @@ describe('DockerEventService - malformed payloads', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'ok',
expect.objectContaining({ containerName: 'ok' }),
);
});
});
@@ -375,7 +375,7 @@ describe('DockerEventService - reconciliation', () => {
await Promise.resolve();
const massCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('daemon interruption'));
typeof c[2] === 'string' && c[2].includes('daemon interruption'));
expect(massCall).toBeDefined();
});
@@ -415,9 +415,9 @@ describe('DockerEventService - reconciliation', () => {
await Promise.resolve();
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('crashed-app'));
typeof c[2] === 'string' && c[2].includes('crashed-app'));
expect(crashCall).toBeDefined();
expect(crashCall?.[2]).toBe('my-stack');
expect(crashCall?.[3]).toMatchObject({ stackName: 'my-stack' });
});
});
@@ -443,8 +443,8 @@ describe('DockerEventService - reconnect', () => {
const warn = mockDispatchAlert.mock.calls.find(c => c[0] === 'warning');
const info = mockDispatchAlert.mock.calls.find(c => c[0] === 'info');
expect(warn?.[1]).toContain('Lost connection');
expect(info?.[1]).toContain('Reconnected');
expect(warn?.[2]).toContain('Lost connection');
expect(info?.[2]).toContain('Reconnected');
});
it('shutdown cancels pending reconnect', async () => {
@@ -504,9 +504,9 @@ describe('DockerEventService - hardening', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'app',
expect.objectContaining({ containerName: 'app' }),
);
});
@@ -532,9 +532,9 @@ describe('DockerEventService - hardening', () => {
await Promise.resolve();
const oomCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('OOM Kill'));
typeof c[2] === 'string' && c[2].includes('OOM Kill'));
const crashCall = mockDispatchAlert.mock.calls.find(c =>
typeof c[1] === 'string' && c[1].includes('Crash Detected'));
typeof c[2] === 'string' && c[2].includes('Crash Detected'));
expect(oomCall).toBeDefined();
expect(crashCall).toBeUndefined();
});
@@ -558,9 +558,9 @@ describe('DockerEventService - hardening', () => {
// Inspect failed, so classification stays as the original 'crash'.
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
undefined,
'ephemeral',
expect.objectContaining({ containerName: 'ephemeral' }),
);
});
@@ -585,9 +585,9 @@ describe('DockerEventService - hardening', () => {
await vi.advanceTimersByTimeAsync(700);
const crashCalls = mockDispatchAlert.mock.calls.filter(c =>
typeof c[1] === 'string' && c[1].includes('Crash Detected'));
typeof c[2] === 'string' && c[2].includes('Crash Detected'));
expect(crashCalls).toHaveLength(1);
expect(crashCalls[0][1]).toContain('Code: 2');
expect(crashCalls[0][2]).toContain('Code: 2');
});
});
@@ -385,8 +385,9 @@ services:
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'image_update_available',
expect.stringContaining('stackA'),
'stackA',
{ stackName: 'stackA' },
);
expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(1, 'stackA', true, expect.any(Number));
});
@@ -413,7 +414,7 @@ services:
await (service as any).checkNode(1, 'local', fakeDb());
expect(mockDispatchAlert).toHaveBeenCalledTimes(2);
const dispatched = mockDispatchAlert.mock.calls.map(call => call[2]);
const dispatched = mockDispatchAlert.mock.calls.map(call => (call[3] as any)?.stackName);
expect(dispatched).toEqual(expect.arrayContaining(['stackA', 'stackB']));
expect(mockSetSystemState).toHaveBeenCalledWith('image_update_notifications_backfilled', '1');
+16 -16
View File
@@ -274,7 +274,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
});
it('does not dispatch when CPU below threshold', async () => {
@@ -283,7 +283,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '50' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
});
it('dispatches RAM warning when over threshold', async () => {
@@ -292,7 +292,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_ram_limit: '80' });
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Memory'), undefined);
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Memory'), { stackName: undefined });
});
it('dispatches disk warning when over threshold', async () => {
@@ -301,7 +301,7 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_disk_limit: '90' });
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Disk'), undefined);
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Disk'), { stackName: undefined });
});
it('skips host limits when threshold is 0 or NaN', async () => {
@@ -309,10 +309,10 @@ describe('MonitorService - evaluateGlobalSettings', () => {
const svc = MonitorService.getInstance();
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: '0' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
await (svc as any).evaluateGlobalSettings({ host_cpu_limit: 'abc' });
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), undefined);
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: undefined });
});
});
@@ -352,7 +352,7 @@ describe('MonitorService - breach state machine', () => {
const svc = MonitorService.getInstance();
await (svc as any).evaluate();
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), 'my-stack');
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('CPU'), { stackName: 'my-stack' });
expect(mockUpdateStackAlertLastFired).toHaveBeenCalledWith(1, expect.any(Number));
});
@@ -526,7 +526,7 @@ describe('MonitorService - restart_count metric', () => {
expect(mockGetContainerRestartCount).toHaveBeenCalledWith('c1');
// restart_count=5 > threshold=3, should fire
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('Restart count'), 'my-stack');
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Restart count'), { stackName: 'my-stack' });
});
it('skips Docker inspect when no restart_count rules exist', async () => {
@@ -545,7 +545,7 @@ describe('MonitorService - restart_count metric', () => {
await (svc as any).evaluate();
expect(mockGetContainerRestartCount).toHaveBeenCalledWith('c1');
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', expect.stringContaining('Restart count'), expect.anything());
expect(mockDispatchAlert).not.toHaveBeenCalledWith('warning', 'monitor_alert', expect.stringContaining('Restart count'), expect.anything());
});
});
@@ -577,9 +577,9 @@ describe('MonitorService - Sencho version check', () => {
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluate();
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('0.46.0'));
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0'));
// Message must include the real running version, not "0.0.0".
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('currently running 0.45.0'));
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('currently running 0.45.0'));
expect(mockSetSystemState).toHaveBeenCalledWith('last_sencho_update_notified_version', '0.46.0');
});
@@ -593,7 +593,7 @@ describe('MonitorService - Sencho version check', () => {
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('0.46.0'));
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0'));
});
it('handles version check failure gracefully', async () => {
@@ -605,7 +605,7 @@ describe('MonitorService - Sencho version check', () => {
// Should not throw
await expect((svc as any).evaluate()).resolves.toBeUndefined();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('available'));
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('available'));
});
it('respects the 6-hour cooldown interval', async () => {
@@ -632,7 +632,7 @@ describe('MonitorService - Sencho version check', () => {
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluate();
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', expect.stringContaining('0.46.0'));
expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0'));
expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything());
// Should not have even attempted the lookup.
expect(mockGetLatestVersion).not.toHaveBeenCalled();
@@ -675,7 +675,7 @@ describe('MonitorService - Sencho version check', () => {
expect(mockGetLatestVersion).not.toHaveBeenCalled();
// Exactly one dispatch across both evals.
const availabilityDispatches = mockDispatchAlert.mock.calls.filter(
(args: unknown[]) => typeof args[1] === 'string' && args[1].includes('available'),
(args: unknown[]) => typeof args[2] === 'string' && args[2].includes('available'),
);
expect(availabilityDispatches).toHaveLength(1);
});
@@ -691,7 +691,7 @@ describe('MonitorService - Sencho version check', () => {
(svc as any).lastVersionCheckAt = 0;
await (svc as any).evaluate();
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('0.47.0'));
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.47.0'));
expect(store.last_sencho_update_notified_version).toBe('0.47.0');
});
});
@@ -94,7 +94,7 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('error', 'Container crashed', 'my-app');
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
// Should have called fetch with discord webhook URL
expect(mockFetch).toHaveBeenCalledWith(
@@ -114,7 +114,7 @@ describe('NotificationService - routing logic', () => {
]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('error', 'Container crashed', 'my-app');
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
// Should NOT have called the route's discord channel
expect(mockFetch).not.toHaveBeenCalledWith(
@@ -132,7 +132,7 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('warning', 'Host CPU high');
await svc.dispatchAlert('warning', 'monitor_alert', 'Host CPU high');
// Should have called global agent (no stackName means skip routing)
expect(mockFetch).toHaveBeenCalledWith(
@@ -148,7 +148,7 @@ describe('NotificationService - routing logic', () => {
]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('error', 'Test', 'my-app');
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'my-app' });
// Both routes match, both should be dispatched (all matching routes fire)
expect(mockFetch).toHaveBeenCalledWith(
@@ -169,7 +169,7 @@ describe('NotificationService - routing logic', () => {
]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('error', 'Test', 'production-app');
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'production-app' });
// Route should not fire
expect(mockFetch).not.toHaveBeenCalledWith(
@@ -189,7 +189,7 @@ describe('NotificationService - routing logic', () => {
]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('info', 'Update complete', 'app-b');
await svc.dispatchAlert('info', 'image_update_applied', 'Update complete', { stackName: 'app-b' });
expect(mockFetch).toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
@@ -202,14 +202,14 @@ describe('NotificationService - routing logic', () => {
mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
// Should not throw
await expect(svc.dispatchAlert('error', 'Crash', 'my-app')).resolves.toBeUndefined();
await expect(svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' })).resolves.toBeUndefined();
});
it('does not dispatch to global agents when routes array is empty and no stackName', async () => {
mockGetEnabledNotificationRoutes.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('info', 'Test');
await svc.dispatchAlert('info', 'system', 'Test');
// No routes, no agents — just logs and broadcasts
expect(mockFetch).not.toHaveBeenCalled();
@@ -219,10 +219,11 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledNotificationRoutes.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('info', 'Should be logged');
await svc.dispatchAlert('info', 'system', 'Should be logged');
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
level: 'info',
category: 'system',
message: 'Should be logged',
timestamp: expect.any(Number),
stack_name: undefined,
@@ -234,10 +235,11 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledNotificationRoutes.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1');
await svc.dispatchAlert('warning', 'autoheal_triggered', 'Restarted', { stackName: 'my-app', containerName: 'my-app-web-1' });
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
level: 'warning',
category: 'autoheal_triggered',
message: 'Restarted',
timestamp: expect.any(Number),
stack_name: 'my-app',
@@ -250,7 +252,7 @@ describe('NotificationService - routing logic', () => {
makeRoute({ channel_type: 'slack', channel_url: 'https://hooks.slack.com/services/route-specific' }),
]);
await svc.dispatchAlert('warning', 'Alert', 'my-app');
await svc.dispatchAlert('warning', 'monitor_alert', 'Alert', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(
'https://hooks.slack.com/services/route-specific',
@@ -266,7 +268,7 @@ describe('NotificationService - routing logic', () => {
makeRoute({ channel_type: 'webhook', channel_url: 'https://example.com/hook' }),
]);
await svc.dispatchAlert('error', 'Critical failure', 'my-app');
await svc.dispatchAlert('error', 'monitor_alert', 'Critical failure', { stackName: 'my-app' });
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/hook',
@@ -281,7 +283,7 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
await svc.dispatchAlert('error', 'Test', 'my-app');
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'my-app' });
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
1, // notification id from mock
@@ -294,7 +296,7 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
mockFetch.mockRejectedValueOnce(new Error('Timeout'));
await svc.dispatchAlert('warning', 'Host alert');
await svc.dispatchAlert('warning', 'monitor_alert', 'Host alert');
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
1,
@@ -306,7 +308,7 @@ describe('NotificationService - routing logic', () => {
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
mockFetch.mockResolvedValueOnce({ ok: true });
await svc.dispatchAlert('info', 'All good', 'my-app');
await svc.dispatchAlert('info', 'system', 'All good', { stackName: 'my-app' });
expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled();
});
@@ -161,7 +161,8 @@ describe('enforcePolicyPreDeploy', () => {
expect(result.violations).toEqual([]);
expect(notificationStub.dispatchAlert).toHaveBeenCalledTimes(1);
expect(notificationStub.dispatchAlert.mock.calls[0][0]).toBe('warning');
expect(notificationStub.dispatchAlert.mock.calls[0][1]).toContain('Trivy not installed');
expect(notificationStub.dispatchAlert.mock.calls[0][1]).toBe('scan_finding');
expect(notificationStub.dispatchAlert.mock.calls[0][2]).toContain('Trivy not installed');
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
@@ -162,11 +162,11 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning');
expect(warningCalls).toHaveLength(2);
expect(warningCalls[0][1]).toContain('prod-high-gate');
expect(warningCalls[0][1]).toContain('nginx:1.14');
expect(warningCalls[0][1]).toContain('CRITICAL');
expect(warningCalls[0][1]).toContain('HIGH');
expect(warningCalls[1][1]).toContain('redis:6');
expect(warningCalls[0][2]).toContain('prod-high-gate');
expect(warningCalls[0][2]).toContain('nginx:1.14');
expect(warningCalls[0][2]).toContain('CRITICAL');
expect(warningCalls[0][2]).toContain('HIGH');
expect(warningCalls[1][2]).toContain('redis:6');
});
it('does not dispatch any policy alert when no violations occur', async () => {
@@ -177,7 +177,7 @@ describe('SchedulerService - scheduled scan policy alerts', () => {
await svc.triggerTask(300);
const warningCalls = mockDispatchAlert.mock.calls.filter(
(c) => c[0] === 'warning' && typeof c[1] === 'string' && c[1].includes('Policy'),
(c) => c[0] === 'warning' && typeof c[2] === 'string' && c[2].includes('Policy'),
);
expect(warningCalls).toHaveLength(0);
});
+21 -12
View File
@@ -870,7 +870,7 @@ describe('SchedulerService - error handling', () => {
const svc = SchedulerService.getInstance();
await svc.triggerTask(91);
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('failed'), undefined);
expect(mockDispatchAlert).toHaveBeenCalledWith('error', 'system', expect.stringContaining('failed'), { stackName: undefined });
});
it('dispatches recovery notification when previous status was failure', async () => {
@@ -890,7 +890,7 @@ describe('SchedulerService - error handling', () => {
const svc = SchedulerService.getInstance();
await svc.triggerTask(92);
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('recovered'), 'my-stack');
expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('recovered'), { stackName: 'my-stack' });
});
});
@@ -946,13 +946,15 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'scan_finding',
expect.stringContaining('nightly-scan'),
undefined,
{ stackName: undefined },
);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'scan_finding',
expect.stringContaining('Scanned 3 image(s)'),
undefined,
{ stackName: undefined },
);
});
@@ -965,8 +967,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'warning',
'scan_finding',
expect.stringContaining('2 failed'),
undefined,
{ stackName: undefined },
);
});
@@ -979,8 +982,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'scan_finding',
expect.stringContaining('completed'),
'web-stack',
{ stackName: 'web-stack' },
);
});
@@ -998,8 +1002,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'scan_finding',
expect.stringContaining('recovered-scan'),
undefined,
{ stackName: undefined },
);
});
@@ -1032,8 +1037,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'system',
expect.stringMatching(/failed.*Trivy/i),
'payment-stack',
{ stackName: 'payment-stack' },
);
});
@@ -1046,7 +1052,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
const svc = SchedulerService.getInstance();
await svc.triggerTask(206);
const message = mockDispatchAlert.mock.calls[0][1] as string;
const message = mockDispatchAlert.mock.calls[0][2] as string;
expect(message).toContain('2 critical');
expect(message).toContain('5 high');
expect(message).toContain('10 medium');
@@ -1061,8 +1067,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'scan_finding',
expect.stringContaining('No images to scan'),
undefined,
{ stackName: undefined },
);
});
@@ -1075,8 +1082,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'scan_finding',
expect.stringContaining('All 12 image(s) already scanned recently'),
undefined,
{ stackName: undefined },
);
});
@@ -1196,8 +1204,9 @@ describe('SchedulerService - invalid cron at execution time', () => {
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'system',
expect.stringContaining('failed'),
undefined
{ stackName: undefined },
);
});
});