Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13204-tests-1765854340931.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tests
---

Add coverage for the CloudPulse alerts notification channels listing ([#13204](https://github.com/linode/manager/pull/13204))
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
/**
* @file Integration Tests for CloudPulse Alerting β€” Notification Channel Listing Page
*/
import { profileFactory } from '@linode/utilities';
import { mockGetAccount } from 'support/intercepts/account';
import { mockGetAlertChannels } from 'support/intercepts/cloudpulse';
import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags';
import { mockGetProfile } from 'support/intercepts/profile';
import { ui } from 'support/ui';

import {
accountFactory,
flagsFactory,
notificationChannelFactory,
} from 'src/factories';
import {
ChannelAlertsTooltipText,
ChannelListingTableLabelMap,
} from 'src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants';
import { formatDate } from 'src/utilities/formatDate';

import type { NotificationChannel } from '@linode/api-v4';

const sortOrderMap = {
ascending: 'asc',
descending: 'desc',
};

const LabelLookup = Object.fromEntries(
ChannelListingTableLabelMap.map((item) => [item.colName, item.label])
);
type SortOrder = 'ascending' | 'descending';

interface VerifyChannelSortingParams {
columnLabel: string;
expected: number[];
sortOrder: SortOrder;
}

const notificationChannels = notificationChannelFactory
.buildList(26)
.map((ch, i) => {
const isEmail = i % 2 === 0;
const alerts = Array.from({ length: isEmail ? 5 : 3 }).map((_, idx) => ({
id: idx + 1,
label: `Alert-${idx + 1}`,
type: 'alerts-definitions',
url: 'Sample',
}));

if (isEmail) {
return {
...ch,
id: i + 1,
label: `Channel-${i + 1}`,
type: 'custom',
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: 'default',
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;
}
});

const isEmailContent = (
content: NotificationChannel['content']
): content is {
email: {
email_addresses: string[];
message: string;
subject: string;
};
} => 'email' in content;
const mockProfile = profileFactory.build({
timezone: 'gmt',
});

/**
* Verifies sorting of a column in the alerts table.
*
* @param params - Configuration object for sorting verification.
* @param params.columnLabel - The label of the column to sort.
* @param params.sortOrder - Expected sorting order (ascending | descending).
* @param params.expected - Expected row order after sorting.
*/
const VerifyChannelSortingParams = (
columnLabel: string,
sortOrder: 'ascending' | 'descending',
expected: number[]
) => {
cy.get(`[data-qa-header="${columnLabel}"]`).click({ force: true });

cy.get(`[data-qa-header="${columnLabel}"]`)
.invoke('attr', 'aria-sort')
.then((current) => {
if (current !== sortOrder) {
cy.get(`[data-qa-header="${columnLabel}"]`).click({ force: true });
}
});

cy.get(`[data-qa-header="${columnLabel}"]`).should(
'have.attr',
'aria-sort',
sortOrder
);

cy.get('[data-qa="notification-channels-table"] tbody:last-of-type tr').then(
($rows) => {
const actualOrder = $rows
.toArray()
.map((row) =>
Number(row.getAttribute('data-qa-notification-channel-cell'))
);
expect(actualOrder).to.eqls(expected);
}
);

const order = sortOrderMap[sortOrder];
const orderBy = LabelLookup[columnLabel];

cy.url().should(
'endWith',
`/alerts/notification-channels?order=${order}&orderBy=${orderBy}`
);
};

describe('Notification Channel Listing Page', () => {
/**
* Validates the listing page for CloudPulse notification channels.
* Confirms channel data rendering, search behavior, and table sorting
* across all columns using a controlled 26-item mock dataset.
*/
beforeEach(() => {
mockAppendFeatureFlags(flagsFactory.build());
mockGetProfile(mockProfile);
mockGetAccount(accountFactory.build());
mockGetAlertChannels(notificationChannels).as(
'getAlertNotificationChannels'
);

cy.visitWithLogin('/alerts/notification-channels');

ui.pagination.findPageSizeSelect().click();

cy.get('[data-qa-pagination-page-size-option="100"]')
.should('exist')
.click();

ui.tooltip.findByText(ChannelAlertsTooltipText).should('be.visible');

Check warning on line 180 in packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Define a constant instead of duplicating this literal 5 times. Raw Output: {"ruleId":"sonarjs/no-duplicate-string","severity":1,"message":"Define a constant instead of duplicating this literal 5 times.","line":180,"column":60,"nodeType":"Literal","endLine":180,"endColumn":72}

cy.wait('@getAlertNotificationChannels').then(({ response }) => {
const body = response?.body;
const data = body?.data;

const channels = data as NotificationChannel[];

expect(body?.results).to.eq(notificationChannels.length);

channels.forEach((item, index) => {
const expected = notificationChannels[index];

// Basic fields
expect(item.id).to.eq(expected.id);
expect(item.label).to.eq(expected.label);
expect(item.type).to.eq(expected.type);
expect(item.status).to.eq(expected.status);
expect(item.channel_type).to.eq(expected.channel_type);

// Creator/updater fields
expect(item.created_by).to.eq(expected.created_by);
expect(item.updated_by).to.eq(expected.updated_by);

// Email content (safe narrow)
if (isEmailContent(item.content) && isEmailContent(expected.content)) {
expect(item.content.email.email_addresses).to.deep.eq(
expected.content.email.email_addresses
);
expect(item.content.email.subject).to.eq(
expected.content.email.subject
);
expect(item.content.email.message).to.eq(
expected.content.email.message
);
}

// Alerts list
expect(item.alerts.length).to.eq(expected.alerts.length);

item.alerts.forEach((alert, aIndex) => {

Check warning on line 220 in packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Refactor this code to not nest functions more than 4 levels deep. Raw Output: {"ruleId":"sonarjs/no-nested-functions","severity":1,"message":"Refactor this code to not nest functions more than 4 levels deep.","line":220,"column":45,"nodeType":null,"endLine":220,"endColumn":47}
const expAlert = expected.alerts[aIndex];

expect(alert.id).to.eq(expAlert.id);
expect(alert.label).to.eq(expAlert.label);
expect(alert.type).to.eq(expAlert.type);
expect(alert.url).to.eq(expAlert.url);
});
});
});
});

it('searches and validates notification channel details', () => {
cy.findByPlaceholderText('Search for Notification Channels').as(
'searchInput'
);

cy.get('[data-qa="notification-channels-table"]')
.find('tbody')
.last()
.within(() => {
cy.get('tr').should('have.length', 26);
});

cy.get('@searchInput').clear();
cy.get('@searchInput').type('Channel-9');
cy.get('[data-qa="notification-channels-table"]')
.find('tbody')
.last()
.within(() => {
cy.get('tr').should('have.length', 1);

cy.get('tr').each(($row) => {
const expected = notificationChannels[8];

cy.wrap($row).within(() => {

Check warning on line 255 in packages/manager/cypress/e2e/core/cloudpulse/alert-notification-channel-list.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Refactor this code to not nest functions more than 4 levels deep. Raw Output: {"ruleId":"sonarjs/no-nested-functions","severity":1,"message":"Refactor this code to not nest functions more than 4 levels deep.","line":255,"column":35,"nodeType":null,"endLine":255,"endColumn":37}
cy.findByText(expected.label).should('be.visible');
cy.findByText(String(expected.alerts.length)).should('be.visible');
cy.findByText('Email').should('be.visible');
cy.get('td').eq(3).should('have.text', expected.created_by);
cy.findByText(
formatDate(expected.updated, {
format: 'MMM dd, yyyy, h:mm a',
timezone: 'GMT',
})
).should('be.visible');
cy.get('td').eq(5).should('have.text', expected.updated_by);
});
});
});
});

it('sorting and validates notification channel details', () => {
const sortColumns = [
{
column: 'Channel Name',
ascending: [...notificationChannels]
.sort((a, b) => a.label.localeCompare(b.label))
.map((ch) => ch.id),

descending: [...notificationChannels]
.sort((a, b) => b.label.localeCompare(a.label))
.map((ch) => ch.id),
},
{
column: 'Alerts',
ascending: [...notificationChannels]
.sort((a, b) => a.alerts.length - b.alerts.length)
.map((ch) => ch.id),

descending: [...notificationChannels]
.sort((a, b) => b.alerts.length - a.alerts.length)
.map((ch) => ch.id),
},

{
column: 'Channel Type',
ascending: [...notificationChannels]
.sort((a, b) => a.channel_type.localeCompare(b.channel_type))
.map((ch) => ch.id),

descending: [...notificationChannels]
.sort((a, b) => b.channel_type.localeCompare(a.channel_type))
.map((ch) => ch.id),
},

{
column: 'Created By',
ascending: [...notificationChannels]
.sort((a, b) => a.created_by.localeCompare(b.created_by))
.map((ch) => ch.id),

descending: [...notificationChannels]
.sort((a, b) => b.created_by.localeCompare(a.created_by))
.map((ch) => ch.id),
},
{
column: 'Last Modified',
ascending: [...notificationChannels]
.sort((a, b) => a.updated.localeCompare(b.updated))
.map((ch) => ch.id),

descending: [...notificationChannels]
.sort((a, b) => b.updated.localeCompare(a.updated))
.map((ch) => ch.id),
},
{
column: 'Last Modified By',
ascending: [...notificationChannels]
.sort((a, b) => a.updated_by.localeCompare(b.updated_by))
.map((ch) => ch.id),

descending: [...notificationChannels]
.sort((a, b) => b.updated_by.localeCompare(a.updated_by))
.map((ch) => ch.id),
},
];

cy.get('[data-qa="notification-channels-table"] thead th').as('headers');

cy.get('@headers').then(($headers) => {
const actual = Array.from($headers)
.map((th) => th.textContent?.trim())
.filter(Boolean);

expect(actual).to.deep.equal([
'Channel Name',
'Alerts',
'Channel Type',
'Created By',
'Last Modified',
'Last Modified By',
]);
});

sortColumns.forEach(({ column, ascending, descending }) => {
VerifyChannelSortingParams(column, 'ascending', ascending);
VerifyChannelSortingParams(column, 'descending', descending);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @file Integration Tests for CloudPulse Alerting β€” Notification Channel Listing Page
*
* Covers three access-control behaviors:
* Covers four access-control behaviors:
* 1. Access is allowed when `notificationChannels` is true.
* 2. Navigation/tab visibility is blocked when `notificationChannels` is false.
* 3. Direct URL access is blocked when `notificationChannels` is false.
Expand Down Expand Up @@ -91,7 +91,6 @@ describe('Notification Channel Listing Page β€” Access Control', () => {
it('blocks direct URL access to /alerts/notification-channels when notificationChannels is disabled', () => {
const flags: Partial<Flags> = {
aclp: { beta: true, enabled: true },

aclpAlerting: {
accountAlertLimit: 10,
accountMetricLimit: 10,
Expand Down
2 changes: 1 addition & 1 deletion packages/manager/src/factories/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const flagsFactory = Factory.Sync.makeFactory<Partial<Flags>>({
alertDefinitions: true,
beta: true,
recentActivity: false,
notificationChannels: false,
notificationChannels: true,
editDisabledStatuses: [
'in progress',
'failed',
Expand Down
Loading