From e1bcdcb764d3dc16cd15a7ae1cfe9b5a17f08f96 Mon Sep 17 00:00:00 2001 From: agorthi-akamai Date: Tue, 27 Jan 2026 11:48:01 +0530 Subject: [PATCH 1/3] test[DI-29580]:Add spec for delete notification channel --- .../alert-notification-channel-list.spec.ts | 334 +++++++++++++++--- .../cypress/support/intercepts/cloudpulse.ts | 49 +++ 2 files changed, 326 insertions(+), 57 deletions(-) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index eddcbd0d53d..01d11232800 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -3,7 +3,11 @@ */ import { profileFactory } from '@linode/utilities'; import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetAlertChannels } from 'support/intercepts/cloudpulse'; +import { + mockDeleteChannel, + mockDeleteChannelError, + mockGetAlertChannels, +} from 'support/intercepts/cloudpulse'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; @@ -13,6 +17,12 @@ import { flagsFactory, notificationChannelFactory, } from 'src/factories'; +import { + channelTypeMap, + DELETE_CHANNEL_FAILED_MESSAGE, + DELETE_CHANNEL_SUCCESS_MESSAGE, + DELETE_CHANNEL_TOOLTIP_TEXT, +} from 'src/features/CloudPulse/Alerts/constants'; import { ChannelAlertsTooltipText, ChannelListingTableLabelMap, @@ -29,6 +39,7 @@ const sortOrderMap = { const LabelLookup = Object.fromEntries( ChannelListingTableLabelMap.map((item) => [item.colName, item.label]) ); + type SortOrder = 'ascending' | 'descending'; interface VerifyChannelSortingParams { @@ -37,60 +48,120 @@ interface VerifyChannelSortingParams { sortOrder: SortOrder; } -const notificationChannels = notificationChannelFactory - .buildList(26) - .map((ch, i) => { - const isEmail = i % 2 === 0; - const alerts = { - alert_count: isEmail ? 5 : 3, - url: `monitor/alert-channels/${i + 1}/alerts`, - type: 'alerts-definitions', - }; - - if (isEmail) { - return { - ...ch, - id: i + 1, - label: `Channel-${i + 1}`, - type: 'user', - created_by: 'user', - updated_by: 'user', - channel_type: 'email', - updated: new Date(2024, 0, i + 1).toISOString(), - alerts, - content: { - email: { - email_addresses: [`test-${i + 1}@example.com`], - subject: 'Test Subject', - message: 'Test message', - }, - }, - } as NotificationChannel; - } else { - return { - ...ch, - id: i + 1, - label: `Channel-${i + 1}`, - type: 'system', - created_by: 'system', - updated_by: 'system', - channel_type: 'webhook', - updated: new Date(2024, 0, i + 1).toISOString(), - alerts, - content: { - webhook: { - webhook_url: `https://example.com/webhook/${i + 1}`, - http_headers: [ - { - header_key: 'Authorization', - header_value: 'Bearer secret-token', - }, - ], - }, - }, - } as NotificationChannel; - } - }); +// Helper to generate alerts +const generateAlerts = (numAlerts: number) => ({ + alert_count: numAlerts, + type: 'alerts-definitions' as const, + url: '/monitor/alert-channels/alerts', +}); + +const guaranteedChannels: NotificationChannel[] = [ + notificationChannelFactory.build({ + id: 1, + label: 'Email-System-0Alerts', + type: 'system', + channel_type: 'email', + alerts: generateAlerts(0), + }), + notificationChannelFactory.build({ + id: 2, + label: 'Email-User-0Alerts', + type: 'user', + channel_type: 'email', + alerts: generateAlerts(0), + }), + notificationChannelFactory.build({ + id: 3, + label: 'Webhook-System-3Alerts', + type: 'system', + channel_type: 'webhook', + alerts: generateAlerts(3), + }), + notificationChannelFactory.build({ + id: 4, + label: 'Webhook-User-3Alerts', + type: 'user', + channel_type: 'webhook', + alerts: generateAlerts(3), + }), + notificationChannelFactory.build({ + id: 5, + label: 'email-User-3Alerts', + type: 'user', + channel_type: 'email', + alerts: generateAlerts(3), + }), +]; + +// Generate remaining channels up to 26 +const remainingChannels: NotificationChannel[] = Array.from( + { length: 26 - guaranteedChannels.length }, + (_, idx) => { + const id = guaranteedChannels.length + idx + 1; + const type: 'system' | 'user' = Math.random() < 0.5 ? 'user' : 'system'; + const channelType: 'email' | 'webhook' = + Math.random() < 0.5 ? 'email' : 'webhook'; + const alertsCount = Math.random() < 0.5 ? 0 : 3; + + return notificationChannelFactory.build({ + id, + label: `Channel-${id}`, + type, + channel_type: channelType, + alerts: generateAlerts(alertsCount), + }); + } +); + +const notificationChannels = [...guaranteedChannels, ...remainingChannels]; + +/** + * Finds a notification channel by channel_type, owner type, and alerts length, + * and returns its NotificationChannel object. + * + * Throws an error if no matching channel is found. + * This guarantees the return type is always 'NotificationChannel'. + */ +const findChannel = ( + // List of all notification channels to search + channels: NotificationChannel[], + + channelType: NotificationChannel['channel_type'], + + // Owner/type of the channel (e.g. 'user', 'system') + channelOwnerType: NotificationChannel['type'], + + // Expected number of alerts (use 0 for "no alerts") + alertsLength: number +): NotificationChannel => { + // Find the first channel that matches all criteria + const channel = channels.find( + (ch) => + ch.channel_type === channelType && + ch.type === channelOwnerType && + // Special handling for zero alerts: + // alerts may be undefined or an empty array + (alertsLength === 0 + ? !ch.alerts || ch.alerts.alert_count === 0 + : ch.alerts?.alert_count === alertsLength) + ); + + // Fail fast if no matching channel is found + if (!channel) { + throw new Error( + `No channel found with channel_type=${channelType}, type=${channelOwnerType}, alertsLength=${alertsLength}` + ); + } + + // Safe to return: channel is guaranteed to exist + return channel; +}; +const { label: userChannelLabel, id: userChannelId } = findChannel( + notificationChannels, + 'email', // channel_type + 'user', // channel owner/type + 0 // alertsLength (0 = no alerts) +); const isEmailContent = ( content: NotificationChannel['content'] @@ -167,7 +238,7 @@ describe('Notification Channel Listing Page', () => { mockGetAlertChannels(notificationChannels).as( 'getAlertNotificationChannels' ); - + mockDeleteChannel(userChannelId).as('deleteNotificationChannel'); cy.visitWithLogin('/alerts/notification-channels'); ui.pagination.findPageSizeSelect().click(); @@ -247,7 +318,9 @@ describe('Notification Channel Listing Page', () => { cy.findByText(String(expected.alerts.alert_count)).should( 'be.visible' ); - cy.findByText('Email').should('be.visible'); + cy.findByText(channelTypeMap[expected.channel_type]).should( + 'be.visible' + ); cy.get('td').eq(3).should('have.text', expected.created_by); cy.findByText( formatDate(expected.updated, { @@ -349,4 +422,151 @@ describe('Notification Channel Listing Page', () => { VerifyChannelSortingParams(column, 'descending', descending); }); }); + + it('deletes a user-type email notification channel with no alerts', () => { + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(userChannelLabel); + + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${userChannelLabel}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${userChannelLabel}?`) + .should('be.visible') + .within(() => { + // Focus the "Alert Label" confirmation input + cy.findByLabelText('Notification Channel Label').click(); + + // Type the alert label to enable the Delete button + cy.focused().type(userChannelLabel); + + // Click the Delete button to confirm + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + ui.toast.assertMessage(DELETE_CHANNEL_SUCCESS_MESSAGE); + }); + + it('disable deletion of a user-type email notification channel with alerts', () => { + // --- Arrange: Find a channel that has at least 1 alert --- + const { label: userChannelLabel } = findChannel( + notificationChannels, + 'email', // channel_type + 'user', // owner/type + 3 // alertsLength: at least 1 alert + ); + + // --- Act: Search for the channel --- + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(userChannelLabel); + + // --- Act: Open action menu --- + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${userChannelLabel}`) + .should('be.visible') + .click(); + + ui.tooltip.findByText(DELETE_CHANNEL_TOOLTIP_TEXT).should('be.visible'); + + // --- Act: Click Delete action --- + ui.actionMenuItem + .findByTitle('Delete') + .should('be.visible') + .should('be.disabled'); // ✅ key assertion for channels with alerts + }); + + it('ensures system-type channels never show the Delete button', () => { + // --- User-type email channel with alerts --- + const { label: systemChannelLabel } = findChannel( + notificationChannels, + 'email', // channel_type + 'system', // type/owner + 0 // alertsLength = 0 + ); + + // --- Act: Search for the channel --- + cy.findByPlaceholderText('Search for Notification Channels').as( + 'searchInput' + ); + + cy.get('@searchInput').clear(); + cy.get('@searchInput').type(systemChannelLabel); + + // Open action menu + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${systemChannelLabel}`) + .should('be.visible') + .click(); + + // Delete button should NOT exist for system-type channels + cy.get('div[data-qa-action-menu="true"]') // targets the opened popover + .within(() => { + // Assert Delete button does NOT exist + cy.get('[data-qa-action-menu-item="Delete"]').should('not.exist'); + + // Optionally assert Show Details exists + cy.get('[data-qa-action-menu-item="Show Details"]').should( + 'be.visible' + ); + }); + }); + it('displays an error when deleting a notification channel fails', () => { + const notificationChannel = notificationChannelFactory.build({ + id: 123, + label: 'Channel-error', + type: 'user', + created_by: 'user', + updated_by: 'user', + channel_type: 'email', + alerts: generateAlerts(0), + }); + const userChannelLabel = notificationChannel.label; + mockGetAlertChannels([notificationChannel]); + + // Arrange: Mock the DELETE API to return a 500 error + mockDeleteChannelError(123).as('deleteChannel'); + cy.visitWithLogin('/alerts/notification-channels'); + + // Act: Attempt to delete the channel + ui.actionMenu + .findByTitle(`Action menu for Notification Channel ${userChannelLabel}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + ui.dialog + .findByTitle(`Delete ${userChannelLabel}?`) + .should('be.visible') + .within(() => { + // Focus the "Alert Label" confirmation input + cy.findByLabelText('Notification Channel Label').click(); + + // Type the alert label to enable the Delete button + cy.focused().type(userChannelLabel); + + // Click the Delete button to confirm + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + ui.toast.assertMessage(DELETE_CHANNEL_FAILED_MESSAGE); + }); }); diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index e681218faed..71a3ddc8cb0 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -615,3 +615,52 @@ export const mockGetCloudPulseServiceByType = ( makeResponse(service) ); }; + +/** + * Intercepts a DELETE request for a specific notification channel and mocks the backend response. + * + * This helper uses Cypress `cy.intercept()` to stub a DELETE API call to the + * alert channels endpoint (`/monitor/alert-channels/:id`) and returns a mocked + * response with the given status code. This allows tests to simulate both + * successful and failing delete operations without hitting a real backend. + * + * @param channelId - The ID of the notification channel to delete. + * @param statusCode - The HTTP status code to mock (default: 200). + * + * @returns A Cypress.Chainable that can be `as()` aliased and awaited with `cy.wait()`. + */ +export const mockDeleteChannel = ( + channelId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`/monitor/alert-channels/${channelId}`), + { + statusCode: 200, + body: {}, + } + ); +}; + +/** + * Mocks a DELETE request for a specific notification channel and simulates + * a server error response. + * This function uses Cypress's `cy.intercept()` to stub the DELETE API call + * + * @param channelId - The ID of the notification channel to delete. + * @returns Cypress.Chainable that can be aliased with `.as()` and awaited with `cy.wait()`. + */ +export const mockDeleteChannelError = ( + channelId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`/monitor/alert-channels/${channelId}`), + { + statusCode: 500, + body: { + message: 'Internal server error', + }, + } + ); +}; From 5712a818f2110f3d97a387d9f3677309e9e46ddd Mon Sep 17 00:00:00 2001 From: agorthi-akamai Date: Tue, 27 Jan 2026 11:51:34 +0530 Subject: [PATCH 2/3] test[DI-29580]:Add spec for delete notification channel --- packages/manager/.changeset/pr-13327-tests-1769494880139.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13327-tests-1769494880139.md diff --git a/packages/manager/.changeset/pr-13327-tests-1769494880139.md b/packages/manager/.changeset/pr-13327-tests-1769494880139.md new file mode 100644 index 00000000000..95025547b72 --- /dev/null +++ b/packages/manager/.changeset/pr-13327-tests-1769494880139.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add spec for delete notification channel ([#13327](https://github.com/linode/manager/pull/13327)) From 72122abff06ce27fba1af6c676dc794019cba9c5 Mon Sep 17 00:00:00 2001 From: agorthi-akamai Date: Tue, 27 Jan 2026 14:32:32 +0530 Subject: [PATCH 3/3] test[DI-29580]:Add spec for delete notification channel --- .../e2e/core/cloudpulse/alert-notification-channel-list.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts index 01d11232800..a3a4f789d6e 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts @@ -487,7 +487,7 @@ describe('Notification Channel Listing Page', () => { ui.actionMenuItem .findByTitle('Delete') .should('be.visible') - .should('be.disabled'); // ✅ key assertion for channels with alerts + .should('be.disabled'); }); it('ensures system-type channels never show the Delete button', () => {