From 2bf58ddd4ea74d0664b9f222304399eea0346ce7 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:04:42 -0500 Subject: [PATCH] test: [M3-6169] - Account cancellation integration tests (#9952) * Add `mockCancelAccount`, `mockCancelAccountError`, and `mockWebpageUrl` mock utilities * Make accordion UI helper yield contents to prevent selection conflicts between accordion headings and action buttons * Add UI tests for account cancellation via Cloud Manager --- .../.changeset/pr-9952-tests-1701457740855.md | 5 + .../core/account/account-cancellation.spec.ts | 200 ++++++++++++++++++ .../cypress/support/intercepts/account.ts | 46 +++- .../cypress/support/intercepts/general.ts | 20 +- .../manager/cypress/support/ui/accordion.ts | 2 +- 5 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-9952-tests-1701457740855.md create mode 100644 packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts diff --git a/packages/manager/.changeset/pr-9952-tests-1701457740855.md b/packages/manager/.changeset/pr-9952-tests-1701457740855.md new file mode 100644 index 00000000000..d27d09fdd9f --- /dev/null +++ b/packages/manager/.changeset/pr-9952-tests-1701457740855.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add account cancellation UI tests ([#9952](https://github.com/linode/manager/pull/9952)) diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts new file mode 100644 index 00000000000..b9ea9698ebb --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -0,0 +1,200 @@ +/** + * @file Integration tests for Cloud Manager account cancellation flows. + */ + +import { profileFactory } from 'src/factories/profile'; +import { accountFactory } from 'src/factories/account'; +import { + mockGetAccount, + mockCancelAccount, + mockCancelAccountError, +} from 'support/intercepts/account'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { + randomDomainName, + randomPhrase, + randomString, +} from 'support/util/random'; +import type { CancelAccount } from '@linode/api-v4'; +import { mockWebpageUrl } from 'support/intercepts/general'; + +// Data loss warning which is displayed in the account cancellation dialog. +const cancellationDataLossWarning = + 'Please note this is an extremely destructive action. Closing your account \ +means that all services Linodes, Volumes, DNS Records, etc will be lost and \ +may not be able be restored.'; + +// Error message that appears when a payment failure occurs upon cancellation attempt. +const cancellationPaymentErrorMessage = + 'We were unable to charge your credit card for services rendered. \ +We cannot cancel this account until the balance has been paid.'; + +describe('Account cancellation', () => { + /* + * - Confirms that a user can cancel their account from the Account Settings page. + * - Confirms that user is warned that account cancellation is destructive. + * - Confirms that Cloud Manager displays a notice when an error occurs during cancellation. + * - Confirms that Cloud Manager includes user comments in cancellation request payload. + * - Confirms that Cloud Manager shows a survey CTA which directs the user to the expected URL. + */ + it('users can cancel account', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + username: 'mock-user', + restricted: false, + }); + const mockCancellationResponse: CancelAccount = { + survey_link: `https://${randomDomainName()}/${randomString(5)}`, + }; + + const cancellationComments = randomPhrase(); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + mockCancelAccountError(cancellationPaymentErrorMessage, 409).as( + 'cancelAccount' + ); + mockWebpageUrl( + mockCancellationResponse.survey_link, + 'This is a mock webpage to confirm Cloud Manager survey link behavior' + ).as('getSurveyPage'); + + // Navigate to Account Settings page, click "Close Account" button. + cy.visitWithLogin('/account/settings'); + cy.wait(['@getAccount', '@getProfile']); + + ui.accordion + .findByTitle('Close Account') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.dialog + .findByTitle('Are you sure you want to close your Linode account?') + .should('be.visible') + .within(() => { + cy.findByText(cancellationDataLossWarning, { exact: false }).should( + 'be.visible' + ); + + // Confirm that submit button is disabled before entering required info. + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.disabled'); + + // Enter username, confirm that submit button becomes enabled, and click + // the submit button. + cy.findByLabelText( + `Please enter your Username (${mockProfile.username}) to confirm.` + ) + .should('be.visible') + .should('be.enabled') + .type(mockProfile.username); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that request payload contains expected data and API error + // message is displayed in the dialog. + cy.wait('@cancelAccount').then((intercept) => { + expect(intercept.request.body['comments']).to.equal(''); + }); + + cy.findByText(cancellationPaymentErrorMessage).should('be.visible'); + + // Enter account cancellation comments, click "Close Account" again, + // and this time mock a successful account cancellation response. + mockCancelAccount(mockCancellationResponse).as('cancelAccount'); + cy.contains('Comments (optional)').click().type(cancellationComments); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@cancelAccount').then((intercept) => { + expect(intercept.request.body['comments']).to.equal( + cancellationComments + ); + }); + }); + + // Confirm that Cloud presents account cancellation screen and prompts the + // user to complete the exit survey. Confirm that clicking survey button + // directs the user to the expected URL. + cy.findByText('It’s been our pleasure to serve you.').should('be.visible'); + ui.button + .findByTitle('Take our exit survey') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@getSurveyPage'); + cy.url().should('equal', mockCancellationResponse.survey_link); + }); + + /* + * - Confirms Cloud Manager behavior when a restricted user attempts to close an account. + * - Confirms that API error response message is displayed in cancellation dialog. + */ + it('restricted users cannot cancel account', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + username: 'mock-restricted-user', + restricted: true, + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + mockCancelAccountError('Unauthorized', 403).as('cancelAccount'); + + // Navigate to Account Settings page, click "Close Account" button. + cy.visitWithLogin('/account/settings'); + cy.wait(['@getAccount', '@getProfile']); + + ui.accordion + .findByTitle('Close Account') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Fill out cancellation dialog and attempt submission. + ui.dialog + .findByTitle('Are you sure you want to close your Linode account?') + .should('be.visible') + .within(() => { + cy.findByLabelText( + `Please enter your Username (${mockProfile.username}) to confirm.` + ) + .should('be.visible') + .should('be.enabled') + .type(mockProfile.username); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that API unauthorized error message is displayed. + cy.wait('@cancelAccount'); + cy.findByText('Unauthorized').should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 18906c0f844..552a7bcd9f4 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -11,11 +11,13 @@ import { makeResponse } from 'support/util/response'; import type { Account, AccountSettings, + CancelAccount, EntityTransfer, Invoice, InvoiceItem, Payment, PaymentMethod, + User, } from '@linode/api-v4'; /** @@ -26,7 +28,7 @@ import type { * @returns Cypress chainable. */ export const mockGetAccount = (account: Account): Cypress.Chainable => { - return cy.intercept('GET', apiMatcher('account'), account); + return cy.intercept('GET', apiMatcher('account'), makeResponse(account)); }; /** @@ -39,7 +41,11 @@ export const mockGetAccount = (account: Account): Cypress.Chainable => { export const mockUpdateAccount = ( updatedAccount: Account ): Cypress.Chainable => { - return cy.intercept('PUT', apiMatcher('account'), updatedAccount); + return cy.intercept( + 'PUT', + apiMatcher('account'), + makeResponse(updatedAccount) + ); }; /** @@ -322,3 +328,39 @@ export const mockGetPayments = ( paginateResponse(payments) ); }; + +/** + * Intercepts POST request to cancel account and mocks cancellation response. + * + * @param cancellation - Account cancellation object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCancelAccount = ( + cancellation: CancelAccount +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('account/cancel'), + makeResponse(cancellation) + ); +}; + +/** + * Intercepts POST request to cancel account and mocks an API error response. + * + * @param errorMessage - Error message to include in mock error response. + * @param status - HTTP status for mock error response. + * + * @returns Cypress chainable. + */ +export const mockCancelAccountError = ( + errorMessage: string, + status: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('account/cancel'), + makeErrorResponse(errorMessage, status) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/general.ts b/packages/manager/cypress/support/intercepts/general.ts index e028e0f9bab..3cd13083bc5 100644 --- a/packages/manager/cypress/support/intercepts/general.ts +++ b/packages/manager/cypress/support/intercepts/general.ts @@ -1,5 +1,23 @@ import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; +import { makeResponse } from 'support/util/response'; + +/** + * Intercepts GET request to given URL and mocks an HTTP 200 response with the given content. + * + * This can be used to mock visits to arbitrary webpages. + * + * @param url - Webpage URL for which to intercept GET request. + * @param content - Webpage content with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockWebpageUrl = ( + url: string, + content: string +): Cypress.Chainable => { + return cy.intercept(url, makeResponse(content, 200)); +}; /** * Intercepts all Linode APIv4 requests and mocks maintenance mode response. @@ -9,7 +27,7 @@ import { apiMatcher } from 'support/util/intercepts'; * * @returns Cypress chainable. */ -export const mockApiMaintenanceMode = () => { +export const mockApiMaintenanceMode = (): Cypress.Chainable => { const errorResponse = makeErrorResponse( 'Currently in maintenance mode.', 503 diff --git a/packages/manager/cypress/support/ui/accordion.ts b/packages/manager/cypress/support/ui/accordion.ts index 1beadfd7d23..523de4ed3fa 100644 --- a/packages/manager/cypress/support/ui/accordion.ts +++ b/packages/manager/cypress/support/ui/accordion.ts @@ -19,6 +19,6 @@ export const accordion = { * @returns Cypress chainable. */ findByTitle: (title: string) => { - return cy.get(`[data-qa-panel="${title}"]`); + return cy.get(`[data-qa-panel="${title}"]`).find('[data-qa-panel-details]'); }, };