import { Injectable } from '@angular/core'; import { GCMockModule } from '@core/mocks/gc-module.mock'; import { ApplicantFormFromApi, ApplicationForUi, ApplicationFromApi } from '@core/typings/application.typing'; import { BudgetDetail } from '@core/typings/budget.typing'; import { CsrConnectStats, NonprofitDataApi, NonprofitDetail } from '@core/typings/organization.typing'; import { ProcessingTypes } from '@core/typings/payment.typing'; import { ProgramApplicantType } from '@core/typings/program.typing'; import { ApplicationStatuses } from '@core/typings/status.typing'; import { ViewTranslationsBlank } from '@core/typings/translation.typing'; import { WorkflowManagerActions } from '@core/typings/workflow.typing'; import { FullEmail, SimpleEmail } from '@features/communications/communications.typing'; import { ApplicationViewFormFromApi, AvailabilityOptions, CompletionRequirementType, FormAudience, FormDecisionTypes, FormStatuses, FormTypes, ResponseVisibilityOptions } from '@features/configure-forms/form.typing'; import { EmployeeSSOFieldsData } from '@features/employee-sso-fields/employee-sso-fields.typing'; import { EmailNotificationType } from '@features/system-emails/email.typing'; import { AddressFromApi } from '@yourcause/common'; import { AfterEach, BeforeEach, Spec, TestCase } from '@yourcause/test-decorators'; import { DescribeAngularService } from '@yourcause/test-decorators/angular'; import { expect, spy } from 'chai'; import { OrganizationEligibleForGivingStatus } from 'frontend-common/src/typings/search.typing'; import { skip, timer } from 'rxjs'; import { ApplicationViewService } from './application-view.service'; @Injectable({ providedIn: 'root' }) @DescribeAngularService(ApplicationViewService, { imports: [ GCMockModule ] }) export class ApplicationViewServiceSpec implements Spec { appId = 23532; reviewerRecommendedAmount = 346; orgId = 3; cycleId = 4; programId = 9; applicantId = 2; workflowId = 7; workflowLevelId = 8; applicationFormId = 10; grantProgramCycle = { id: this.cycleId, name: 'Cycle name', startDate: '', endDate: '', grantProgramId: this.programId, isArchived: false, clientOrganizationsProcessingTypeId: ProcessingTypes.YourCause, isClientProcessing: false }; applicationFromApi: ApplicationFromApi = { reservedFunds: false, budgetId: 12445, fundingSourceId: null, applicationGuid: 'abc-123', applicationId: this.appId, applicationStatus: null, submittedDate: '', applicantId: this.applicantId, applicantFirstName: 'Lukie', applicantCanReceiveEmails: true, applicantLastName: 'Smith', applicantEmail: 'lukie@mailinator.com', applicantPhoneNumber: '', applicationProfileImageUrl: '', applicantAddress: '', programName: 'Program name', programId: this.programId, grantProgramTimezone: 'UTC', organizationId: this.orgId, charityId: 5, organizationName: 'Org name', orgnizationImageUrl: '', organizationIdentification: '12-3415', organizationAddress: '', isPrivateOrg: false, daysInReview: 0, isApplicationInClientUserWorkflowLevel: true, workflowLevelApplicationReview: null, reviewRuleType: null, totalWorkflowLevelReviews: 4, totalWorkflowLevelUsers: 4, majorityReviewed: false, currentWorkflowName: 'WFL name', currentWorkFlowLevelName: 'WF name', parentWorkFlowLevelName: 'Parent WFL name', parentWorkFlowLevelId: 1, canRouteApplication: true, canArchiveAndUnarchiveApplication: true, canApproveApplication: true, canAwardApplication: true, canDeclineApplication: true, canArchiveApplication: true, canExtendFormDueDate: true, canRecommendFunding: true, hasNonPaidPayments: true, workflowLevelUsersWithoutReviews: [], workflowLevelRoutes: [], managerWillComplete: true, isDraft: true, programAllowCollaboration: true, programSendEmailsToCollaborators: true, amountRequested: 500, paymentDesignation: 'payment designation', careOf: null, createdBy: null, lastUpdatedBy: null, updatedDate: '', createdDate: '', nonprofitGuid: 'guid', currentWorkFlowLevelId: this.workflowLevelId, currentWorkFlowId: this.workflowId, isArchived: true, nominee: null, nominationApplicationId: 6, canAccessNominationApplication: true, isMasked: true, canViewMaskedApplicantInfo: false, specialHandlingAddress1: 'Address 1', specialHandlingAddress2: 'Address 2', specialHandlingCity: 'City', specialHandlingCountry: 'US', specialHandlingName: 'Special Handling name', specialHandlingPostalCode: '32690', specialHandlingStateProvinceRegion: 'SC', specialHandlingNotes: '', specialHandlingFileUrl: null, specialHandlingReason: '', currencyRequestedAmountEquivalent: 0, currencyRequested: 'USD', inKindAmountRequested: 0, inKindItems: [{ count: 2, itemIdentification: 'item' }], isProgramArchived: true, meetsVettingRequirement: true, grantProgramCycle: this.grantProgramCycle, hasAwards: false, hasPayments: false, hasCashAward: false, organizationEligibleForGivingStatus: OrganizationEligibleForGivingStatus.ELIGIBLE, recommendedFundingAmount: 0, orgIsInternational: false, applicantIsSSO: false, currentWorkflowLevelShowBudgetSummaryInfo: true, currentWorkflowLevelAllowUserToViewAwardsAndPayments: true, currentWorkflowLevelAllowAward: true }; csrData: CsrConnectStats = { clientCharityDonationsByYear: [{ donationYear: 1, totalDonors: 2, totalProcessedEmployeesDonations: 3, totalCompanyMatches: 4, totalVolunteerParticipants: 3, totalHoursVolunteered: 5 }], networkCharityDonationsByYear: [] }; applicationForUi: ApplicationForUi = { ...this.applicationFromApi, amountRequestedForEdit: this.applicationFromApi.currencyRequestedAmountEquivalent, designation: '' }; defaultFormId = 12; applicationForms: ApplicantFormFromApi[] = [{ updatedBy: '', updatedDate: '', createdBy: '', impersonatedBy: '', createImpersonatedBy: '', formTypeId: FormTypes.REQUEST, applicationFormId: this.applicationFormId, formId: this.defaultFormId, formRevisionId: 5, formName: 'Form name', formDescription: '', formStatus: FormStatuses.DraftSaved, statusUpdatedDate: '', statusUpdatedBy: '', formSubmittedOn: '', totalCommentsInCurrentWorkflow: 0, canRequestRevision: false, canApproveApplicaitonForm: false, canDeclineApplicationForm: false, formData: {}, statusUpdatedByClientUser: null, formComments: [], myCommentInCurrentLevel: '', programApplicantType: ProgramApplicantType.ORGS, isDefault: true, portalFormAvailabilityInfo: null, dueDate: '', submittedBy: '', isDraft: false, formDefinition: [], requireSignature: false, signatureDescription: '' }]; formId = 23523; revisionId = 547; appViewFormsFromApi: ApplicationViewFormFromApi[] = [{ audience: FormAudience.MANAGER, formId: this.formId, formRevisionId: this.revisionId, grantManagerActionType: ResponseVisibilityOptions.VIEW_AT_ALL_WORKFLOW, responses: [], otherLevelResponses: [], nominationResponses: [], name: '', formType: FormTypes.REVIEW, comments: [], actionType: ResponseVisibilityOptions.VIEW_AT_ALL_WORKFLOW, completionRequirementType: CompletionRequirementType.ALL_USERS, specificNumberForCompletion: null, managersCount: 4, portalAvailabilityDetails: { portalFormAvailabilityType: AvailabilityOptions.AUTO, portalFormAvailabilityDateOption: null, portalFormAvailabilityDateOffset: '', portalFormAvailabilityDate: '', portalFormAvailabilityDynamicDateApplicationApproval: '', portalFormAvailabilityDynamicDateAwardCreation: '', portalFormAvailabilityDynamicDateApplicationSubmission: '', portalFormAvailabilityDynamicDateWorkflowLevelEntry: '' }, dueDate: '', sortOrder: 1, requireSignature: false, signatureDescription: '' }]; defaultAddress: AddressFromApi = { address1: 'Address 1', address2: '', city: 'City', country: 'US', stateProvRegCode: 'SC', stateProvRegName: 'SC', countryCode: 'US', postalCode: '23533' }; nonprofitDetail = { name: 'Org Name', displayAddress: this.defaultAddress, address: this.defaultAddress, remittanceInfo: { address: this.defaultAddress } } as NonprofitDetail; setAppViewMapSpies = [ 'getApplicationDetail', 'addPermissionsForApplicationViewMap', 'getOrgForInfoPanel', 'getShowSendToApplicant', 'adaptPrimaryApplicant' ]; @BeforeEach() mock (service: ApplicationViewService) { service['applicationViewResources'].getApplication = async () => { return this.applicationFromApi; }; service['budgetService'].getBudgetDetail = async () => { return { budgetFundingSources: [{ budgetId: 1, fundingSourceId: 2 }] } as BudgetDetail; }; service['applicationViewResources'].getReviewerRecommendedFundingInfo = async () => { return { amountRequested: 500, currencyRequestedAmountEquivalent: 500, currencyRequested: 'USD', recommendedFundingAmount: 1000, reviewerRecommendations: [{ reviewerRecommendedFundingAmount: this.reviewerRecommendedAmount, decision: FormDecisionTypes.Approve, createdBy: '', createdDate: '', submittedDate: '', workflowLevelName: '' }] }; }; service['aggregationService'].getCharityDonationsByYear = async () => { return this.csrData; }; service['translationService'].setViewTranslationsOnState(ViewTranslationsBlank); service['communicationService']['communicationResources'].getCommunicationsForApplication = async () => { return { emails: [], comments: [], manualCommunicationRecords: [] }; }; service['applicationFormService']['applicationFormResources'].getAllApplicationForms = async () => { return { formDetails: this.appViewFormsFromApi, applicationReferenceFieldResponses: [] }; }; service['applicationFormService']['applicationFormResources'].getApplicationForms = async () => { return this.applicationForms; }; service['systemTagsService']['fetchTagsForRecord'] = async () => {}; service['awardService'].getAwards = async () => { return { totalAmount: 0, awards: [] }; }; service['employeeSSOFieldsService']['employeeSSOFieldsResources'].getEmployeeSSOFieldsForApp = async () => { return {} as EmployeeSSOFieldsData; }; service['applicationActionService']['workflowService']['workflowResources'].getMyWorkflowManagerRoles = async () => { return [{ workflowId: this.workflowId, workflowActions: [ WorkflowManagerActions.Approve, WorkflowManagerActions.NotifyOfStatus ] }, { workflowId: this.workflowId, workflowActions: [ WorkflowManagerActions.Approve, WorkflowManagerActions.NotifyOfStatus ] }]; }; service['applicationActionService']['workflowService'].setMyWorkflowManagerRolesMap(); service['specialHandlingService']['nonprofitService']['nonprofitResources'].getNonprofitAdditionalDataByGuid = async () => { return { nonprofitDetail: this.nonprofitDetail, nonprofit: {} as NonprofitDataApi }; }; } @AfterEach() restore () { spy.restore(); } @TestCase('should be able to get application details') async getApplicationDetail (service: ApplicationViewService) { const detail = await service.getApplicationDetail(this.appId); expect(detail.nonprofitGuid === this.applicationFromApi.nonprofitGuid); expect(detail.applicationId === this.applicationFromApi.applicationId); expect(detail.isMasked === this.applicationFromApi.isMasked); expect(detail.programId === this.applicationFromApi.programId); expect(detail.recommendedFundingAmount === this.applicationFromApi.recommendedFundingAmount); } @TestCase('should be able to get reviewer recommended funding info') async getReviewerRecommendedFundingInfo (service: ApplicationViewService) { const result = await service.getReviewerRecommendedFundingInfo(this.appId); const hasReviewersRecommendation = result.some((res) => { return res.recommendedFundingAmount === this.reviewerRecommendedAmount; }); expect(hasReviewersRecommendation).to.be.true; } @TestCase('should be able to resolve application view') async resolveApplicationView (service: ApplicationViewService) { const error = { error: { message: 'error' } }; spy.on(service, 'setCsrData', async () => { // eslint-disable-next-line no-throw-literal throw error; }); let loggedError: unknown; spy.on(service['logger'], 'error', (err) => { loggedError = err; }); await service.resolveApplicationView(this.appId); const details = service.applicationViewMap[this.appId]; const applicationIsSet = !!details.application; const applicantIsSet = !!details.primaryApplicant; expect(applicantIsSet).to.be.true; expect(applicationIsSet).to.be.true; expect(loggedError).to.deep.equal(error); } @TestCase('should be able to resolve application view - fail') async resolveApplicationViewFail (service: ApplicationViewService) { spy.on(service, 'setApplicationViewMap', async () => { // eslint-disable-next-line no-throw-literal throw { error: { message: 'error' } }; }); spy.on(service['logger'], 'error'); spy.on(service['notifier'], 'error'); let err: unknown; try { await service.resolveApplicationView(this.appId); } catch(e) { err = e; } const hasError = !!err; expect(hasError).to.be.true; expect(service['logger']['error']).to.have.been.called.once; expect(service['notifier']['error']).to.have.been.called.once; } @TestCase('should be able to set csr data') async setCsrData (service: ApplicationViewService) { await service.setCsrData(this.appId, 1235); const details = service.applicationViewMap[this.appId]; expect(details.csrData).to.deep.equal(this.csrData); } @TestCase('should be able to add permissions for application view map') addPermissionsForApplicationViewMap (service: ApplicationViewService) { const adapted = service.addPermissionsForApplicationViewMap(this.applicationForUi); expect(adapted.canApproveApplication).to.be.true; expect(adapted.canAwardApplication).to.be.true; } @TestCase('should be able to set application view map - not edit') async setApplicationViewMap (service: ApplicationViewService) { spy.on(service, this.setAppViewMapSpies); spy.on(service['specialHandlingService'], 'getDefaultSpecialHandling'); spy.on(service['applicationFormService'], 'getAllApplicationForms'); spy.on(service['applicationFormService'], 'getApplicantFormsForApplication'); spy.on(service['awardService'], 'getAwards'); spy.on(service['employeeSSOFieldsService'], 'getEmployeeSSOFieldsForApp'); spy.on(service['systemTagsService'], 'fetchTagsForRecord'); spy.on(service['budgetService'], 'getBudgetDetail'); const isEdit = false; const isNewApp = false; await service.setApplicationViewMap(this.appId, isEdit, isNewApp); const map = service.applicationViewMap; const detail = map[this.appId]; expect(detail.application.amountRequested).to.be.equal(500); expect(detail.application.currentWorkFlowId).to.be.equal(this.workflowId); expect(detail.application.applicationStatus).to.be.equal(ApplicationStatuses.Draft); // Get app detail called twice - once for app and another for the related nomination expect(service['getApplicationDetail']).to.have.been.called.twice; expect(service['addPermissionsForApplicationViewMap']).to.have.been.called.once; expect(service['specialHandlingService']['getDefaultSpecialHandling']).to.have.been.called.once; expect(service['applicationFormService']['getApplicantFormsForApplication']).to.have.been.called.once; expect(service['awardService']['getAwards']).to.have.been.called.once; expect(service['employeeSSOFieldsService']['getEmployeeSSOFieldsForApp']).to.have.been.called.once; expect(service['systemTagsService']['fetchTagsForRecord']).to.have.been.called.once; expect(service['getOrgForInfoPanel']).to.have.been.called.once; expect(service['budgetService']['getBudgetDetail']).to.have.been.called.once; expect(service['adaptPrimaryApplicant']).to.have.been.called.once; expect(service['getShowSendToApplicant']).to.have.been.called; // This is only for view expect(service['applicationFormService']['getAllApplicationForms']).to.not.have.been.called; } @TestCase('should be able to set application view map - edit') async setApplicationViewMapEdit (service: ApplicationViewService) { spy.on(service, this.setAppViewMapSpies); spy.on(service['specialHandlingService'], 'getDefaultSpecialHandling'); spy.on(service['applicationFormService'], 'getAllApplicationForms'); spy.on(service['applicationFormService'], 'getApplicantFormsForApplication'); spy.on(service['awardService'], 'getAwards'); spy.on(service['employeeSSOFieldsService'], 'getEmployeeSSOFieldsForApp'); spy.on(service['systemTagsService'], 'fetchTagsForRecord'); spy.on(service['budgetService'], 'getBudgetDetail'); const isEdit = true; const isNewApp = true; await service.setApplicationViewMap(this.appId, isEdit, isNewApp); const map = service.applicationEditMap; const detail = map[this.appId]; // new apps set amount requested to null const hasCorrectAmount = detail.application.amountRequested === null; expect(hasCorrectAmount).to.be.true; expect(detail.application.currentWorkFlowId).to.be.equal(this.workflowId); expect(detail.application.applicationStatus).to.be.equal(ApplicationStatuses.Draft); // Get app detail called twice - once for app and another for the related nomination expect(service['getApplicationDetail']).to.have.been.called.twice; expect(service['addPermissionsForApplicationViewMap']).to.have.been.called.once; expect(service['specialHandlingService']['getDefaultSpecialHandling']).to.have.been.called.once; expect(service['applicationFormService']['getApplicantFormsForApplication']).to.have.been.called.once; expect(service['awardService']['getAwards']).to.have.been.called.once; expect(service['employeeSSOFieldsService']['getEmployeeSSOFieldsForApp']).to.have.been.called.once; expect(service['systemTagsService']['fetchTagsForRecord']).to.have.been.called.once; expect(service['getOrgForInfoPanel']).to.have.been.called.once; expect(service['budgetService']['getBudgetDetail']).to.have.been.called.once; expect(service['adaptPrimaryApplicant']).to.have.been.called.once; // This is only for view expect(service['applicationFormService']['getAllApplicationForms']).to.not.have.been.called; expect(service['getShowSendToApplicant']).to.not.have.been.called; } @TestCase('should be able to get org info for panel') getOrgForInfoPanel (service: ApplicationViewService) { const parentNonprofitGuid = 'parentNonprofitGuid'; const result = service.getOrgForInfoPanel( this.applicationForUi, { defaultAddress: null, orgAddressString: '', nonprofitDetail: { parentNonprofitGuid } as NonprofitDetail } ); expect(result.id).to.be.equal(this.orgId); expect(result.identification).to.be.equal(this.applicationFromApi.organizationIdentification); expect(result.parentGuid).to.be.equal(parentNonprofitGuid); } @TestCase('should be able to get org info for panel - no org') getOrgForInfoPanelNoOrg (service: ApplicationViewService) { const parentNonprofitGuid = 'parentNonprofitGuid'; const result = service.getOrgForInfoPanel( { ...this.applicationForUi, organizationId: null }, { defaultAddress: null, orgAddressString: '', nonprofitDetail: { parentNonprofitGuid } as NonprofitDetail } ); const noResult = !result; expect(noResult).to.be.true; } @TestCase('should be able to adapt primary applicant') adaptPrimaryApplicant (service: ApplicationViewService) { const applicant = service.adaptPrimaryApplicant(this.applicationForUi); expect(applicant.id).to.be.equal(this.applicationFromApi.applicantId); expect(applicant.lastName).to.be.equal(this.applicationFromApi.applicantLastName); expect(applicant.canReceiveEmails).to.be.equal(this.applicationFromApi.applicantCanReceiveEmails); } @TestCase('should be able to set application edit map') async setApplicationEditMap (service: ApplicationViewService) { await service.setApplicationViewMap(this.appId, true); const map = service.applicationEditMap; const detail = map[this.appId]; expect(detail.formsForEdit.length).to.be.equal(this.applicationForms.length); expect(detail.defaultFormId).to.be.equal(this.defaultFormId); expect(detail.application.currentWorkFlowId).to.be.equal(this.workflowId); } @TestCase('should know when send to applicant should be false') async getShowSendToApplicantFalse (service: ApplicationViewService) { let notifyApplicant = false; let isDraft = true; // If isDraft or notifyApplicant are false, showSendApplication is false let showSendApplication = await service.getShowSendToApplicant( this.appId, notifyApplicant, isDraft ); expect(showSendApplication).to.be.false; notifyApplicant = true; isDraft = false; showSendApplication = await service.getShowSendToApplicant( this.appId, notifyApplicant, isDraft ); expect(showSendApplication).to.be.false; notifyApplicant = false; isDraft = false; showSendApplication = await service.getShowSendToApplicant( this.appId, notifyApplicant, isDraft ); expect(showSendApplication).to.be.false; } @TestCase('show send to applicant should be false bc email was already sent') async getShowSendToApplicantFalseEmailExists (service: ApplicationViewService) { service['communicationService']['communicationResources'].getCommunicationsForApplication = async () => { return { emails: [{ id: 1, emailNotificationType: EmailNotificationType.ApplicantAddedToApplication } as SimpleEmail], comments: [], manualCommunicationRecords: [] }; }; service['communicationService']['communicationResources'].getEmailDetails = async () => { return { applicationId: this.appId } as FullEmail; }; // isDraft and notifyApplicant need to be true in order to get to email check const notifyApplicant = true; const isDraft = true; const showSendApplication = await service.getShowSendToApplicant( this.appId, notifyApplicant, isDraft ); // This email was already sent, so this should return false expect(showSendApplication).to.be.false; } @TestCase('show send to applicant should be true bc email was not sent') async getShowSendToApplicantTrue (service: ApplicationViewService) { // isDraft and notifyApplicant need to be true in order to get to email check const notifyApplicant = true; const isDraft = true; service['communicationService']['communicationResources'].getEmailDetails = async () => { return { applicationId: this.appId } as FullEmail; }; const showSendApplication = await service.getShowSendToApplicant( this.appId, notifyApplicant, isDraft ); // This email was not sent so thsi should be true expect(showSendApplication).to.be.true; } @TestCase('should be able to trigger approve modal') async triggerApproveModal (service: ApplicationViewService) { let triggered = false; service.changesTo$('triggerApproveModal').pipe(skip(1)).subscribe(() => { triggered = true; }); service.triggerApproveModal(); await timer(1000).toPromise(); expect(triggered).to.be.true; } @TestCase('should be able to trigger activity tab update') async triggerActivityTabUpdate (service: ApplicationViewService) { let triggered = false; service.changesTo$('triggerActivityTabUpdate').pipe(skip(1)).subscribe(() => { triggered = true; }); service.triggerActivityTabUpdate(); await timer(1000).toPromise(); expect(triggered).to.be.true; } @TestCase('should be able to get can view application') async canViewApplication (service: ApplicationViewService) { const canView = true; spy.on(service['applicationViewResources'], 'canViewApplication', async () => { return canView; }); const result = await service.canViewApplication(1); expect(result).to.be.equal(canView); } @TestCase('should be able to get audit trail') async getApplicationAuditTrail (service: ApplicationViewService) { spy.on(service['applicationViewResources'], 'getApplicationAuditTrail', async () => {}); await service.getApplicationAuditTrail(1); expect(service['applicationViewResources']['getApplicationAuditTrail']).to.have.been.called.once; } }