diff --git a/src/backend/src/common/interfaces/form-field.interface.ts b/src/backend/src/common/interfaces/form-field.interface.ts index 0144b1a73..262b3d029 100644 --- a/src/backend/src/common/interfaces/form-field.interface.ts +++ b/src/backend/src/common/interfaces/form-field.interface.ts @@ -5,4 +5,9 @@ export interface IFormField { type?: 'text' | 'boolean'; // add ORs for future support if needed isRichText?: boolean; allowedUserTypesEdit: Array; // null if no role restrictions apply + + // @isSystemGeneratedField + // applicable for fields like createdAt, createdBy, or IDIR-derived fields which are added by the server [and FE User cannot change these]. + // This is currently used to filter and validate role changes of the only USER specific fields when the user deletes a particular review - MPO/CPO/ProgramArea + isSystemGeneratedField?: boolean; } diff --git a/src/backend/src/modules/pia-intake/helper/update-review-submission-fields.ts b/src/backend/src/modules/pia-intake/helper/update-review-submission-fields.ts new file mode 100644 index 000000000..acbf49ea1 --- /dev/null +++ b/src/backend/src/modules/pia-intake/helper/update-review-submission-fields.ts @@ -0,0 +1,77 @@ +import { ForbiddenException } from '@nestjs/common'; +import { KeycloakUser } from 'src/modules/auth/keycloak-user.model'; +import { PiaIntakeStatusEnum } from '../enums/pia-intake-status.enum'; +import { Review } from '../jsonb-classes/review'; +import { CpoReview } from '../jsonb-classes/review/cpo-review'; +import { ProgramAreaSelectedRolesReview } from '../jsonb-classes/review/programArea/programAreaSelectedRoleReviews'; + +/** + * @method updateReviewSubmissionFields + * @description + * This method updates the dto with the submission related fields, which needs to be filled in by the server based on the user logged in + */ +export const updateReviewSubmissionFields = ( + updatedValue: + | Review + | Record + | Record, // add more types here + storedValue: + | Review + | Record + | Record, + user: KeycloakUser, + key: 'mpo' | string, + allowedInSpecificStatus?: PiaIntakeStatusEnum[] | null, + updatedStatus?: PiaIntakeStatusEnum, +) => { + if (!updatedValue?.[key]) return; + + // overwrite the updated values to include the stored fields that may not be passed by the client + if (updatedValue?.[key]) { + updatedValue[key] = { + ...(storedValue?.[key] || {}), + ...updatedValue?.[key], + }; + } + + // Scenario 1: User is saving review information for the first time [First time save] + // Scenario 2: User is saving review information for the subsequent times [Editing] + + // Either ways, if the value is changed from the stored one, update the submission fields + if ( + storedValue?.[key]?.isAcknowledged !== + updatedValue?.[key]?.isAcknowledged || + storedValue?.[key]?.reviewNote !== updatedValue?.[key]?.reviewNote + ) { + // if it is not the same person updating the fields, throw forbidden error + if ( + storedValue?.[key]?.reviewedByGuid && + storedValue?.[key]?.reviewedByGuid !== user.idir_user_guid + ) { + throw new ForbiddenException({ + message: `You do not have permissions to edit this review.`, + }); + } + + if ( + allowedInSpecificStatus && + updatedStatus && + !allowedInSpecificStatus.includes(updatedStatus) + ) { + throw new ForbiddenException({ + message: 'You do not permissions to update review in this status', + }); + } + + // if first time reviewed + if (!storedValue?.[key]?.reviewedByGuid) { + updatedValue[key].reviewedAt = new Date(); + updatedValue[key].reviewedByGuid = user.idir_user_guid; + updatedValue[key].reviewedByUsername = user.idir_username; + updatedValue[key].reviewedByDisplayName = user.display_name; + } + + // update last review updated at + updatedValue[key].reviewLastUpdatedAt = new Date(); + } +}; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/review/cpo-review.ts b/src/backend/src/modules/pia-intake/jsonb-classes/review/cpo-review.ts new file mode 100644 index 000000000..bee1016bb --- /dev/null +++ b/src/backend/src/modules/pia-intake/jsonb-classes/review/cpo-review.ts @@ -0,0 +1,95 @@ +import { IsBoolean, IsString } from '@nestjs/class-validator'; +import { Transform } from 'class-transformer'; +import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { IFormField } from 'src/common/interfaces/form-field.interface'; +import { validateRoleForFormField } from 'src/common/validators/form-field-role.validator'; +import { RoleReview } from './role-review'; + +export class CpoReview extends RoleReview { + // overriding the mandatory fields + @IsBoolean() + isAcknowledged: boolean; + + @IsString() + @Transform(({ value }) => value?.trim()) + reviewNote?: string; +} + +export const cpoReviewMetadata: Array> = [ + { + key: 'isAcknowledged', + type: 'boolean', + allowedUserTypesEdit: [UserTypesEnum.CPO], + isSystemGeneratedField: false, + }, + { + key: 'reviewNote', + type: 'text', + allowedUserTypesEdit: [UserTypesEnum.CPO], + isSystemGeneratedField: false, + }, + { + key: 'reviewedByDisplayName', + type: 'text', + allowedUserTypesEdit: [], // empty array signifies that the client can't edit these fields + isSystemGeneratedField: true, + }, + { + key: 'reviewedByUsername', + type: 'text', + allowedUserTypesEdit: [], + isSystemGeneratedField: true, + }, + { + key: 'reviewedByGuid', + type: 'text', + allowedUserTypesEdit: [], + isSystemGeneratedField: true, + }, + { + key: 'reviewedAt', + type: 'text', + allowedUserTypesEdit: [], + isSystemGeneratedField: true, + }, + { + key: 'reviewLastUpdatedAt', + type: 'text', + allowedUserTypesEdit: [], + isSystemGeneratedField: true, + }, +]; + +export const validateRoleForCpoReview = ( + updatedValue: CpoReview, + storedValue: CpoReview, + userType: UserTypesEnum[], + path: string, + isDeleted?: boolean, // when review is deleted, only check roles of NON-system generated values +) => { + if (!updatedValue) return; + + let keys = Object.keys(updatedValue) as Array; + + // if review is deleted, only check role for ONLY user generated keys + if (isDeleted) { + keys = keys.filter((key) => { + const metadata = cpoReviewMetadata.find((m) => m.key === key); + return !metadata.isSystemGeneratedField; + }); + } + + keys.forEach((key) => { + const updatedKeyValue = updatedValue?.[key]; + const storedKeyValue = storedValue?.[key]; + const metadata = cpoReviewMetadata.find((m) => m.key === key); + + validateRoleForFormField( + metadata, + updatedKeyValue, + storedKeyValue, + userType, + `${path}.${key}`, + ); + }); +}; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/review/index.ts b/src/backend/src/modules/pia-intake/jsonb-classes/review/index.ts index 84370e2cb..9e4eaa824 100644 --- a/src/backend/src/modules/pia-intake/jsonb-classes/review/index.ts +++ b/src/backend/src/modules/pia-intake/jsonb-classes/review/index.ts @@ -1,6 +1,9 @@ import { IsObject, IsOptional, ValidateNested } from '@nestjs/class-validator'; +import { ForbiddenException } from '@nestjs/common'; import { Type } from 'class-transformer'; import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { KeycloakUser } from 'src/modules/auth/keycloak-user.model'; +import { CpoReview, validateRoleForCpoReview } from './cpo-review'; import { MpoReview, validateRoleForMpoReview } from './mpo-review'; import { ProgramAreaReview, @@ -19,6 +22,10 @@ export class Review { @ValidateNested() @Type(() => MpoReview) mpo?: MpoReview; + + @IsObject() + @IsOptional() + cpo?: Record; } /** @@ -29,16 +36,95 @@ export const validateRoleForReview = ( updatedValue: Review, storedValue: Review, userType: UserTypesEnum[], + loggedInUser: KeycloakUser, ) => { - if (!updatedValue) return; + if (!updatedValue && !storedValue) return; + // // program area validations + // validateRoleForProgramAreaReview( updatedValue?.programArea, storedValue?.programArea, userType, + loggedInUser, ); + // // mpo validations - validateRoleForMpoReview(updatedValue?.mpo, storedValue?.mpo, userType); + // + let isMpoReviewDeleted = false; + + if (!updatedValue?.mpo && storedValue?.mpo) { + isMpoReviewDeleted = true; + + if (updatedValue?.mpo?.reviewedByGuid !== loggedInUser.idir_user_guid) { + new ForbiddenException({ + message: 'User is not allowed to remove review for the path', + path: `review.mpo`, + }); + } + } + + validateRoleForMpoReview( + updatedValue?.mpo, + storedValue?.mpo, + userType, + isMpoReviewDeleted, + ); + + // + // cpo validations + // + const updatedCpoReviews = updatedValue?.cpo; + const storedCpoReviews = storedValue?.cpo; + + // no records + if (!updatedCpoReviews && !storedCpoReviews) return; + + const updatedReviewers = Object.keys(updatedCpoReviews || {}); + const storedReviewers = Object.keys(storedCpoReviews || {}); + + // check role access for reviews that are cleared / removed reviews + // check role for non-system generated values + const deletedReviewers = storedReviewers.filter((r) => { + return ( + !updatedReviewers.includes(r) || // when the role key does not exist at all + !updatedCpoReviews[r] // or when the key exists, but null or undefined + ); + }); + + deletedReviewers.forEach((userId) => { + // check if user only deletes/removes reviews he only left + if ( + storedCpoReviews?.[userId]?.reviewedByGuid !== + loggedInUser?.idir_user_guid + ) { + new ForbiddenException({ + message: 'User is not allowed to remove review for the path', + path: `review.cpo.${userId}`, + }); + } + + // check for role considerations of the user + validateRoleForCpoReview( + updatedCpoReviews?.[userId], + storedCpoReviews?.[userId], + userType, + `review.cpo.${userId}`, + true, + ); + }); + + // check role access for reviews that are updated or added + updatedReviewers + .filter((r) => !deletedReviewers.includes(r)) // filter deleted / nullified reviews + .forEach((userId) => { + validateRoleForCpoReview( + updatedCpoReviews?.[userId], + storedCpoReviews?.[userId], + userType, + `review.cpo.${userId}`, + ); + }); }; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/review/mpo-review.ts b/src/backend/src/modules/pia-intake/jsonb-classes/review/mpo-review.ts index 130771b05..3fdfc0f86 100644 --- a/src/backend/src/modules/pia-intake/jsonb-classes/review/mpo-review.ts +++ b/src/backend/src/modules/pia-intake/jsonb-classes/review/mpo-review.ts @@ -15,36 +15,43 @@ export const mpoReviewMetadata: Array> = [ key: 'isAcknowledged', type: 'boolean', allowedUserTypesEdit: [UserTypesEnum.MPO], + isSystemGeneratedField: false, }, { key: 'reviewNote', type: 'text', allowedUserTypesEdit: [UserTypesEnum.MPO], + isSystemGeneratedField: false, }, { key: 'reviewedByDisplayName', type: 'text', allowedUserTypesEdit: [], // empty array signifies that the client can't edit these fields + isSystemGeneratedField: true, }, { key: 'reviewedByUsername', type: 'text', allowedUserTypesEdit: [], + isSystemGeneratedField: true, }, { key: 'reviewedByGuid', type: 'text', allowedUserTypesEdit: [], + isSystemGeneratedField: true, }, { key: 'reviewedAt', type: 'text', allowedUserTypesEdit: [], + isSystemGeneratedField: true, }, { key: 'reviewLastUpdatedAt', type: 'text', allowedUserTypesEdit: [], + isSystemGeneratedField: true, }, ]; @@ -52,10 +59,19 @@ export const validateRoleForMpoReview = ( updatedValue: MpoReview, storedValue: MpoReview, userType: UserTypesEnum[], + isDeleted?: boolean, ) => { if (!updatedValue) return; - const keys = Object.keys(updatedValue) as Array; + let keys = Object.keys(updatedValue) as Array; + + // if review is deleted, only check role for ONLY user generated keys + if (isDeleted) { + keys = keys.filter((key) => { + const metadata = mpoReviewMetadata.find((m) => m.key === key); + return !metadata.isSystemGeneratedField; + }); + } keys.forEach((key) => { const updatedKeyValue = updatedValue?.[key]; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/review/program-area-review.ts b/src/backend/src/modules/pia-intake/jsonb-classes/review/program-area-review.ts index 9390eda90..74131c6da 100644 --- a/src/backend/src/modules/pia-intake/jsonb-classes/review/program-area-review.ts +++ b/src/backend/src/modules/pia-intake/jsonb-classes/review/program-area-review.ts @@ -1,7 +1,9 @@ import { IsArray, IsObject, IsOptional } from '@nestjs/class-validator'; +import { ForbiddenException } from '@nestjs/common'; import { UserTypesEnum } from 'src/common/enums/users.enum'; import { IFormField } from 'src/common/interfaces/form-field.interface'; import { validateRoleForFormField } from 'src/common/validators/form-field-role.validator'; +import { KeycloakUser } from 'src/modules/auth/keycloak-user.model'; import { ProgramAreaSelectedRolesReview, validateRoleForSelectedRoleReviews, @@ -26,8 +28,9 @@ export const validateRoleForProgramAreaReview = ( updatedValue: ProgramAreaReview, storedValue: ProgramAreaReview, userType: UserTypesEnum[], + loggedInUser: KeycloakUser, ) => { - if (!updatedValue) return; + if (!updatedValue && !storedValue) return; // // selectedRoles @@ -52,14 +55,51 @@ export const validateRoleForProgramAreaReview = ( const updatedReviews = updatedValue?.reviews; const storedReviews = storedValue?.reviews; - if (!updatedReviews) return; + // no records + if (!updatedReviews && !storedReviews) return; - Object.keys(updatedReviews).forEach((role) => { + const updatedReviewers = Object.keys(updatedReviews || {}); + const storedReviewers = Object.keys(storedReviews || {}); + + // check role access for reviews that are cleared / removed reviews + // check role for non-system generated values + const deletedReviewers = storedReviewers.filter((r) => { + return ( + !updatedReviewers.includes(r) || // when the role key does not exist at all + !updatedReviews[r] // or when the key exists, but null or undefined + ); + }); + + deletedReviewers.forEach((role) => { + // check if user only deletes/removes reviews he only left + if ( + storedReviews?.[role]?.reviewedByGuid !== loggedInUser?.idir_user_guid + ) { + throw new ForbiddenException({ + message: 'User is not allowed to remove review for the path', + path: `review.programArea.reviews.${role}`, + }); + } + + // check for role considerations of the user validateRoleForSelectedRoleReviews( updatedReviews?.[role], storedReviews?.[role], userType, `review.programArea.reviews.${role}`, + true, ); }); + + // check role access for reviews that are updated or added + updatedReviewers + .filter((r) => !deletedReviewers.includes(r)) // filter deleted / nullified reviews + .forEach((role) => { + validateRoleForSelectedRoleReviews( + updatedReviews?.[role], + storedReviews?.[role], + userType, + `review.programArea.reviews.${role}`, + ); + }); }; diff --git a/src/backend/src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews.ts b/src/backend/src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews.ts index e5dedc5b3..137f692a0 100644 --- a/src/backend/src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews.ts +++ b/src/backend/src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews.ts @@ -55,13 +55,22 @@ export const validateRoleForSelectedRoleReviews = ( storedValue: ProgramAreaSelectedRolesReview, userType: UserTypesEnum[], path: string, + isDeleted?: boolean, // when review is deleted, only check roles of NON-system generated values ) => { if (!updatedValue) return; - const keys = Object.keys(updatedValue) as Array< + let keys = Object.keys(updatedValue) as Array< keyof ProgramAreaSelectedRolesReview >; + // if review is deleted, only check role for ONLY user generated keys + if (isDeleted) { + keys = keys.filter((key) => { + const metadata = selectedRolesReviewMetadata.find((m) => m.key === key); + return !metadata.isSystemGeneratedField; + }); + } + keys.forEach((key) => { const updatedKeyValue = updatedValue?.[key]; const storedKeyValue = storedValue?.[key]; diff --git a/src/backend/src/modules/pia-intake/pia-intake.service.ts b/src/backend/src/modules/pia-intake/pia-intake.service.ts index 2e17c14a9..4868f3f0e 100644 --- a/src/backend/src/modules/pia-intake/pia-intake.service.ts +++ b/src/backend/src/modules/pia-intake/pia-intake.service.ts @@ -39,7 +39,7 @@ import { Review, validateRoleForReview } from './jsonb-classes/review'; import { handlePiaStatusChange } from './helper/handle-pia-status-change'; import { PiaTypesEnum } from 'src/common/enums/pia-types.enum'; import { handlePiaUpdates } from './helper/handle-pia-updates'; -import { ProgramAreaSelectedRolesReview } from './jsonb-classes/review/programArea/programAreaSelectedRoleReviews'; +import { updateReviewSubmissionFields } from './helper/update-review-submission-fields'; @Injectable() export class PiaIntakeService { @@ -63,7 +63,7 @@ export class PiaIntakeService { // Get User role access type const accessType = this.getRoleAccess(userRoles); - this.validateJsonbFields(createPiaIntakeDto, null, accessType); + this.validateJsonbFields(createPiaIntakeDto, null, accessType, user); // fetch PIA TYPE const piaType = this.getPiaType(createPiaIntakeDto, null); @@ -72,15 +72,12 @@ export class PiaIntakeService { handlePiaStatusChange(createPiaIntakeDto, null, accessType, piaType); // once validated, updated the review fields - this.updateReviewSubmissionFields( - createPiaIntakeDto?.review, - null, - user, - 'mpo', - ); + updateReviewSubmissionFields(createPiaIntakeDto?.review, null, user, 'mpo'); this.updateProgramAreaReviews(createPiaIntakeDto, null, user); + this.updateCpoReviews(createPiaIntakeDto, null, user); + // TODO: add status restrictions: User can't create/edit PIA in *_REVIEW status [should be incomplete / Edits in progress only] const piaInfoForm: PiaIntakeEntity = await this.piaIntakeRepository.save({ @@ -128,7 +125,12 @@ export class PiaIntakeService { } // validate jsonb fields for role access - this.validateJsonbFields(updatePiaIntakeDto, existingRecord, accessType); + this.validateJsonbFields( + updatePiaIntakeDto, + existingRecord, + accessType, + user, + ); // fetch PIA TYPE const piaType = this.getPiaType(updatePiaIntakeDto, existingRecord); @@ -153,7 +155,7 @@ export class PiaIntakeService { this.updateReviewField(updatePiaIntakeDto, existingRecord); // once validated, updated the review submission fields - this.updateReviewSubmissionFields( + updateReviewSubmissionFields( updatePiaIntakeDto?.review, existingRecord?.review, user, @@ -162,6 +164,8 @@ export class PiaIntakeService { this.updateProgramAreaReviews(updatePiaIntakeDto, existingRecord, user); + this.updateCpoReviews(updatePiaIntakeDto, existingRecord, user); + // update the record with the provided keys [using save instead of update updates the @UpdateDateColumn] await this.piaIntakeRepository.save({ id, @@ -651,7 +655,7 @@ export class PiaIntakeService { }); } - this.updateReviewSubmissionFields( + updateReviewSubmissionFields( updatedProgramAreaReviews, storedProgramAreaReviews, user, @@ -662,69 +666,30 @@ export class PiaIntakeService { }); }; - /** - * @method updateReviewSubmissionFields - * @description - * This method updates the dto with the submission related fields, which needs to be filled in by the server based on the user logged in - */ - updateReviewSubmissionFields = ( - updatedValue: Review | Record, // add more types here - storedValue: Review | Record, + updateCpoReviews = ( + updatedValue: CreatePiaIntakeDto | UpdatePiaIntakeDto, + storedValue: PiaIntakeEntity, user: KeycloakUser, - key: 'mpo' | string, - allowedInSpecificStatus?: PiaIntakeStatusEnum[] | null, - updatedStatus?: PiaIntakeStatusEnum, ) => { - if (!updatedValue?.[key]) return; - - // overwrite the updated values to include the stored fields that may not be passed by the client - if (updatedValue?.[key]) { - updatedValue[key] = { - ...(storedValue?.[key] || {}), - ...updatedValue?.[key], - }; - } + const updatedCpoReviews = updatedValue?.review?.cpo; + const storedCpoReviews = storedValue?.review?.cpo; - // Scenario 1: User is saving review information for the first time [First time save] - // Scenario 2: User is saving review information for the subsequent times [Editing] + // GUID of the CPO reviewers + const updatedCpoReviewers = Object.keys(updatedCpoReviews || {}); - // Either ways, if the value is changed from the stored one, update the submission fields - if ( - storedValue?.[key]?.isAcknowledged !== - updatedValue?.[key]?.isAcknowledged || - storedValue?.[key]?.reviewNote !== updatedValue?.[key]?.reviewNote - ) { - // if it is not the same person updating the fields, throw forbidden error - if ( - storedValue?.[key]?.reviewedByGuid && - storedValue?.[key]?.reviewedByGuid !== user.idir_user_guid - ) { - throw new ForbiddenException({ - message: `You do not have permissions to edit this review.`, - }); - } - - if ( - allowedInSpecificStatus && - updatedStatus && - !allowedInSpecificStatus.includes(updatedStatus) - ) { - throw new ForbiddenException({ - message: 'You do not permissions to update review in this status', - }); - } - - // if first time reviewed - if (!storedValue?.[key]?.reviewedByGuid) { - updatedValue[key].reviewedAt = new Date(); - updatedValue[key].reviewedByGuid = user.idir_user_guid; - updatedValue[key].reviewedByUsername = user.idir_username; - updatedValue[key].reviewedByDisplayName = user.display_name; - } - - // update last review updated at - updatedValue[key].reviewLastUpdatedAt = new Date(); + // if no records to upsert + if (updatedCpoReviewers.length === 0) { + return; } + + Object.keys(updatedCpoReviews).forEach((reviewerId) => { + updateReviewSubmissionFields( + updatedCpoReviews, + storedCpoReviews, + user, + reviewerId, + ); + }); }; getPiaType( @@ -763,6 +728,7 @@ export class PiaIntakeService { updatedValue: CreatePiaIntakeDto | UpdatePiaIntakeDto, storedValue: PiaIntakeEntity, userType: UserTypesEnum[], + user: KeycloakUser, ) { validateRoleForCollectionUseAndDisclosure( updatedValue?.collectionUseAndDisclosure, @@ -770,7 +736,12 @@ export class PiaIntakeService { userType, ); - validateRoleForReview(updatedValue?.review, storedValue?.review, userType); + validateRoleForReview( + updatedValue?.review, + storedValue?.review, + userType, + user, + ); validateRoleForPPq(updatedValue?.ppq, storedValue?.ppq, userType); diff --git a/src/backend/test/unit/pia-intake/helpers/update-review-submission-fields.spec.ts b/src/backend/test/unit/pia-intake/helpers/update-review-submission-fields.spec.ts new file mode 100644 index 000000000..fc94015c0 --- /dev/null +++ b/src/backend/test/unit/pia-intake/helpers/update-review-submission-fields.spec.ts @@ -0,0 +1,225 @@ +import { ForbiddenException } from '@nestjs/common'; +import { PiaIntakeStatusEnum } from 'src/modules/pia-intake/enums/pia-intake-status.enum'; +import { updateReviewSubmissionFields } from 'src/modules/pia-intake/helper/update-review-submission-fields'; +import { ProgramAreaSelectedRolesReview } from 'src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews'; +import { keycloakUserMock } from 'test/util/mocks/data/auth.mock'; + +/** + * @method updateReviewSubmissionFields + * @description This method updates the dto with the submission related fields, which needs to be filled in by the server based on the user logged in + * @fieldsUpdated reviewedAt, reviewedByGuid, reviewedByUsername, reviewedByDisplayName, reviewLastUpdatedAt + * @input + * updatedValue: Review | Record + * storedValue: Review | Record + * user: KeycloakUser + * key: 'mpo' | string + * allowedInSpecificStatus?: PiaIntakeStatusEnum[] | null + * updatedStatus?: PiaIntakeStatusEnum + */ +describe('`updateReviewSubmissionFields` method', () => { + it('does not update anything if updatedValue is not provided', () => { + const updatedValue: Record = null; + const storedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, + }; + const user = { ...keycloakUserMock }; + + updateReviewSubmissionFields(updatedValue, storedValue, user, 'mpo'); + + expect(updatedValue?.mpo?.reviewLastUpdatedAt).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedAt).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByGuid).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByUsername).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByDisplayName).toBeUndefined(); + }); + + it('does not update anything if updatedValue key is different than expected', () => { + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, + }; + const storedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, + }; + const user = { ...keycloakUserMock }; + + updateReviewSubmissionFields(updatedValue, storedValue, user, 'RANDOM_KEY'); + + expect(updatedValue?.mpo?.reviewLastUpdatedAt).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedAt).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByGuid).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByUsername).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByDisplayName).toBeUndefined(); + }); + + it('does not update anything if updatedValue has NO updated data', () => { + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, // same as before + }; + const storedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, + }; + const user = { ...keycloakUserMock }; + + updateReviewSubmissionFields(updatedValue, storedValue, user, 'mpo'); + + expect(updatedValue?.mpo?.reviewLastUpdatedAt).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedAt).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByGuid).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByUsername).toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByDisplayName).toBeUndefined(); + }); + + it('update fields if updatedValue HAS updated data', () => { + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, + }; + const storedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, + }; + const user = { ...keycloakUserMock }; + + updateReviewSubmissionFields(updatedValue, storedValue, user, 'mpo'); + + expect(updatedValue?.mpo?.reviewLastUpdatedAt).not.toBeUndefined(); + expect(updatedValue?.mpo?.reviewedAt).not.toBeUndefined(); + expect(updatedValue?.mpo?.reviewedByGuid).toBe(user.idir_user_guid); + expect(updatedValue?.mpo?.reviewedByUsername).toBe(user.idir_username); + expect(updatedValue?.mpo?.reviewedByDisplayName).toBe(user.display_name); + }); + + it('fails and throws error if updatedValue has updated data, but a different reviewer', () => { + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, + }; + const storedValue: Record = { + mpo: { + isAcknowledged: true, + reviewNote: 'Test Note', + reviewedAt: new Date(), + reviewedByDisplayName: 'ABCD', + reviewedByGuid: 'OTHER_USER', // OTHER USER REVIEWED + reviewLastUpdatedAt: new Date(), + reviewedByUsername: 'RANDOM_USER', + }, + }; + + const user = { ...keycloakUserMock }; + + try { + updateReviewSubmissionFields(updatedValue, storedValue, user, 'mpo'); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + + it('succeeds and updates reviewLastUpdatedAt when updated data is provided by the same user', () => { + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, + }; + const user = { ...keycloakUserMock }; + const reviewedAt = new Date(); + const storedValue: Record = { + mpo: { + isAcknowledged: true, + reviewNote: 'Test Note', + reviewedAt: reviewedAt, + reviewedByDisplayName: user?.display_name, + reviewedByGuid: user?.idir_user_guid, + reviewLastUpdatedAt: reviewedAt, + reviewedByUsername: user?.idir_username, + }, + }; + + updateReviewSubmissionFields(updatedValue, storedValue, user, 'mpo'); + + expect(updatedValue.mpo.reviewLastUpdatedAt).not.toBe( + storedValue.mpo.reviewLastUpdatedAt, + ); + expect(updatedValue.mpo.reviewedAt).toBe(storedValue.mpo.reviewedAt); + expect(updatedValue.mpo.reviewedByDisplayName).toBe( + storedValue.mpo.reviewedByDisplayName, + ); + expect(updatedValue.mpo.reviewedByGuid).toBe( + storedValue.mpo.reviewedByGuid, + ); + expect(updatedValue.mpo.reviewedByUsername).toBe( + storedValue.mpo.reviewedByUsername, + ); + }); + + it('fails and throw error if updated are made in a NOT allowed status', () => { + const user = { ...keycloakUserMock }; + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, // same as before + }; + const reviewedAt = new Date(); + const storedValue: Record = { + mpo: { + isAcknowledged: true, + reviewNote: 'Test Note', + reviewedAt: reviewedAt, + reviewedByDisplayName: user?.display_name, + reviewedByGuid: user?.idir_user_guid, + reviewLastUpdatedAt: reviewedAt, + reviewedByUsername: user?.idir_username, + }, + }; + const key = 'mpo'; + const allowedInSpecificStatus: PiaIntakeStatusEnum[] = [ + PiaIntakeStatusEnum.COMPLETE, + PiaIntakeStatusEnum.MPO_REVIEW, + ]; + const updatedStatus: PiaIntakeStatusEnum = PiaIntakeStatusEnum.CPO_REVIEW; + + try { + updateReviewSubmissionFields( + updatedValue, + storedValue, + user, + key, + allowedInSpecificStatus, + updatedStatus, + ); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + + it('succeeds and updates if updated are made in an allowed status', () => { + const user = { ...keycloakUserMock }; + const updatedValue: Record = { + mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, // same as before + }; + const reviewedAt = new Date(); + const storedValue: Record = { + mpo: { + isAcknowledged: true, + reviewNote: 'Test Note', + reviewedAt: reviewedAt, + reviewedByDisplayName: user?.display_name, + reviewedByGuid: user?.idir_user_guid, + reviewLastUpdatedAt: reviewedAt, + reviewedByUsername: user?.idir_username, + }, + }; + const key = 'mpo'; + const allowedInSpecificStatus: PiaIntakeStatusEnum[] = [ + PiaIntakeStatusEnum.COMPLETE, + PiaIntakeStatusEnum.MPO_REVIEW, + ]; + const updatedStatus: PiaIntakeStatusEnum = PiaIntakeStatusEnum.MPO_REVIEW; // This status is allowed + + try { + updateReviewSubmissionFields( + updatedValue, + storedValue, + user, + key, + allowedInSpecificStatus, + updatedStatus, + ); + } catch (e) { + expect(e).not.toBeInstanceOf(ForbiddenException); + } + }); +}); diff --git a/src/backend/test/unit/pia-intake/jsonb-classes/review/index.spec.ts b/src/backend/test/unit/pia-intake/jsonb-classes/review/index.spec.ts new file mode 100644 index 000000000..b8a78e6e2 --- /dev/null +++ b/src/backend/test/unit/pia-intake/jsonb-classes/review/index.spec.ts @@ -0,0 +1,410 @@ +import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { KeycloakUser } from 'src/modules/auth/keycloak-user.model'; +import { + Review, + validateRoleForReview, +} from 'src/modules/pia-intake/jsonb-classes/review'; +import { keycloakUserMock } from 'test/util/mocks/data/auth.mock'; + +import * as validateRoleForCpoReview from 'src/modules/pia-intake/jsonb-classes/review/cpo-review'; +import * as validateRoleForMpoReview from 'src/modules/pia-intake/jsonb-classes/review/mpo-review'; +import * as validateRoleForProgramAreaReview from 'src/modules/pia-intake/jsonb-classes/review/program-area-review'; +import { piaReviewMock } from 'test/util/mocks/data/pia-review.mock'; +import { ForbiddenException } from '@nestjs/common'; + +/** + * @class Review + * @file src/modules/pia-intake/jsonb-classes/review/index.ts + */ +describe('`Review` class', () => { + /** + * @method validateRoleForReview + */ + describe('`validateRoleForReview` method', () => { + let validateRoleForProgramAreaReviewSpy = null; + let validateRoleForMpoReviewSpy = null; + let validateRoleForCpoReviewSpy = null; + + beforeEach(() => { + validateRoleForProgramAreaReviewSpy = jest + .spyOn( + validateRoleForProgramAreaReview, + 'validateRoleForProgramAreaReview', + ) + .mockImplementation(() => null); + + validateRoleForMpoReviewSpy = jest + .spyOn(validateRoleForMpoReview, 'validateRoleForMpoReview') + .mockImplementation(() => null); + + validateRoleForCpoReviewSpy = jest + .spyOn(validateRoleForCpoReview, 'validateRoleForCpoReview') + .mockImplementation(() => null); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not process the method when there are no values', () => { + const updatedValue: Review = null; + const storedValue: Review = null; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { ...keycloakUserMock }; + + validateRoleForReview(updatedValue, storedValue, userType, loggedInUser); + + expect(validateRoleForProgramAreaReviewSpy).not.toHaveBeenCalled(); + expect(validateRoleForMpoReviewSpy).not.toHaveBeenCalled(); + expect(validateRoleForCpoReviewSpy).not.toHaveBeenCalled(); + }); + + it('ProgramAreaReview, MpoReview: succeeds when appropriate values are supplied', () => { + const updatedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: { + isAcknowledged: true, + reviewNote: 'Test Note 2', + }, + }; + const storedValue: Review = null; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { ...keycloakUserMock }; + + validateRoleForReview(updatedValue, storedValue, userType, loggedInUser); + + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledWith( + updatedValue.programArea, + storedValue?.programArea, + userType, + loggedInUser, + ); + + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledWith( + updatedValue?.mpo, + storedValue?.mpo, + userType, + false, + ); + + expect(validateRoleForCpoReviewSpy).not.toHaveBeenCalled(); + }); + + it('fails when MPO Review is deleted by a user who did not create it', () => { + const updatedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: null, + }; + const storedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: { + ...piaReviewMock, + reviewedByGuid: 'KNOWN_USER', + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { + ...keycloakUserMock, + idir_user_guid: 'RANDOM_USER', + }; + + try { + validateRoleForReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledWith( + updatedValue.programArea, + storedValue?.programArea, + userType, + loggedInUser, + ); + + expect(validateRoleForMpoReviewSpy).not.toHaveBeenCalled(); + expect(validateRoleForCpoReviewSpy).not.toHaveBeenCalled(); + } + }); + + it('succeeds when MPO Review is deleted by the SAME user', () => { + const updatedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: null, + }; + const storedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: { + ...piaReviewMock, + reviewedByGuid: 'KNOWN_USER', + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { + ...keycloakUserMock, + idir_user_guid: 'KNOWN_USER', + }; + + validateRoleForReview(updatedValue, storedValue, userType, loggedInUser); + + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledWith( + updatedValue.programArea, + storedValue?.programArea, + userType, + loggedInUser, + ); + + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledWith( + updatedValue?.mpo, + storedValue?.mpo, + userType, + true, + ); + + expect(validateRoleForCpoReviewSpy).not.toHaveBeenCalled(); + }); + + it('Cpo Review: succeeds when appropriate values are supplied', () => { + const updatedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 2', + }, + cpo: { + USER_ID_1: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 3', + }, + }, + }; + const storedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + }, + }, + mpo: { + ...piaReviewMock, + isAcknowledged: true, + reviewNote: 'Test Note 2', + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.CPO]; + const loggedInUser: KeycloakUser = { ...keycloakUserMock }; + + validateRoleForReview(updatedValue, storedValue, userType, loggedInUser); + + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledWith( + updatedValue.programArea, + storedValue?.programArea, + userType, + loggedInUser, + ); + + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledWith( + updatedValue?.mpo, + storedValue?.mpo, + userType, + false, + ); + + expect(validateRoleForCpoReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForCpoReviewSpy).toHaveBeenCalledWith( + updatedValue?.cpo?.USER_ID_1, + storedValue?.cpo?.USER_ID_1, + userType, + `review.cpo.USER_ID_1`, + ); + }); + + it('fails when CPO Review is deleted by a user who did not create it', () => { + const updatedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { ...piaReviewMock }, + }, + }, + mpo: { ...piaReviewMock }, + cpo: { + USER_ID_1: null, + }, + }; + const storedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { ...piaReviewMock }, + }, + }, + mpo: { ...piaReviewMock }, + cpo: { + USER_ID_1: { + ...piaReviewMock, + reviewedByGuid: 'KNOWN_USER', + }, + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.CPO]; + const loggedInUser: KeycloakUser = { + ...keycloakUserMock, + idir_user_guid: 'RANDOM_USER', + }; + + try { + validateRoleForReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledWith( + updatedValue.programArea, + storedValue?.programArea, + userType, + loggedInUser, + ); + + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForCpoReviewSpy).toHaveBeenCalledWith( + updatedValue?.mpo, + storedValue?.mpo, + userType, + false, + ); + + expect(validateRoleForCpoReviewSpy).not.toHaveBeenCalled(); + } + }); + + it('succeeds when CPO Review is deleted by the SAME user', () => { + const updatedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { ...piaReviewMock }, + }, + }, + mpo: { ...piaReviewMock }, + cpo: { + USER_ID_1: null, + USER_ID_2: { ...piaReviewMock }, + }, + }; + const storedValue: Review = { + programArea: { + selectedRoles: ['Director'], + reviews: { + Director: { ...piaReviewMock }, + }, + }, + mpo: { ...piaReviewMock }, + cpo: { + USER_ID_1: { + ...piaReviewMock, + reviewedByGuid: 'KNOWN_USER', + }, + USER_ID_2: { ...piaReviewMock }, + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.CPO]; + const loggedInUser: KeycloakUser = { + ...keycloakUserMock, + idir_user_guid: 'KNOWN_USER', + }; + + validateRoleForReview(updatedValue, storedValue, userType, loggedInUser); + + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForProgramAreaReviewSpy).toHaveBeenCalledWith( + updatedValue.programArea, + storedValue?.programArea, + userType, + loggedInUser, + ); + + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledTimes(1); + expect(validateRoleForMpoReviewSpy).toHaveBeenCalledWith( + updatedValue?.mpo, + storedValue?.mpo, + userType, + false, + ); + + expect(validateRoleForCpoReviewSpy).toHaveBeenCalledTimes(2); // one with deleted review and one without + }); + }); +}); diff --git a/src/backend/test/unit/pia-intake/jsonb-classes/review/program-area-review.spec.ts b/src/backend/test/unit/pia-intake/jsonb-classes/review/program-area-review.spec.ts new file mode 100644 index 000000000..cc823e83a --- /dev/null +++ b/src/backend/test/unit/pia-intake/jsonb-classes/review/program-area-review.spec.ts @@ -0,0 +1,178 @@ +/** + * @file program-area-review.ts + */ + +import { UserTypesEnum } from 'src/common/enums/users.enum'; +import { KeycloakUser } from 'src/modules/auth/keycloak-user.model'; +import { + ProgramAreaReview, + validateRoleForProgramAreaReview, +} from 'src/modules/pia-intake/jsonb-classes/review/program-area-review'; +import { keycloakUserMock } from 'test/util/mocks/data/auth.mock'; +import * as validateRoleForFormField from 'src/common/validators/form-field-role.validator'; +import * as validateRoleForSelectedRoleReviews from 'src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews'; +import { piaReviewMock } from 'test/util/mocks/data/pia-review.mock'; +import { ForbiddenException } from '@nestjs/common'; + +describe('`ProgramAreaReview` class', () => { + let validateRoleForFormFieldSpy = null; + let validateRoleForSelectedRoleReviewsSpy = null; + + beforeEach(() => { + validateRoleForFormFieldSpy = jest + .spyOn(validateRoleForFormField, 'validateRoleForFormField') + .mockImplementation(() => null); + validateRoleForSelectedRoleReviewsSpy = jest + .spyOn( + validateRoleForSelectedRoleReviews, + 'validateRoleForSelectedRoleReviews', + ) + .mockImplementation(() => null); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + /** + * @method validateRoleForProgramAreaReview + */ + describe('`validateRoleForProgramAreaReview` method', () => { + it('does not process the method when there are no supplied values', () => { + const updatedValue: ProgramAreaReview = null; + const storedValue: ProgramAreaReview = null; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { ...keycloakUserMock }; + + validateRoleForProgramAreaReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + + expect(validateRoleForFormFieldSpy).not.toHaveBeenCalled(); + }); + + it('successfully validates selectedRoles and does not process mpo or cpo reviews', () => { + const updatedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + }; + const storedValue: ProgramAreaReview = null; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { ...keycloakUserMock }; + + validateRoleForProgramAreaReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + + expect(validateRoleForFormFieldSpy).toHaveBeenCalledTimes(2); + expect(validateRoleForSelectedRoleReviewsSpy).not.toHaveBeenCalled(); + }); + + it('successfully validates newly added reviews', () => { + const updatedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + reviews: { + ROLE_1: { + isAcknowledged: true, + reviewNote: 'TEST', + }, + }, + }; + const storedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + }; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { ...keycloakUserMock }; + + validateRoleForProgramAreaReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + + expect(validateRoleForFormFieldSpy).toHaveBeenCalledTimes(2); + expect(validateRoleForSelectedRoleReviewsSpy).toHaveBeenCalledWith( + updatedValue.reviews.ROLE_1, + undefined, + userType, + `review.programArea.reviews.ROLE_1`, + ); + }); + + it('fails and throws error if the review was deleted NOT by the user who created it', () => { + const updatedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + reviews: { + ROLE_1: { + isAcknowledged: true, + reviewNote: 'TEST', + }, + ROLE_2: null, // deleted review + }, + }; + const storedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + reviews: { + ROLE_2: { ...piaReviewMock }, + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { + ...keycloakUserMock, + idir_user_guid: 'RANDOM_USER', + }; + + try { + validateRoleForProgramAreaReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + expect(validateRoleForFormFieldSpy).toHaveBeenCalledTimes(2); + expect(validateRoleForSelectedRoleReviewsSpy).not.toHaveBeenCalled(); + } + }); + + it('succeeds if a review was deleted by the SAME user who created it', () => { + const updatedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + reviews: { + ROLE_1: { + isAcknowledged: true, + reviewNote: 'TEST', + }, + ROLE_2: null, // deleted review + }, + }; + const storedValue: ProgramAreaReview = { + selectedRoles: ['ROLE_1', 'ROLE_2'], + reviews: { + ROLE_2: { ...piaReviewMock, reviewedByGuid: 'KNOWN_USER' }, + }, + }; + const userType: UserTypesEnum[] = [UserTypesEnum.MPO]; + const loggedInUser: KeycloakUser = { + ...keycloakUserMock, + idir_user_guid: 'KNOWN_USER', + }; + + validateRoleForProgramAreaReview( + updatedValue, + storedValue, + userType, + loggedInUser, + ); + + expect(validateRoleForFormFieldSpy).toHaveBeenCalledTimes(2); + expect(validateRoleForSelectedRoleReviewsSpy).toHaveBeenCalledTimes(2); // one for deleted and one for addition + }); + }); +}); diff --git a/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts b/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts index 735655367..3ee1b5509 100644 --- a/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts +++ b/src/backend/test/unit/pia-intake/pia-intake.service.spec.ts @@ -52,7 +52,7 @@ import { } from 'test/util/mocks/data/invites.mock'; import { inviteeEntityMock } from 'test/util/mocks/data/invitee.mock'; import { PiaTypesEnum } from 'src/common/enums/pia-types.enum'; -import { ProgramAreaSelectedRolesReview } from 'src/modules/pia-intake/jsonb-classes/review/programArea/programAreaSelectedRoleReviews'; +import * as updateReviewSubmissionFields from 'src/modules/pia-intake/helper/update-review-submission-fields'; /** * @Description @@ -3038,252 +3038,6 @@ describe('PiaIntakeService', () => { }); }); - /** - * @method updateReviewSubmissionFields - * @description This method updates the dto with the submission related fields, which needs to be filled in by the server based on the user logged in - * @fieldsUpdated reviewedAt, reviewedByGuid, reviewedByUsername, reviewedByDisplayName, reviewLastUpdatedAt - * @input - * updatedValue: Review | Record - * storedValue: Review | Record - * user: KeycloakUser - * key: 'mpo' | string - * allowedInSpecificStatus?: PiaIntakeStatusEnum[] | null - * updatedStatus?: PiaIntakeStatusEnum - */ - describe('`updateReviewSubmissionFields` method', () => { - it('does not update anything if updatedValue is not provided', () => { - const updatedValue: Record = null; - const storedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, - }; - const user = { ...keycloakUserMock }; - - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - 'mpo', - ); - expect(updatedValue?.mpo?.reviewLastUpdatedAt).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedAt).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByGuid).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByUsername).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByDisplayName).toBeUndefined(); - }); - - it('does not update anything if updatedValue key is different than expected', () => { - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, - }; - const storedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, - }; - const user = { ...keycloakUserMock }; - - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - 'RANDOM_KEY', - ); - expect(updatedValue?.mpo?.reviewLastUpdatedAt).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedAt).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByGuid).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByUsername).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByDisplayName).toBeUndefined(); - }); - - it('does not update anything if updatedValue has NO updated data', () => { - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, // same as before - }; - const storedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, - }; - const user = { ...keycloakUserMock }; - - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - 'mpo', - ); - expect(updatedValue?.mpo?.reviewLastUpdatedAt).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedAt).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByGuid).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByUsername).toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByDisplayName).toBeUndefined(); - }); - - it('update fields if updatedValue HAS updated data', () => { - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, - }; - const storedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Test Note' }, - }; - const user = { ...keycloakUserMock }; - - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - 'mpo', - ); - expect(updatedValue?.mpo?.reviewLastUpdatedAt).not.toBeUndefined(); - expect(updatedValue?.mpo?.reviewedAt).not.toBeUndefined(); - expect(updatedValue?.mpo?.reviewedByGuid).toBe(user.idir_user_guid); - expect(updatedValue?.mpo?.reviewedByUsername).toBe(user.idir_username); - expect(updatedValue?.mpo?.reviewedByDisplayName).toBe(user.display_name); - }); - - it('fails and throws error if updatedValue has updated data, but a different reviewer', () => { - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, - }; - const storedValue: Record = { - mpo: { - isAcknowledged: true, - reviewNote: 'Test Note', - reviewedAt: new Date(), - reviewedByDisplayName: 'ABCD', - reviewedByGuid: 'OTHER_USER', // OTHER USER REVIEWED - reviewLastUpdatedAt: new Date(), - reviewedByUsername: 'RANDOM_USER', - }, - }; - - const user = { ...keycloakUserMock }; - - try { - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - 'mpo', - ); - } catch (e) { - expect(e).toBeInstanceOf(ForbiddenException); - } - }); - - it('succeeds and updates reviewLastUpdatedAt when updated data is provided by the same user', () => { - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, - }; - const user = { ...keycloakUserMock }; - const reviewedAt = new Date(); - const storedValue: Record = { - mpo: { - isAcknowledged: true, - reviewNote: 'Test Note', - reviewedAt: reviewedAt, - reviewedByDisplayName: user?.display_name, - reviewedByGuid: user?.idir_user_guid, - reviewLastUpdatedAt: reviewedAt, - reviewedByUsername: user?.idir_username, - }, - }; - - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - 'mpo', - ); - - expect(updatedValue.mpo.reviewLastUpdatedAt).not.toBe( - storedValue.mpo.reviewLastUpdatedAt, - ); - expect(updatedValue.mpo.reviewedAt).toBe(storedValue.mpo.reviewedAt); - expect(updatedValue.mpo.reviewedByDisplayName).toBe( - storedValue.mpo.reviewedByDisplayName, - ); - expect(updatedValue.mpo.reviewedByGuid).toBe( - storedValue.mpo.reviewedByGuid, - ); - expect(updatedValue.mpo.reviewedByUsername).toBe( - storedValue.mpo.reviewedByUsername, - ); - }); - - it('fails and throw error if updated are made in a NOT allowed status', () => { - const user = { ...keycloakUserMock }; - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, // same as before - }; - const reviewedAt = new Date(); - const storedValue: Record = { - mpo: { - isAcknowledged: true, - reviewNote: 'Test Note', - reviewedAt: reviewedAt, - reviewedByDisplayName: user?.display_name, - reviewedByGuid: user?.idir_user_guid, - reviewLastUpdatedAt: reviewedAt, - reviewedByUsername: user?.idir_username, - }, - }; - const key = 'mpo'; - const allowedInSpecificStatus: PiaIntakeStatusEnum[] = [ - PiaIntakeStatusEnum.COMPLETE, - PiaIntakeStatusEnum.MPO_REVIEW, - ]; - const updatedStatus: PiaIntakeStatusEnum = PiaIntakeStatusEnum.CPO_REVIEW; - - try { - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - key, - allowedInSpecificStatus, - updatedStatus, - ); - } catch (e) { - expect(e).toBeInstanceOf(ForbiddenException); - } - }); - - it('succeeds and updates if updated are made in an allowed status', () => { - const user = { ...keycloakUserMock }; - const updatedValue: Record = { - mpo: { isAcknowledged: true, reviewNote: 'Updated Note' }, // same as before - }; - const reviewedAt = new Date(); - const storedValue: Record = { - mpo: { - isAcknowledged: true, - reviewNote: 'Test Note', - reviewedAt: reviewedAt, - reviewedByDisplayName: user?.display_name, - reviewedByGuid: user?.idir_user_guid, - reviewLastUpdatedAt: reviewedAt, - reviewedByUsername: user?.idir_username, - }, - }; - const key = 'mpo'; - const allowedInSpecificStatus: PiaIntakeStatusEnum[] = [ - PiaIntakeStatusEnum.COMPLETE, - PiaIntakeStatusEnum.MPO_REVIEW, - ]; - const updatedStatus: PiaIntakeStatusEnum = PiaIntakeStatusEnum.MPO_REVIEW; // This status is allowed - - try { - service.updateReviewSubmissionFields( - updatedValue, - storedValue, - user, - key, - allowedInSpecificStatus, - updatedStatus, - ); - } catch (e) { - expect(e).not.toBeInstanceOf(ForbiddenException); - } - }); - }); - /** * @method updateProgramAreaReviews * @input fields @@ -3293,10 +3047,10 @@ describe('PiaIntakeService', () => { * */ describe('`updateProgramAreaReviews` method', () => { - let mockListener = null; + let updateReviewSubmissionFieldsSpy = null; beforeEach(() => { - mockListener = jest - .spyOn(service, 'updateReviewSubmissionFields') + updateReviewSubmissionFieldsSpy = jest + .spyOn(updateReviewSubmissionFields, 'updateReviewSubmissionFields') .mockImplementation(() => null); }); @@ -3306,7 +3060,7 @@ describe('PiaIntakeService', () => { const user = { ...keycloakUserMock }; service.updateProgramAreaReviews(updatedValue, storedValue, user); - expect(mockListener).not.toHaveBeenCalled(); + expect(updateReviewSubmissionFieldsSpy).not.toHaveBeenCalled(); }); it('fails and throws error when review is updated for fields not in selectedRoles', () => { @@ -3334,11 +3088,11 @@ describe('PiaIntakeService', () => { service.updateProgramAreaReviews(updatedValue, storedValue, user); } catch (e) { expect(e).toBeInstanceOf(BadRequestException); - expect(mockListener).not.toHaveBeenCalled(); + expect(updateReviewSubmissionFieldsSpy).not.toHaveBeenCalled(); } }); - it('succeeds when review is updated for fields in selectedRoles', () => { + it('successfully calls the subsequent method when review is updated for fields in selectedRoles', () => { const updatedValue: UpdatePiaIntakeDto = { saveId: 1, review: { @@ -3369,16 +3123,61 @@ describe('PiaIntakeService', () => { const user = { ...keycloakUserMock }; service.updateProgramAreaReviews(updatedValue, storedValue, user); - expect( - updatedValue?.review?.programArea?.reviews?.['Assistant Director'] - ?.reviewedByGuid, - ).toBe(user.idir_user_guid); - expect( - updatedValue?.review?.programArea?.reviews?.Boss?.reviewedByGuid, - ).toBeUndefined(); - expect( - updatedValue?.review?.programArea?.reviews?.['Boss 2']?.reviewedByGuid, - ).toBe(user.idir_user_guid); + + expect(updateReviewSubmissionFieldsSpy).toHaveBeenCalledTimes(2); + }); + }); + + /** + * @method updateCpoReviews + * @input fields + * updatedValue: CreatePiaIntakeDto | UpdatePiaIntakeDto, + * storedValue: PiaIntakeEntity, + * user: KeycloakUser, + */ + describe('`updateCpoReviews` method', () => { + let updateReviewSubmissionFieldsSpy = null; + beforeEach(() => { + updateReviewSubmissionFieldsSpy = jest + .spyOn(updateReviewSubmissionFields, 'updateReviewSubmissionFields') + .mockImplementation(() => null); + }); + + it('does not process the method when there is no updated reviews', () => { + const updatedValue: UpdatePiaIntakeDto = { saveId: 1 }; + const storedValue: PiaIntakeEntity = { ...piaIntakeEntityMock }; + const user = { ...keycloakUserMock }; + + service.updateCpoReviews(updatedValue, storedValue, user); + expect(updateReviewSubmissionFieldsSpy).not.toHaveBeenCalled(); + }); + + it('successfully calls the subsequent method when updatedCpoReviews have reviews', () => { + const updatedValue: UpdatePiaIntakeDto = { + saveId: 1, + review: { + cpo: { + GUID_1: { + isAcknowledged: true, + reviewNote: 'Test Note 1', + }, + GUID_2: { + isAcknowledged: true, + reviewNote: 'Test Note 2', + }, + GUID_3: { + isAcknowledged: true, + reviewNote: 'Test Note 3', + }, + }, + }, + }; + const storedValue: PiaIntakeEntity = { ...piaIntakeEntityMock }; + const user = { ...keycloakUserMock }; + + service.updateCpoReviews(updatedValue, storedValue, user); + + expect(updateReviewSubmissionFieldsSpy).toHaveBeenCalledTimes(3); }); }); }); diff --git a/src/backend/test/util/mocks/data/pia-review.mock.ts b/src/backend/test/util/mocks/data/pia-review.mock.ts new file mode 100644 index 000000000..72e6d144f --- /dev/null +++ b/src/backend/test/util/mocks/data/pia-review.mock.ts @@ -0,0 +1,11 @@ +import { keycloakUserMock } from './auth.mock'; + +export const piaReviewMock = { + isAcknowledged: true, + reviewNote: 'Test note!', + reviewedAt: new Date(), + reviewLastUpdatedAt: new Date(), + reviewedByGuid: keycloakUserMock.idir_user_guid, + reviewedByUsername: keycloakUserMock.idir_username, + reviewedByDisplayName: keycloakUserMock.display_name, +};