mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
85842cc547
* feat: add service-scoped stack alert rules
Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.
* fix: gate remote scoped alert creates without losing the body
Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.
* fix: bound remote alert body buffer and reject encoded JSON
Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.
* fix: harden service-scoped alert delete, cooldown, and proxy gates
Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.
* test: expect dispatchAlert persisted result in crash-safety cases
Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
628 lines
24 KiB
TypeScript
628 lines
24 KiB
TypeScript
/**
|
|
* Unit tests for Notification Routing — CRUD operations on notification_routes,
|
|
* routing logic in NotificationService, and edge cases.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const {
|
|
mockGetEnabledNotificationRoutes,
|
|
mockGetEnabledNotificationSuppressionRules,
|
|
mockGetEnabledAgents,
|
|
mockGetStackLabelIds,
|
|
mockAddNotificationHistory,
|
|
mockUpdateNotificationDispatchError,
|
|
mockGetGlobalSettings,
|
|
} = vi.hoisted(() => ({
|
|
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
|
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
|
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
|
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
|
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
|
id: 1,
|
|
level: 'info',
|
|
message: 'test',
|
|
timestamp: Date.now(),
|
|
is_read: 0,
|
|
}),
|
|
mockUpdateNotificationDispatchError: vi.fn(),
|
|
mockGetGlobalSettings: vi.fn().mockReturnValue({ notification_dispatch_retries: '0' }),
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
|
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
|
getEnabledAgents: mockGetEnabledAgents,
|
|
getStackLabelIds: mockGetStackLabelIds,
|
|
addNotificationHistory: mockAddNotificationHistory,
|
|
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
|
|
getGlobalSettings: mockGetGlobalSettings,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// NodeRegistry is consulted to resolve this instance's default node id so
|
|
// internal dispatch writes land on the same key the middleware sets for user
|
|
// requests. Mock it to a fixed id so assertions are deterministic.
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getDefaultNodeId: () => 1,
|
|
getComposeDir: () => '/app/compose',
|
|
}),
|
|
},
|
|
}));
|
|
|
|
// Spy on global fetch for webhook dispatch verification
|
|
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
|
|
import { NotificationService } from '../services/NotificationService';
|
|
import { StackActivityMetricsService } from '../services/StackActivityMetricsService';
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
function makeRoute(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 1,
|
|
name: 'Prod Discord',
|
|
node_id: null as number | null,
|
|
stack_patterns: ['my-app'],
|
|
label_ids: null as number[] | null,
|
|
categories: null as string[] | null,
|
|
levels: null as ('info' | 'warning' | 'error')[] | null,
|
|
channel_type: 'discord' as const,
|
|
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
|
priority: 0,
|
|
enabled: true,
|
|
created_at: Date.now(),
|
|
updated_at: Date.now(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeAgent(type: 'discord' | 'slack' | 'webhook' = 'slack') {
|
|
return {
|
|
id: 1,
|
|
type,
|
|
url: 'https://hooks.slack.com/services/global',
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
describe('NotificationService - routing logic', () => {
|
|
let svc: NotificationService;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset singleton so each test gets a fresh instance
|
|
(NotificationService as any).instance = undefined;
|
|
svc = NotificationService.getInstance();
|
|
});
|
|
|
|
it('routes to matching route channel and skips global agents', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
|
|
|
// Should have called fetch with discord webhook URL
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
// Should NOT have called the global slack agent
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('falls back to global agents when no route matches', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: ['other-stack'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
|
|
|
// Should NOT have called the route's discord channel
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything()
|
|
);
|
|
// Should have called global slack agent as fallback
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('falls back to global agents when no stackName provided and route requires a specific stack', async () => {
|
|
// Route has a stack_patterns filter, so it won't match an alert with no stackName
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ stack_patterns: ['my-app'] })]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('warning', 'monitor_alert', 'Host CPU high');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('respects priority ordering — first match wins', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ id: 1, name: 'High priority', priority: 0, stack_patterns: ['my-app'], channel_url: 'https://discord.com/api/webhooks/first' }),
|
|
makeRoute({ id: 2, name: 'Low priority', priority: 10, stack_patterns: ['my-app'], channel_url: 'https://discord.com/api/webhooks/second' }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'my-app' });
|
|
|
|
// Both routes match, both should be dispatched (all matching routes fire)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/first',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/second',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
// Global agents should still be skipped since routes matched
|
|
expect(mockGetEnabledAgents).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips routes that do not match the stack', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: ['staging-app'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'production-app' });
|
|
|
|
// Route should not fire
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything()
|
|
);
|
|
// Global agent should fire as fallback
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('handles multiple stack patterns in a single route', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: ['app-a', 'app-b', 'app-c'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('info', 'image_update_applied', 'Update complete', { stackName: 'app-b' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('gracefully handles fetch errors in route dispatch without crashing', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
|
mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
|
|
|
|
// Should not throw
|
|
await expect(svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' })).resolves.toEqual({ persisted: true });
|
|
});
|
|
|
|
it('does not dispatch to global agents when routes array is empty and no stackName', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('info', 'system', 'Test');
|
|
|
|
// No routes, no agents — just logs and broadcasts
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('always logs to history regardless of routing', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
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,
|
|
container_name: undefined,
|
|
actor_username: null,
|
|
});
|
|
});
|
|
|
|
it('persists stackName and containerName context on the history row', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
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',
|
|
container_name: 'my-app-web-1',
|
|
actor_username: null,
|
|
});
|
|
});
|
|
|
|
it('dispatches to slack channel type correctly via route', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ channel_type: 'slack', channel_url: 'https://hooks.slack.com/services/route-specific' }),
|
|
]);
|
|
|
|
await svc.dispatchAlert('warning', 'monitor_alert', 'Alert', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/route-specific',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: expect.stringContaining('Alert'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('dispatches to webhook channel type correctly via route', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ channel_type: 'webhook', channel_url: 'https://example.com/hook' }),
|
|
]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Critical failure', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://example.com/hook',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: expect.stringContaining('Critical failure'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('records dispatch errors when route webhook fails', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
|
mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Test', { stackName: 'my-app' });
|
|
|
|
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
|
1, // notification id from mock
|
|
expect.stringContaining('Discord webhook request failed')
|
|
);
|
|
});
|
|
|
|
it('records dispatch errors when global agent webhook fails', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
mockFetch.mockRejectedValueOnce(new Error('Timeout'));
|
|
|
|
await svc.dispatchAlert('warning', 'monitor_alert', 'Host alert');
|
|
|
|
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
|
1,
|
|
expect.stringContaining('Slack webhook request failed')
|
|
);
|
|
});
|
|
|
|
it('does not record dispatch errors when all dispatches succeed', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
|
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
|
|
await svc.dispatchAlert('info', 'system', 'All good', { stackName: 'my-app' });
|
|
|
|
expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fires a node-scoped route when node_id matches the local node', async () => {
|
|
// getDefaultNodeId returns 1 (mocked above)
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 1 })]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('info', 'system', 'Test', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('skips a node-scoped route when node_id does not match the local node', async () => {
|
|
// getDefaultNodeId returns 1; route is scoped to node 99
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 99 })]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('info', 'system', 'Test', { stackName: 'my-app' });
|
|
|
|
// Route should be skipped; falls back to global agent
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything()
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('fires a null-scoped route regardless of which node emits the alert', async () => {
|
|
// node_id=null means "any node"
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: null })]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('warning', 'monitor_alert', 'Global alert', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('routes by severity when levels is set; mismatches fall back to agents', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], levels: ['error'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('info', 'monitor_alert', 'Info only', { stackName: 'my-app' });
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything(),
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
|
|
vi.clearAllMocks();
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], levels: ['error'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Error route', { stackName: 'my-app' });
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
|
|
it('fails closed on invalid stored route patterns without blocking agent fallback', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: ['****'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('error', 'monitor_alert', 'Bad pattern', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything(),
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
|
|
it('fires a category-only route when category matches', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('error', 'deploy_failure', 'Deploy failed', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('skips a category-only route when category does not match', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('info', 'deploy_success', 'Deploy ok', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything()
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('fires a label-only route when stack has a matching label', async () => {
|
|
// Stack 'my-app' on node 1 has label id 42
|
|
mockGetStackLabelIds.mockReturnValue([42]);
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], label_ids: [42] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('info', 'image_update_available', 'Update ready', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('skips a label-only route when stack does not have a matching label', async () => {
|
|
mockGetStackLabelIds.mockReturnValue([99]);
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], label_ids: [42] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await svc.dispatchAlert('info', 'image_update_available', 'Update ready', { stackName: 'my-app' });
|
|
|
|
expect(mockFetch).not.toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.anything()
|
|
);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/services/global',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('AND semantics: combined label + category route fires only when both match', async () => {
|
|
mockGetStackLabelIds.mockReturnValue([42]);
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], label_ids: [42], categories: ['deploy_failure'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
mockGetStackLabelIds.mockReturnValueOnce([99]);
|
|
await svc.dispatchAlert('error', 'deploy_failure', 'Deploy fail 1', { stackName: 'my-app' });
|
|
expect(mockFetch).not.toHaveBeenCalledWith('https://discord.com/api/webhooks/123/abc', expect.anything());
|
|
|
|
vi.clearAllMocks();
|
|
mockGetStackLabelIds.mockReturnValue([42]);
|
|
await svc.dispatchAlert('info', 'deploy_success', 'Deploy ok', { stackName: 'my-app' });
|
|
expect(mockFetch).not.toHaveBeenCalledWith('https://discord.com/api/webhooks/123/abc', expect.anything());
|
|
|
|
vi.clearAllMocks();
|
|
// Both match
|
|
mockGetStackLabelIds.mockReturnValue([42]);
|
|
await svc.dispatchAlert('error', 'deploy_failure', 'Deploy fail 2', { stackName: 'my-app' });
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('category-only route with no stackName matches alert from any emission', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeRoute({ stack_patterns: [], categories: ['system'] }),
|
|
]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
|
|
await svc.dispatchAlert('info', 'system', 'Host rebooted');
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://discord.com/api/webhooks/123/abc',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('NotificationService - crash safety (dispatchAlert never rejects)', () => {
|
|
let svc: NotificationService;
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(NotificationService as any).instance = undefined;
|
|
svc = NotificationService.getInstance();
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it('resolves (does not reject) and skips dispatch when the history write throws', async () => {
|
|
const recordSpy = vi.spyOn(StackActivityMetricsService.getInstance(), 'record');
|
|
mockAddNotificationHistory.mockImplementationOnce(() => {
|
|
throw new Error('database is locked');
|
|
});
|
|
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
|
|
|
await expect(
|
|
svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }),
|
|
).resolves.toEqual({ persisted: false });
|
|
|
|
// No external dispatch and no routing read when there is no persisted row.
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
expect(mockGetEnabledNotificationRoutes).not.toHaveBeenCalled();
|
|
// The write failure is recorded for metrics and logged.
|
|
expect(recordSpy).toHaveBeenCalledWith(1, 'write', expect.any(Number), false);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('[Notify] Failed to persist notification:', expect.any(Error));
|
|
recordSpy.mockRestore();
|
|
});
|
|
|
|
it('records a successful write metric on the happy path', async () => {
|
|
const recordSpy = vi.spyOn(StackActivityMetricsService.getInstance(), 'record');
|
|
|
|
await svc.dispatchAlert('info', 'system', 'Host rebooted');
|
|
|
|
expect(recordSpy).toHaveBeenCalledWith(1, 'write', expect.any(Number), true);
|
|
recordSpy.mockRestore();
|
|
});
|
|
|
|
it('resolves (does not reject) when reading routes throws after a successful write', async () => {
|
|
mockGetEnabledNotificationRoutes.mockImplementationOnce(() => {
|
|
throw new Error('database read failed');
|
|
});
|
|
|
|
await expect(
|
|
svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }),
|
|
).resolves.toEqual({ persisted: true });
|
|
|
|
// The history row was still written; only routing failed, and it was logged.
|
|
expect(mockAddNotificationHistory).toHaveBeenCalledTimes(1);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('[Notify] dispatchAlert failed:', expect.any(Error));
|
|
});
|
|
|
|
it('resolves (does not reject) when a subscriber send throws during broadcast', async () => {
|
|
// A subscriber whose socket reports OPEN but throws on send exercises the
|
|
// broadcast leg of the outer guard.
|
|
const throwingWs = {
|
|
readyState: 1, // WebSocket.OPEN
|
|
send: () => { throw new Error('socket write failed'); },
|
|
} as unknown as import('ws').WebSocket;
|
|
svc.subscribe(throwingWs);
|
|
|
|
await expect(
|
|
svc.dispatchAlert('info', 'system', 'Host rebooted'),
|
|
).resolves.toEqual({ persisted: true });
|
|
|
|
expect(mockAddNotificationHistory).toHaveBeenCalledTimes(1);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('[Notify] dispatchAlert failed:', expect.any(Error));
|
|
});
|
|
|
|
it('snapshots subscribers so an unsubscribe during a send does not skip later subscribers', async () => {
|
|
const sent: string[] = [];
|
|
let unsubscribeB: () => void = () => {};
|
|
const wsB = {
|
|
readyState: 1, // WebSocket.OPEN
|
|
send: () => { sent.push('B'); },
|
|
} as unknown as import('ws').WebSocket;
|
|
// A's send removes B mid-iteration, mimicking a 'close' handler firing.
|
|
const wsA = {
|
|
readyState: 1,
|
|
send: () => { sent.push('A'); unsubscribeB(); },
|
|
} as unknown as import('ws').WebSocket;
|
|
svc.subscribe(wsA);
|
|
unsubscribeB = svc.subscribe(wsB);
|
|
|
|
await svc.dispatchAlert('info', 'system', 'Host rebooted');
|
|
|
|
// B still receives the broadcast because iteration runs over a snapshot.
|
|
expect(sent).toEqual(['A', 'B']);
|
|
});
|
|
});
|