mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
090a0d73ac
* feat: add configurable notification dispatch retries Allow each node to set 0-3 extra in-process delivery attempts with a fixed one-second delay for routes, agents, and Test dispatch. * fix: harden notification retry settings load/save and channel tests Guard Delivery retries against failed and out-of-order same-node settings responses, and cover Slack/webhook retry classification alongside Discord. * fix: clear Delivery retries saving state and correct screenshot alt Separate save-request ownership from value-generation invalidation so a successful PATCH cannot leave Save retries stuck on Saving, reset saving on node switch, and align the Channels screenshot alt with the committed image. * fix: surface invalid notification retry settings instead of false saved clamp Align Channels GET handling with the backend strict 0-3 parser so stored values like 9 or 1.5 show as error needing repair, matching runtime fallback to 0 instead of displaying a clamped saved policy.
202 lines
6.9 KiB
TypeScript
202 lines
6.9 KiB
TypeScript
/**
|
|
* Apprise delivery through NotificationService: payloads, status classes,
|
|
* fail-closed malformed config, and dispatch_error recording.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
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: 99,
|
|
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,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getDefaultNodeId: () => 1,
|
|
getComposeDir: () => '/app/compose',
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/StackActivityMetricsService', () => ({
|
|
StackActivityMetricsService: {
|
|
getInstance: () => ({
|
|
record: vi.fn(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import { NotificationService, NotificationDeliveryError } from '../services/NotificationService';
|
|
|
|
const KEYED = 'http://apprise.local/notify/key-secret';
|
|
const STATELESS = 'http://apprise.local/notify';
|
|
|
|
function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 1,
|
|
name: 'Apprise route',
|
|
node_id: null,
|
|
stack_patterns: ['my-app'],
|
|
label_ids: null,
|
|
categories: null,
|
|
levels: null,
|
|
channel_type: 'apprise' as const,
|
|
channel_url: KEYED,
|
|
config: '{}',
|
|
priority: 0,
|
|
enabled: true,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('NotificationService - Apprise delivery', () => {
|
|
let svc: NotificationService;
|
|
let mockFetch: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
|
|
svc = NotificationService.getInstance();
|
|
mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
vi.stubGlobal('fetch', mockFetch);
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
|
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
|
|
mockGetEnabledAgents.mockReturnValue([]);
|
|
mockUpdateNotificationDispatchError.mockClear();
|
|
mockAddNotificationHistory.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('sends keyed payload without tag when tags are empty', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute({ config: '{}' })]);
|
|
await svc.dispatchAlert('warning', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
|
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: 'Sencho Alert [WARNING]',
|
|
body: 'boom',
|
|
type: 'warning',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('sends keyed tag and maps error to failure', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeAppriseRoute({ config: JSON.stringify({ tags: 'ops' }) }),
|
|
]);
|
|
await svc.dispatchAlert('error', 'deploy_failure', 'boom', { stackName: 'my-app' });
|
|
expect(mockFetch).toHaveBeenCalledWith(KEYED, expect.objectContaining({
|
|
body: JSON.stringify({
|
|
title: 'Sencho Alert [ERROR]',
|
|
body: 'boom',
|
|
type: 'failure',
|
|
tag: 'ops',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('sends stateless urls from stored config via agent fallback', async () => {
|
|
mockGetEnabledAgents.mockReturnValue([{
|
|
type: 'apprise',
|
|
url: STATELESS,
|
|
enabled: true,
|
|
config: JSON.stringify({ urls: 'discord://token mailto://a@b.com' }),
|
|
}]);
|
|
await svc.dispatchAlert('info', 'deploy_success', 'ok');
|
|
expect(mockFetch).toHaveBeenCalledWith(STATELESS, expect.objectContaining({
|
|
body: JSON.stringify({
|
|
title: 'Sencho Alert [INFO]',
|
|
body: 'ok',
|
|
type: 'info',
|
|
urls: 'discord://token mailto://a@b.com',
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it('treats HTTP 204 as non-retryable failure and records dispatch_error', async () => {
|
|
mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([makeAppriseRoute()]);
|
|
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
|
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
|
99,
|
|
expect.stringContaining('HTTP 204'),
|
|
);
|
|
const errText = String(mockUpdateNotificationDispatchError.mock.calls[0][1]);
|
|
expect(errText).not.toContain('key-secret');
|
|
});
|
|
|
|
it('classifies 4xx as non-retryable and 5xx as retryable via testDispatch', async () => {
|
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 400 });
|
|
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
|
message: expect.stringContaining('HTTP 400'),
|
|
retryable: false,
|
|
} satisfies Partial<NotificationDeliveryError>);
|
|
|
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 503 });
|
|
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
|
message: expect.stringContaining('HTTP 503'),
|
|
retryable: true,
|
|
});
|
|
});
|
|
|
|
it('wraps network failures as retryable sanitized errors', async () => {
|
|
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
await expect(svc.testDispatch('apprise', KEYED, {})).rejects.toMatchObject({
|
|
message: 'Apprise request failed',
|
|
retryable: true,
|
|
status: null,
|
|
});
|
|
});
|
|
|
|
it('does not fetch when stored config is malformed and records a sanitized error', async () => {
|
|
mockGetEnabledNotificationRoutes.mockReturnValue([
|
|
makeAppriseRoute({ config: '{broken', channel_url: KEYED }),
|
|
]);
|
|
await svc.dispatchAlert('info', 'deploy_success', 'ok', { stackName: 'my-app' });
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
expect(mockUpdateNotificationDispatchError).toHaveBeenCalledWith(
|
|
99,
|
|
expect.stringContaining('Apprise configuration is missing or invalid'),
|
|
);
|
|
expect(String(mockUpdateNotificationDispatchError.mock.calls[0][1])).not.toContain('key-secret');
|
|
});
|
|
});
|