From f0369fd642b0266d0833f17cc5c48490f14f6df8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:33:30 +0100 Subject: [PATCH] test(web): verify recovery preserves destination edits Exercise the actual destinations tab, delivery hooks and SMTP editor across rejected and accepted Retry and Dismiss requests. Guard input identity, focus, dirty state, feedback and retained history rendering as health reconciles; standalone feedback tests did not cover this integration. Change-source: pulse-maintainer --- .../DestinationsTab.recoveryediting.test.tsx | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx diff --git a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx new file mode 100644 index 000000000..39d6a8be2 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx @@ -0,0 +1,221 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationsAPI, type NotificationHealth } from '@/api/notifications'; +import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n'; +import { DestinationsTab } from '../tabs/DestinationsTab'; +import type { UIAppriseConfig, UIEmailConfig } from '../types'; + +vi.mock('@/api/notifications', () => ({ + NotificationsAPI: { + getHealth: vi.fn(), + getDeliveryLog: vi.fn(), + retryTerminalFailures: vi.fn(), + dismissTerminalFailures: vi.fn(), + }, +})); +vi.mock('@/api/alerts', () => ({ AlertsAPI: { getEvents: vi.fn().mockResolvedValue([]) } })); +vi.mock('@/stores/notifications', () => ({ + notificationStore: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, +})); +vi.mock('@/utils/logger', () => ({ logger: { error: vi.fn() } })); +vi.mock('@/stores/license', () => ({ hasFeature: () => false })); +vi.mock('@/stores/licenseCommercial', () => ({ getUpgradeActionDestination: () => null })); +vi.mock('@/stores/sessionPresentationPolicy', () => ({ + presentationPolicyHidesUpgradePrompts: () => true, +})); +vi.mock('@/stores/alertsActivation', () => ({ + useAlertsActivation: () => ({ config: () => null }), +})); +// Keep the actual tab, delivery hooks, health/log/feedback cards and SMTP editor. +// Unrelated destination transports and their independent network calls are excluded. +vi.mock('../AlertAppriseDestinationsSection', () => ({ + AlertAppriseDestinationsSection: () => null, +})); +vi.mock('../AlertWebhookDestinationsSection', () => ({ + AlertWebhookDestinationsSection: () => null, +})); +vi.mock('../AlertDeadManDestinationSection', () => ({ + AlertDeadManDestinationSection: () => null, +})); +vi.mock('../AlertPushDestinationsSection', () => ({ AlertPushDestinationsSection: () => null })); + +const buildEmailConfig = (): UIEmailConfig => ({ + enabled: true, + from: 'pulse@example.com', + maxRetries: 3, + password: '', + port: 587, + provider: 'smtp', + rateLimit: 60, + replyTo: '', + retryDelay: 5, + server: 'smtp.example.com', + startTLS: true, + tls: true, + to: ['alerts@example.com'], + username: 'ops@example.com', +}); + +const buildAppriseConfig = (): UIAppriseConfig => ({ + apiKey: '', + apiKeyHeader: 'X-API-KEY', + cliPath: '/usr/local/bin/apprise', + configKey: '', + enabled: true, + hasApiKey: false, + mode: 'cli', + serverUrl: '', + skipTlsVerify: false, + targetsText: 'mailto://alerts@example.com', + timeoutSeconds: 20, +}); + +function degradedHealth(): NotificationHealth { + return { + overallHealthy: false, + queue: { + pending: 0, + sending: 0, + sent: 12, + failed: 0, + deadLetter: 85, + healthy: false, + status: 'degraded', + attentionRequired: 85, + reasonCodes: ['dead_letter_retained'], + completedRetentionDays: 7, + deadLetterRetentionDays: 30, + countsAreRetentionBounded: true, + retryAttemptsAffectHealth: false, + terminalFailuresAffectHealth: true, + failureClasses7d: { + authentication: 0, + rate_limited: 0, + connectivity: 1, + tls: 0, + configuration: 0, + rejected: 0, + server_error: 0, + unknown: 0, + }, + failureClassesAvailable: true, + failureClassWindowDays: 7, + }, + }; +} + +describe('DestinationsTab recovery while editing SMTP', () => { + beforeEach(() => { + vi.resetAllMocks(); + setActiveLocale(DEFAULT_LOCALE); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.mocked(NotificationsAPI.getHealth).mockResolvedValue(degradedHealth()); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue({ + entries: [ + { + notificationId: 'retained-attempt', + type: 'email', + destinationId: 'email:fixture', + outcome: 'failed', + success: false, + alertIds: ['retained-alert'], + alertCount: 1, + attempts: 3, + timestamp: '2026-09-07T12:00:00Z', + errorMessage: 'SMTP fixture rejected', + }, + ], + windowDays: 7, + completedRetentionDays: 7, + deadLetterRetentionDays: 30, + }); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it.each([ + ['retryTerminalFailures', 'Retry retained deliveries', 'retry'], + ['dismissTerminalFailures', 'Dismiss retained failures', 'dismiss'], + ] as const)( + 'preserves unfinished edits through rejected and accepted %s', + async (action, label, verb) => { + const [emailConfig, setEmailConfig] = createSignal(buildEmailConfig()); + const [appriseConfig, setAppriseConfig] = createSignal(buildAppriseConfig()); + const dirty = vi.fn(); + render(() => ( + null} + isRetrying={() => false} + isLoadingDestinations={() => false} + onRetryLoad={vi.fn()} + webhooks={() => []} + deadManPingUrl={() => ''} + setDeadManPingUrl={vi.fn()} + pushMinimumSeverity={() => 'all'} + setPushMinimumSeverity={vi.fn()} + /> + )); + await screen.findByRole('button', { name: label }); + await screen.findByText('SMTP fixture rejected'); + const editor = screen.getByRole('textbox', { name: 'SMTP server' }); + fireEvent.input(editor, { target: { value: 'unfinished.smtp.example' } }); + expect(dirty).toHaveBeenCalledWith(true); + const status = screen.getByRole('status'); + + let reject!: (error: Error) => void; + vi.mocked(NotificationsAPI[action]).mockReturnValueOnce( + new Promise((_, fail) => { + reject = fail; + }), + ); + fireEvent.click(screen.getByRole('button', { name: label })); + editor.focus(); + expect(screen.getByRole('button', { name: label })).toBeDisabled(); + reject(new Error('fixture rejection')); + await waitFor(() => expect(status).toHaveTextContent('Unable to ' + verb)); + expect(editor).toHaveFocus(); + expect(editor).toHaveValue('unfinished.smtp.example'); + expect(emailConfig().server).toBe('unfinished.smtp.example'); + expect(screen.getByText('SMTP fixture rejected')).toBeInTheDocument(); + expect(NotificationsAPI.getDeliveryLog).toHaveBeenCalledTimes(1); + + let accept!: (result: { success: boolean; affected: number }) => void; + vi.mocked(NotificationsAPI[action]).mockReturnValueOnce( + new Promise((resolve) => { + accept = resolve; + }), + ); + const healthy = degradedHealth(); + healthy.queue = { + ...healthy.queue, + status: 'healthy', + healthy: true, + attentionRequired: 0, + failed: 0, + deadLetter: 0, + }; + vi.mocked(NotificationsAPI.getHealth).mockResolvedValueOnce(healthy); + fireEvent.click(screen.getByRole('button', { name: label })); + editor.focus(); + accept({ success: true, affected: 85 }); + await waitFor(() => expect(screen.queryByRole('button', { name: label })).toBeNull()); + await waitFor(() => expect(NotificationsAPI.getDeliveryLog).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('textbox', { name: 'SMTP server' })).toBe(editor); + expect(editor).toHaveFocus(); + expect(editor).toHaveValue('unfinished.smtp.example'); + expect(emailConfig().server).toBe('unfinished.smtp.example'); + expect(screen.getByRole('status')).toBe(status); + expect(status).toBeEmptyDOMElement(); + expect(screen.getByText('SMTP fixture rejected')).toBeInTheDocument(); + expect(NotificationsAPI[action]).toHaveBeenCalledTimes(2); + }, + ); +});