diff --git a/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js b/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js index ad5eea3537..ed35fccfce 100644 --- a/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js +++ b/e2e-tests/gef/cypress/e2e/pages/amendments/eligibility.js @@ -1,8 +1,8 @@ const eligibility = { errorSummary: () => cy.get('[data-cy="error-summary"]'), criterionInlineError: (id) => cy.get(`[data-cy="inline-error-criterion-${id}"]`), - allTrueRadioButtons: () => cy.get('*[data-cy^="true-radio-criterion-"]'), - allFalseRadioButtons: () => cy.get('*[data-cy^="true-radio-criterion-"]'), + allTrueRadioButtons: () => cy.get('[data-cy^="true-radio-criterion-"]'), + allFalseRadioButtons: () => cy.get('[data-cy^="false-radio-criterion-"]'), criterionTrueRadioButton: (id) => cy.get(`[data-cy="true-radio-criterion-${id}"]`), criterionFalseRadioButton: (id) => cy.get(`[data-cy="false-radio-criterion-${id}"]`), criterionRadiosText: (id) => cy.get(`[data-cy="radio-wrapper-${id}"]`), diff --git a/e2e-tests/gef/cypress/e2e/pages/amendments/manual-approval-needed.js b/e2e-tests/gef/cypress/e2e/pages/amendments/manual-approval-needed.js new file mode 100644 index 0000000000..31e15d1bcf --- /dev/null +++ b/e2e-tests/gef/cypress/e2e/pages/amendments/manual-approval-needed.js @@ -0,0 +1,8 @@ +const manualApprovalNeeded = { + pageHeading: () => cy.get('[data-cy="page-heading"]'), + backLink: () => cy.get('[data-cy="back-link"]'), + emailLink: () => cy.get('[data-cy="form-email-link"]'), + returnLink: () => cy.get('[data-cy="return-link"]'), +}; + +module.exports = manualApprovalNeeded; diff --git a/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js index 80f886ac9d..9f8be9e6ba 100644 --- a/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js +++ b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/eligibility.spec.js @@ -120,4 +120,27 @@ context('Amendments - Eligibility - page tests', () => { cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/cancel`)); }); + + it('should navigate to the manual approval information page if "false" is selected for all criteria', () => { + eligibility.allFalseRadioButtons().click({ multiple: true }); + cy.clickContinueButton(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/manual-approval-needed`)); + }); + + it('should navigate to the manual approval information page if "false" is selected for all criteria', () => { + eligibility.allTrueRadioButtons().click({ multiple: true }); + eligibility.criterionFalseRadioButton(2).click(); + + cy.clickContinueButton(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/manual-approval-needed`)); + }); + + it('should navigate to the effective from page if "true" is selected for all criteria', () => { + eligibility.allTrueRadioButtons().click({ multiple: true }); + cy.clickContinueButton(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/effective-date`)); + }); }); diff --git a/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/manual-approval-needed.spec.js b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/manual-approval-needed.spec.js new file mode 100644 index 0000000000..cb29b81075 --- /dev/null +++ b/e2e-tests/ukef/cypress/e2e/journeys/portal/amendments/pages/manual-approval-needed.spec.js @@ -0,0 +1,93 @@ +import relative from '../../../../relativeURL'; +import MOCK_USERS from '../../../../../../../e2e-fixtures/portal-users.fixture'; +import { MOCK_APPLICATION_AIN_DRAFT } from '../../../../../../../e2e-fixtures/gef/mocks/mock-deals'; +import { anIssuedCashFacility } from '../../../../../../../e2e-fixtures/mock-gef-facilities'; +import { applicationPreview } from '../../../../../../../gef/cypress/e2e/pages'; +import whatDoYouNeedToChange from '../../../../../../../gef/cypress/e2e/pages/amendments/what-do-you-need-to-change'; +import facilityValue from '../../../../../../../gef/cypress/e2e/pages/amendments/facility-value'; +import eligibility from '../../../../../../../gef/cypress/e2e/pages/amendments/eligibility'; +import manualApprovalNeeded from '../../../../../../../gef/cypress/e2e/pages/amendments/manual-approval-needed'; + +const { BANK1_MAKER1 } = MOCK_USERS; + +context('Amendments - Eligibility - page tests', () => { + /** + * @type {string} + */ + let dealId; + + /** + * @type {string} + */ + let facilityId; + /** + * @type {string} + */ + let amendmentId; + + before(() => { + cy.insertOneGefDeal(MOCK_APPLICATION_AIN_DRAFT, BANK1_MAKER1).then((insertedDeal) => { + dealId = insertedDeal._id; + + cy.updateGefDeal(dealId, MOCK_APPLICATION_AIN_DRAFT, BANK1_MAKER1); + + cy.createGefFacilities(dealId, [anIssuedCashFacility({ facilityEndDateEnabled: true })], BANK1_MAKER1).then((createdFacility) => { + facilityId = createdFacility.details._id; + + cy.makerLoginSubmitGefDealForReview(insertedDeal); + cy.checkerLoginSubmitGefDealToUkef(insertedDeal); + + cy.clearSessionCookies(); + cy.login(BANK1_MAKER1); + cy.saveSession(); + cy.visit(relative(`/gef/application-details/${dealId}`)); + + applicationPreview.makeAChangeButton(facilityId).click(); + + cy.url().then((url) => { + const urlSplit = url.split('/'); + + amendmentId = urlSplit[9]; + }); + + whatDoYouNeedToChange.facilityValueCheckbox().click(); + cy.clickContinueButton(); + cy.keyboardInput(facilityValue.facilityValue(), '10000'); + cy.clickContinueButton(); + eligibility.allFalseRadioButtons().click({ multiple: true }); + cy.clickContinueButton(); + }); + }); + }); + + after(() => { + cy.clearCookies(); + cy.clearSessionCookies(); + }); + + beforeEach(() => { + cy.clearSessionCookies(); + cy.login(BANK1_MAKER1); + cy.visit(relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/manual-approval-needed`)); + }); + + it('should render key features of the page', () => { + manualApprovalNeeded.pageHeading().contains('This amendment cannot be automatically approved'); + manualApprovalNeeded.returnLink(); + manualApprovalNeeded.emailLink(); + manualApprovalNeeded.backLink(); + }); + + it('should navigate to the deals overview page when the return link is clicked', () => { + manualApprovalNeeded.returnLink().click(); + + cy.url().should('eq', relative(`/dashboard/deals/0`)); + }); + + it('should navigate to the eligibility page with pre-filled data when "back" is clicked', () => { + manualApprovalNeeded.backLink().click(); + + cy.url().should('eq', relative(`/gef/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/eligibility`)); + eligibility.allFalseRadioButtons().should('be.checked'); + }); +}); diff --git a/gef-ui/api-tests/amendments/manual-approval-needed.get.api-test.ts b/gef-ui/api-tests/amendments/manual-approval-needed.get.api-test.ts new file mode 100644 index 0000000000..cb2bf596ee --- /dev/null +++ b/gef-ui/api-tests/amendments/manual-approval-needed.get.api-test.ts @@ -0,0 +1,236 @@ +import { Headers } from 'node-mocks-http'; +import { NextFunction, Request, Response } from 'express'; +import { DEAL_STATUS, DEAL_SUBMISSION_TYPE, ROLES } from '@ukef/dtfs2-common'; +import { HttpStatusCode } from 'axios'; +import { withRoleValidationApiTests } from '../common-tests/role-validation-api-tests'; +import app from '../../server/createApp'; +import { createApi } from '../create-api'; +import api from '../../server/services/api'; +import * as storage from '../test-helpers/storage/storage'; +import { PortalFacilityAmendmentWithUkefIdMockBuilder } from '../../test-helpers/mock-amendment'; +import { PORTAL_AMENDMENT_PAGES } from '../../server/constants/amendments'; +import { MOCK_BASIC_DEAL } from '../../server/utils/mocks/mock-applications'; +import { MOCK_ISSUED_FACILITY, MOCK_UNISSUED_FACILITY } from '../../server/utils/mocks/mock-facilities'; +import { getAmendmentsUrl } from '../../server/controllers/amendments/helpers/navigation.helper.ts'; + +const originalEnv = { ...process.env }; + +const { get } = createApi(app); + +jest.mock('csurf', () => () => (_req: Request, _res: Response, next: NextFunction) => next()); +jest.mock('../../server/middleware/csrf', () => ({ + csrfToken: () => (_req: Request, _res: Response, next: NextFunction) => next(), +})); + +const mockGetFacility = jest.fn(); +const mockGetApplication = jest.fn(); +const mockGetAmendment = jest.fn(); + +const dealId = '123'; +const facilityId = '111'; +const amendmentId = '111'; + +const mockDeal = { ...MOCK_BASIC_DEAL, submissionType: DEAL_SUBMISSION_TYPE.AIN, status: DEAL_STATUS.UKEF_ACKNOWLEDGED }; + +const url = `/application-details/${dealId}/facilities/${facilityId}/amendments/${amendmentId}/${PORTAL_AMENDMENT_PAGES.MANUAL_APPROVAL_NEEDED}`; + +describe(`GET ${url}`, () => { + let sessionCookie: string; + + beforeEach(async () => { + await storage.flush(); + jest.resetAllMocks(); + + ({ sessionCookie } = await storage.saveUserSession([ROLES.MAKER])); + jest.spyOn(api, 'getFacility').mockImplementation(mockGetFacility); + jest.spyOn(api, 'getApplication').mockImplementation(mockGetApplication); + jest.spyOn(api, 'getAmendment').mockImplementation(mockGetAmendment); + + mockGetFacility.mockResolvedValue(MOCK_ISSUED_FACILITY); + mockGetApplication.mockResolvedValue(mockDeal); + mockGetAmendment.mockResolvedValue( + new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withDealId(dealId) + .withFacilityId(facilityId) + .withAmendmentId(amendmentId) + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: false }, + { id: 2, text: 'Criterion 2', answer: true }, + ]) + .build(), + ); + }); + + afterAll(async () => { + jest.resetAllMocks(); + await storage.flush(); + process.env = originalEnv; + }); + + describe('when FF_PORTAL_FACILITY_AMENDMENTS_ENABLED is disabled', () => { + beforeEach(() => { + process.env.FF_PORTAL_FACILITY_AMENDMENTS_ENABLED = 'false'; + }); + + it('should redirect to /not-found', async () => { + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + }); + + describe('when FF_PORTAL_FACILITY_AMENDMENTS_ENABLED feature flag is not set', () => { + beforeEach(() => { + delete process.env.FF_PORTAL_FACILITY_AMENDMENTS_ENABLED; + }); + + it('should redirect to /not-found', async () => { + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + }); + + describe('when FF_PORTAL_FACILITY_AMENDMENTS_ENABLED is enabled', () => { + beforeEach(() => { + process.env.FF_PORTAL_FACILITY_AMENDMENTS_ENABLED = 'true'; + }); + + withRoleValidationApiTests({ + makeRequestWithHeaders: (headers: Headers) => get(url, {}, headers), + whitelistedRoles: [ROLES.MAKER], + successCode: HttpStatusCode.Ok, + }); + + it('should render manual approval needed page', async () => { + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('This amendment cannot be automatically approved'); + }); + + it('should redirect to /not-found when facility not found', async () => { + // Arrange + mockGetFacility.mockResolvedValue({ details: undefined }); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + + it('should redirect to /not-found when deal not found', async () => { + // Arrange + mockGetApplication.mockResolvedValue(undefined); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + + it('should redirect to /not-found when amendment not found', async () => { + // Arrange + mockGetAmendment.mockResolvedValue(undefined); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual('/not-found'); + }); + + it('should redirect to deal summary page when facility cannot be amended', async () => { + // Arrange + mockGetApplication.mockResolvedValue(MOCK_UNISSUED_FACILITY); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual(`/gef/application-details/${dealId}`); + }); + + it('should redirect to the eligibility page if there are no false responses to the criteria', async () => { + // Arrange + mockGetAmendment.mockResolvedValueOnce( + new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withDealId(dealId) + .withFacilityId(facilityId) + .withAmendmentId(amendmentId) + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + ]) + .build(), + ); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Found); + expect(response.headers.location).toEqual(getAmendmentsUrl({ dealId, facilityId, amendmentId, page: PORTAL_AMENDMENT_PAGES.ELIGIBILITY })); + }); + + it('should render `problem with service` if getApplication throws an error', async () => { + // Arrange + mockGetApplication.mockRejectedValue(new Error('test error')); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('Problem with the service'); + }); + + it('should render `problem with service` if getFacility throws an error', async () => { + // Arrange + mockGetFacility.mockRejectedValue(new Error('test error')); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('Problem with the service'); + }); + + it('should render `problem with service` if getAmendment throws an error', async () => { + // Arrange + mockGetAmendment.mockRejectedValue(new Error('test error')); + + // Act + const response = await getWithSessionCookie(sessionCookie); + + // Assert + expect(response.status).toEqual(HttpStatusCode.Ok); + expect(response.text).toContain('Problem with the service'); + }); + }); +}); + +function getWithSessionCookie(sessionCookie: string) { + return get( + url, + {}, + { + Cookie: [`dtfs-session=${encodeURIComponent(sessionCookie)}`], + }, + ); +} diff --git a/gef-ui/component-tests/partials/amendments/manual-approval-needed.component-test.ts b/gef-ui/component-tests/partials/amendments/manual-approval-needed.component-test.ts new file mode 100644 index 0000000000..3357eb913e --- /dev/null +++ b/gef-ui/component-tests/partials/amendments/manual-approval-needed.component-test.ts @@ -0,0 +1,52 @@ +import { FACILITY_TYPE } from '@ukef/dtfs2-common'; +import pageRenderer from '../../pageRenderer'; +import { ManualApprovalNeededViewModel } from '../../../server/types/view-models/amendments/ManualApprovalNeededViewModel.ts'; + +const page = 'partials/amendments/manual-approval-needed.njk'; +const render = pageRenderer(page); + +describe(page, () => { + const exporterName = 'exporterName'; + const previousPage = 'previousPage'; + const amendmentFormEmail = 'test@email.com'; + const returnLink = '/dashboard/deals'; + const facilityType = FACILITY_TYPE.CASH; + + const params: ManualApprovalNeededViewModel = { + exporterName, + previousPage, + facilityType, + amendmentFormEmail, + returnLink, + }; + + it('should render the page heading', () => { + const wrapper = render(params); + + wrapper.expectText('[data-cy="page-heading"]').toContain('This amendment cannot be automatically approved'); + }); + + it(`should render the 'Back' link`, () => { + const wrapper = render(params); + + wrapper.expectLink('[data-cy="back-link"]').toLinkTo(previousPage, 'Back'); + }); + + it('should render the exporter name and facility type in the heading caption', () => { + const wrapper = render(params); + + wrapper.expectText('[data-cy="heading-caption"]').toRead(`${exporterName}, ${facilityType} facility`); + }); + + it(`should render the return link`, () => { + const wrapper = render(params); + + wrapper.expectLink('[data-cy="return-link"]').toLinkTo(returnLink, 'Return to all applications and notices'); + }); + + it(`should render the form email address`, () => { + const wrapper = render(params); + + wrapper.expectLink('[data-cy="form-email-link"]').toLinkTo(`mailTo:${amendmentFormEmail}`, amendmentFormEmail); + }); +}); diff --git a/gef-ui/server/constants/amendments.ts b/gef-ui/server/constants/amendments.ts index 79defa2071..b25e9e2488 100644 --- a/gef-ui/server/constants/amendments.ts +++ b/gef-ui/server/constants/amendments.ts @@ -8,5 +8,6 @@ export const PORTAL_AMENDMENT_PAGES = { ELIGIBILITY: 'eligibility', EFFECTIVE_DATE: 'effective-date', CHECK_YOUR_ANSWERS: 'check-your-answers', + MANUAL_APPROVAL_NEEDED: 'manual-approval-needed', CANCEL: 'cancel', } as const; diff --git a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts index b7bf02e617..85cd39d9f6 100644 --- a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts +++ b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getNextPage.test.ts @@ -14,6 +14,7 @@ const { ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS, + MANUAL_APPROVAL_NEEDED, } = PORTAL_AMENDMENT_PAGES; describe('getNextPage', () => { @@ -161,10 +162,58 @@ describe('getNextPage', () => { currentPage: ELIGIBILITY, successTestCases: [ { - description: '', + description: 'when all eligibility criteria answers are "true"', expectedNextPage: EFFECTIVE_DATE, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + { + description: 'when all eligibility criteria answers are "false"', + expectedNextPage: MANUAL_APPROVAL_NEEDED, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: false }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: false }, + ]) + .build(), + }, + { + description: 'when some eligibility criteria answers are "false"', + expectedNextPage: MANUAL_APPROVAL_NEEDED, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + ], + }); + + withNextPageTests({ + currentPage: MANUAL_APPROVAL_NEEDED, + errorTestCases: [ + { + description: 'when all eligibility criteria answers are all valid', amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder().build(), }, + { + description: 'when any eligibility criteria answers are all "false"', + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: false }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: false }, + ]) + .build(), + }, ], }); diff --git a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts index d34e5555d2..53f3e0aedc 100644 --- a/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts +++ b/gef-ui/server/controllers/amendments/helpers/navigation.helper.getPreviousPage.test.ts @@ -14,6 +14,7 @@ const { ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS, + MANUAL_APPROVAL_NEEDED, } = PORTAL_AMENDMENT_PAGES; describe('getPreviousPage', () => { @@ -172,6 +173,35 @@ describe('getPreviousPage', () => { ], }); + withPreviousPageTests({ + currentPage: MANUAL_APPROVAL_NEEDED, + successTestCases: [ + { + description: 'when eligibility criteria have at least one "false" answer', + expectedPreviousPage: ELIGIBILITY, + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: false }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + ], + errorTestCases: [ + { + description: 'when eligibility criteria all have "true" answers', + amendment: new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + { id: 3, text: 'Criterion 3', answer: true }, + ]) + .build(), + }, + ], + }); + withPreviousPageTests({ currentPage: EFFECTIVE_DATE, successTestCases: [ diff --git a/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts b/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts index 4f7a8dcead..041b229de1 100644 --- a/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts +++ b/gef-ui/server/controllers/amendments/helpers/navigation.helper.ts @@ -12,10 +12,11 @@ const { ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS, + MANUAL_APPROVAL_NEEDED, } = PORTAL_AMENDMENT_PAGES; const startPages = [WHAT_DO_YOU_NEED_TO_CHANGE] as const; -const endPages = [ELIGIBILITY, EFFECTIVE_DATE, CHECK_YOUR_ANSWERS] as const; +const endPages = [EFFECTIVE_DATE, CHECK_YOUR_ANSWERS] as const; const coverEndDatePages = [COVER_END_DATE, DO_YOU_HAVE_A_FACILITY_END_DATE] as const; /** @@ -62,7 +63,13 @@ const getJourneyForAmendment = (amendment: PortalFacilityAmendmentWithUkefId): P pages.push(FACILITY_VALUE); } - pages.push(...endPages); + pages.push(ELIGIBILITY); + + if (amendment.eligibilityCriteria.criteria.some((criterion) => criterion.answer === false)) { + pages.push(MANUAL_APPROVAL_NEEDED); + } else { + pages.push(...endPages); + } return pages; }; diff --git a/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.test.ts b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.test.ts new file mode 100644 index 0000000000..99b346742b --- /dev/null +++ b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.test.ts @@ -0,0 +1,254 @@ +/* eslint-disable import/first */ +const getApplicationMock = jest.fn(); +const getFacilityMock = jest.fn(); +const getAmendmentMock = jest.fn(); + +import * as dtfsCommon from '@ukef/dtfs2-common'; +import { + aPortalSessionUser, + CURRENCY, + DEAL_STATUS, + DEAL_SUBMISSION_TYPE, + Facility, + PORTAL_LOGIN_STATUS, + ROLES, + PortalFacilityAmendmentWithUkefId, + FACILITY_TYPE, +} from '@ukef/dtfs2-common'; +import { HttpStatusCode } from 'axios'; +import { createMocks } from 'node-mocks-http'; +import { STB_PIM_EMAIL } from '../../../constants/emails.ts'; + +import { getManualApprovalNeeded, GetManualApprovalNeededRequest } from './get-manual-approval-needed.ts'; +import { Deal } from '../../../types/deal'; +import { PortalFacilityAmendmentWithUkefIdMockBuilder } from '../../../../test-helpers/mock-amendment'; +import { PORTAL_AMENDMENT_PAGES } from '../../../constants/amendments'; +import { getAmendmentsUrl, getPreviousPage } from '../helpers/navigation.helper'; +import { ManualApprovalNeededViewModel } from '../../../types/view-models/amendments/ManualApprovalNeededViewModel.ts'; + +jest.mock('../../../services/api', () => ({ + getApplication: getApplicationMock, + getFacility: getFacilityMock, + getAmendment: getAmendmentMock, +})); + +const dealId = 'dealId'; +const facilityId = 'facilityId'; +const amendmentId = 'amendmentId'; + +const companyName = 'company name ltd'; +const amendmentFormEmail = STB_PIM_EMAIL; +const returnLink = '/dashboard/deals'; + +const getHttpMocks = () => + createMocks({ + params: { dealId, facilityId, amendmentId }, + session: { + user: { ...aPortalSessionUser(), roles: [ROLES.MAKER] }, + userToken: 'testToken', + loginStatus: PORTAL_LOGIN_STATUS.VALID_2FA, + }, + }); + +const mockDeal = { exporter: { companyName }, submissionType: DEAL_SUBMISSION_TYPE.AIN, status: DEAL_STATUS.UKEF_ACKNOWLEDGED } as Deal; + +const mockFacility = { + currency: { + id: CURRENCY.GBP, + }, + type: FACILITY_TYPE.CASH, + hasBeenIssued: true, +} as Facility; + +describe('getManualApprovalNeeded', () => { + let amendment: PortalFacilityAmendmentWithUkefId; + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(dtfsCommon, 'isPortalFacilityAmendmentsFeatureFlagEnabled').mockReturnValue(true); + + amendment = new PortalFacilityAmendmentWithUkefIdMockBuilder() + .withDealId(dealId) + .withFacilityId(facilityId) + .withAmendmentId(amendmentId) + .withCriteria([ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: false }, + ]) + .build(); + + getApplicationMock.mockResolvedValue(mockDeal); + getFacilityMock.mockResolvedValue({ details: mockFacility }); + getAmendmentMock.mockResolvedValue(amendment); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should call getApplication with the correct dealId and userToken', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(getApplicationMock).toHaveBeenCalledTimes(1); + expect(getApplicationMock).toHaveBeenCalledWith({ dealId, userToken: req.session.userToken }); + }); + + it('should call getFacility with the correct facilityId and userToken', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(getFacilityMock).toHaveBeenCalledTimes(1); + expect(getFacilityMock).toHaveBeenCalledWith({ facilityId, userToken: req.session.userToken }); + }); + + it('should call getAmendment with the correct facilityId, amendmentId and userToken', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(getAmendmentMock).toHaveBeenCalledTimes(1); + expect(getAmendmentMock).toHaveBeenCalledWith({ facilityId, amendmentId, userToken: req.session.userToken }); + }); + + it('should render the manual approval needed template', async () => { + // Arrange + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + const expectedRenderData: ManualApprovalNeededViewModel = { + exporterName: companyName, + facilityType: mockFacility.type, + previousPage: getPreviousPage(PORTAL_AMENDMENT_PAGES.MANUAL_APPROVAL_NEEDED, amendment), + amendmentFormEmail, + returnLink, + }; + + expect(res._getStatusCode()).toEqual(HttpStatusCode.Ok); + expect(res._getRenderView()).toEqual('partials/amendments/manual-approval-needed.njk'); + expect(res._getRenderData()).toEqual(expectedRenderData); + }); + + it('should redirect if the facility is not found', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getFacilityMock.mockResolvedValue({ details: undefined }); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/not-found`); + }); + + it('should redirect if the deal is not found', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getApplicationMock.mockResolvedValue(undefined); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/not-found`); + }); + + it('should redirect if the amendment is not found', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getAmendmentMock.mockResolvedValue(undefined); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/not-found`); + }); + + it('should redirect if the facility cannot be amended', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getFacilityMock.mockResolvedValue({ details: { ...mockFacility, hasBeenIssued: false } }); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(`/gef/application-details/${dealId}`); + }); + + it('should redirect to the eligibility page if the responses to the criteria are all true', async () => { + // Arrange + const { req, res } = getHttpMocks(); + getAmendmentMock.mockResolvedValue({ + eligibilityCriteria: { + version: 1, + criteria: [ + { id: 1, text: 'Criterion 1', answer: true }, + { id: 2, text: 'Criterion 2', answer: true }, + ], + }, + }); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getStatusCode()).toEqual(HttpStatusCode.Found); + expect(res._getRedirectUrl()).toEqual(getAmendmentsUrl({ dealId, facilityId, amendmentId, page: PORTAL_AMENDMENT_PAGES.ELIGIBILITY })); + }); + + it('should render `problem with service` if getApplication throws an error', async () => { + // Arrange + getApplicationMock.mockRejectedValue(new Error('test error')); + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getRenderView()).toEqual('partials/problem-with-service.njk'); + }); + + it('should render `problem with service` if getFacility throws an error', async () => { + // Arrange + getFacilityMock.mockRejectedValue(new Error('test error')); + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getRenderView()).toEqual('partials/problem-with-service.njk'); + }); + + it('should render `problem with service` if getAmendment throws an error', async () => { + // Arrange + getAmendmentMock.mockRejectedValue(new Error('test error')); + const { req, res } = getHttpMocks(); + + // Act + await getManualApprovalNeeded(req, res); + + // Assert + expect(res._getRenderView()).toEqual('partials/problem-with-service.njk'); + }); +}); diff --git a/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts new file mode 100644 index 0000000000..5fd476ff41 --- /dev/null +++ b/gef-ui/server/controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts @@ -0,0 +1,64 @@ +import { CustomExpressRequest } from '@ukef/dtfs2-common'; +import { Response } from 'express'; +import * as api from '../../../services/api'; +import { asLoggedInUserSession } from '../../../utils/express-session'; +import { userCanAmendFacility } from '../../../utils/facility-amendments.helper'; +import { getAmendmentsUrl, getPreviousPage } from '../helpers/navigation.helper.ts'; +import { PORTAL_AMENDMENT_PAGES } from '../../../constants/amendments.ts'; +import { ManualApprovalNeededViewModel } from '../../../types/view-models/amendments/ManualApprovalNeededViewModel.ts'; +import { STB_PIM_EMAIL } from '../../../constants/emails.ts'; + +export type GetManualApprovalNeededRequest = CustomExpressRequest<{ + params: { dealId: string; facilityId: string; amendmentId: string }; +}>; + +/** + * controller to get the manual approval needed amendment page + * + * @param req - The express request + * @param res - The express response + */ +export const getManualApprovalNeeded = async (req: GetManualApprovalNeededRequest, res: Response) => { + try { + const { dealId, facilityId, amendmentId } = req.params; + const { userToken, user } = asLoggedInUserSession(req.session); + + const deal = await api.getApplication({ dealId, userToken }); + const { details: facility } = await api.getFacility({ facilityId, userToken }); + + if (!deal || !facility) { + console.error('Deal %s or Facility %s was not found', dealId, facilityId); + return res.redirect('/not-found'); + } + + if (!userCanAmendFacility(facility, deal, user.roles)) { + console.error('User cannot amend facility %s on deal %s', facilityId, dealId); + return res.redirect(`/gef/application-details/${dealId}`); + } + + const amendment = await api.getAmendment({ facilityId, amendmentId, userToken }); + + if (!amendment) { + console.error('Amendment %s not found on facility %s', amendmentId, facilityId); + return res.redirect('/not-found'); + } + + if (!amendment.eligibilityCriteria.criteria.some((criterion) => criterion.answer === false)) { + console.error('There are no false answers to the eligibility criteria, manual approval not necessarily required', amendmentId, facilityId); + return res.redirect(getAmendmentsUrl({ dealId, facilityId, amendmentId, page: PORTAL_AMENDMENT_PAGES.ELIGIBILITY })); + } + + const viewModel: ManualApprovalNeededViewModel = { + exporterName: deal.exporter.companyName, + facilityType: facility.type, + previousPage: getPreviousPage(PORTAL_AMENDMENT_PAGES.MANUAL_APPROVAL_NEEDED, amendment), + amendmentFormEmail: STB_PIM_EMAIL, + returnLink: '/dashboard/deals', + }; + + return res.render('partials/amendments/manual-approval-needed.njk', viewModel); + } catch (error) { + console.error('Error getting manual approval needed amendment page %o', error); + return res.render('partials/problem-with-service.njk'); + } +}; diff --git a/gef-ui/server/routes/facilities/amendments/index.ts b/gef-ui/server/routes/facilities/amendments/index.ts index 622111e89f..86e0c6243d 100644 --- a/gef-ui/server/routes/facilities/amendments/index.ts +++ b/gef-ui/server/routes/facilities/amendments/index.ts @@ -20,6 +20,7 @@ import { getBankReviewDate } from '../../../controllers/amendments/bank-review-d import { postBankReviewDate } from '../../../controllers/amendments/bank-review-date/post-bank-review-date.ts'; import { getEligibility } from '../../../controllers/amendments/eligibility-criteria/get-eligibility.ts'; import { postEligibility } from '../../../controllers/amendments/eligibility-criteria/post-eligibility.ts'; +import { getManualApprovalNeeded } from '../../../controllers/amendments/manual-approval-needed/get-manual-approval-needed.ts'; import { getEffectiveFrom } from '../../../controllers/amendments/effective-date/get-effective-from.ts'; import { postEffectiveFrom } from '../../../controllers/amendments/effective-date/post-effective-from.ts'; @@ -31,6 +32,7 @@ const { FACILITY_END_DATE, BANK_REVIEW_DATE, ELIGIBILITY, + MANUAL_APPROVAL_NEEDED, EFFECTIVE_DATE, CANCEL, } = PORTAL_AMENDMENT_PAGES; @@ -89,6 +91,11 @@ router .get(getEligibility) .post(postEligibility); +router + .route(`/application-details/:dealId/facilities/:facilityId/amendments/:amendmentId/${MANUAL_APPROVAL_NEEDED}`) + .all([validatePortalFacilityAmendmentsEnabled, validateToken, validateBank, validateRole({ role: [MAKER] })]) + .get(getManualApprovalNeeded); + router .route(`/application-details/:dealId/facilities/:facilityId/amendments/:amendmentId/${EFFECTIVE_DATE}`) .all([validatePortalFacilityAmendmentsEnabled, validateToken, validateBank, validateRole({ role: [MAKER] })]) diff --git a/gef-ui/server/types/view-models/amendments/ManualApprovalNeededViewModel.ts b/gef-ui/server/types/view-models/amendments/ManualApprovalNeededViewModel.ts new file mode 100644 index 0000000000..a6dce9bc2f --- /dev/null +++ b/gef-ui/server/types/view-models/amendments/ManualApprovalNeededViewModel.ts @@ -0,0 +1,9 @@ +import { FacilityType } from '@ukef/dtfs2-common'; + +export type ManualApprovalNeededViewModel = { + exporterName: string; + facilityType: FacilityType; + previousPage: string; + amendmentFormEmail: string; + returnLink: string; +}; diff --git a/gef-ui/templates/partials/amendments/manual-approval-needed.njk b/gef-ui/templates/partials/amendments/manual-approval-needed.njk new file mode 100644 index 0000000000..13cd12583d --- /dev/null +++ b/gef-ui/templates/partials/amendments/manual-approval-needed.njk @@ -0,0 +1,36 @@ +{% extends "index.njk" %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block pageTitle -%} + This amendment cannot be automatically approved +{%- endblock %} + +{% block content %} + {{ govukBackLink({ + text: "Back", + href: previousPage, + attributes: { + 'data-cy': 'back-link' + } + }) + }} + +
+
+

+ + {{ exporterName }}, {{ facilityType }} facility + + This amendment cannot be automatically approved +

+
+

Complete the Schedule 8 form (Amendment and Notification Request) and email to {{ amendmentFormEmail }}.

+

Include any supporting information and documents needed for UKEF to review and process the amendments.

+

Return to all applications and notices

+
+
+
+ + +{% endblock %} \ No newline at end of file