import { BadRequestException } from "@nestjs/common"; import { Types } from "mongoose"; import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum"; import { DaghiOption } from "src/Types&Enums/claim-request-management/daghi-option.enum"; import { RoleEnum } from "src/Types&Enums/role.enum"; import { TypeOfDamage } from "src/Types&Enums/claim-request-management/type-of-damage.enum"; import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum"; import { ExpertClaimService } from "./expert-claim.service"; const V2_EXPERT_ID = "66ec0e480e321873c0900001"; const blankPricingReply = { description: "Damage assessment", parts: [ { partId: 201, typeOfDamage: TypeOfDamage.Repair, price: "", salary: "", totalPayment: "", factorNeeded: false, daghi: { option: DaghiOption.NO_VALUE }, }, ], }; function createService() { return new (ExpertClaimService as any)( ...new Array(21).fill(undefined), ) as ExpertClaimService; } function setupSuccessfulV2Submit(blameType: BlameRequestType) { const service = createService() as any; const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined); const claim = { _id: "66ec0e480e321873c0900002", publicId: "CLM-1", blameRequestId: "66ec0e480e321873c0900003", status: ClaimCaseStatus.EXPERT_REVIEWING, workflow: { locked: true, lockedBy: { actorId: V2_EXPERT_ID } }, vehicle: { carType: "sedan" }, damage: { selectedParts: [ { id: 1, name: "front", side: "", label_fa: "جلو کامل", catalogKey: "1", }, ], }, }; service.claimCaseDbService = { findById: jest.fn().mockResolvedValue(claim), findByIdAndUpdate, }; service.blameRequestDbService = { findById: jest.fn().mockResolvedValue({ type: blameType, expertInitiated: true, creationMethod: "IN_PERSON", }), }; service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined); service.recordClaimExpertActivity = jest.fn().mockResolvedValue(undefined); return { service, findByIdAndUpdate }; } const validV2Reply = { parts: [ { partId: 1, typeOfDamage: TypeOfDamage.Repair, salary: "1000000", totalPayment: "1000000", factorNeeded: false, }, ], }; describe("ExpertClaimService expert-reply pricing", () => { it("requires the current car price for a car-body expert reply", async () => { const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( BlameRequestType.CAR_BODY, ); await expect( service.submitExpertReplyV2("v2-claim", validV2Reply, { sub: V2_EXPERT_ID, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }), ).rejects.toMatchObject({ response: { code: "CAR_PRICE_REQUIRED", field: "carPrice", }, }); expect(findByIdAndUpdate).not.toHaveBeenCalled(); }); it("normalizes and persists the current car price on car-body replies", async () => { const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( BlameRequestType.CAR_BODY, ); await service.submitExpertReplyV2( "v2-claim", { ...validV2Reply, carPrice: "۱٬۲۵۰٬۰۰۰٬۰۰۰" }, { sub: V2_EXPERT_ID, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }, ); expect(findByIdAndUpdate).toHaveBeenCalledTimes(1); expect(findByIdAndUpdate.mock.calls[0][1]).toMatchObject({ "vehicle.price": "1250000000", "vehicle.carPrice": 1250000000, }); }); it("rejects the current car price on third-party replies", async () => { const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( BlameRequestType.THIRD_PARTY, ); await expect( service.submitExpertReplyV2( "v2-claim", { ...validV2Reply, carPrice: "1250000000" }, { sub: V2_EXPERT_ID, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }, ), ).rejects.toMatchObject({ response: { code: "CAR_PRICE_NOT_ALLOWED", field: "carPrice", }, }); expect(findByIdAndUpdate).not.toHaveBeenCalled(); }); it("does not overwrite vehicle.price on third-party replies", async () => { const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( BlameRequestType.THIRD_PARTY, ); await service.submitExpertReplyV2("v2-claim", validV2Reply, { sub: V2_EXPERT_ID, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }); expect(findByIdAndUpdate).toHaveBeenCalledTimes(1); expect(findByIdAndUpdate.mock.calls[0][1]).not.toHaveProperty( "vehicle.price", ); }); it("enforces the configured total-payment cap for a V1 user-created file", async () => { const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true"; try { const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( BlameRequestType.THIRD_PARTY, ); (service as any).blameRequestDbService.findById.mockResolvedValue({ type: BlameRequestType.THIRD_PARTY, creationMethod: "NORMAL", }); await expect( service.submitExpertReplyV2( "v1-claim", { ...validV2Reply, parts: [ { ...validV2Reply.parts[0], salary: "1000000", totalPayment: "600000000", }, ], }, { sub: V2_EXPERT_ID, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }, ), ).rejects.toMatchObject({ response: { code: "PRICE_CAP_ERROR" } }); expect(findByIdAndUpdate).not.toHaveBeenCalled(); } finally { if (previousEnabled === undefined) { delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; } else { process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled; } } }); it("does not enforce the V1 cap for an expert-initiated flow", async () => { const previousEnabled = process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = "true"; try { const { service, findByIdAndUpdate } = setupSuccessfulV2Submit( BlameRequestType.THIRD_PARTY, ); await service.submitExpertReplyV2( "v3-claim", { ...validV2Reply, parts: [ { ...validV2Reply.parts[0], salary: "1000000", totalPayment: "600000000", }, ], }, { sub: V2_EXPERT_ID, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }, ); expect(findByIdAndUpdate).toHaveBeenCalledTimes(1); } finally { if (previousEnabled === undefined) { delete process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED; } else { process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED = previousEnabled; } } }); it("allows a repair line without daghi and removes a stray daghi payload", () => { const service = createService() as any; expect( service.validateAndNormalizeDaghiForExpertReplyV2([ { partId: 201, typeOfDamage: TypeOfDamage.Repair, price: "10000", salary: "0", totalPayment: "10000", daghi: { option: DaghiOption.NO_VALUE }, }, ]), ).toEqual([ { partId: 201, typeOfDamage: TypeOfDamage.Repair, price: "10000", salary: "0", totalPayment: "10000", }, ]); }); it("allows a deliver-damaged-part line without a branch", () => { const service = createService() as any; expect( service.validateAndNormalizeDaghiForExpertReplyV2([ { partId: 201, typeOfDamage: TypeOfDamage.Change, price: "100000", salary: "100000", totalPayment: "200000", daghi: { option: DaghiOption.DELIVER_DAMAGED_PART }, }, ]), ).toEqual([ { partId: 201, typeOfDamage: TypeOfDamage.Change, price: "100000", salary: "100000", totalPayment: "200000", daghi: { option: DaghiOption.DELIVER_DAMAGED_PART }, }, ]); }); it("rejects a blank pricing line on the legacy submit endpoint before changing status", async () => { const service = createService() as any; const findAndUpdate = jest.fn(); service.claimRequestManagementDbService = { findOne: jest.fn().mockResolvedValue({ _id: "legacy-claim", actorLocked: { actorId: "expert-1" }, unlockTime: new Date(Date.now() + 60_000), lockFile: true, }), findAndUpdate, }; await expect( service.submitReplyRequest("legacy-claim", blankPricingReply, { sub: "expert-1", }), ).rejects.toBeInstanceOf(BadRequestException); expect(findAndUpdate).not.toHaveBeenCalled(); }); it("rejects a blank pricing line on the V2 submit endpoint before completion", async () => { const service = createService() as any; const findByIdAndUpdate = jest.fn(); service.claimCaseDbService = { findById: jest.fn().mockResolvedValue({ blameRequestId: "blame-1", status: ClaimCaseStatus.EXPERT_REVIEWING, workflow: { locked: true, lockedBy: { actorId: "expert-1" } }, }), findByIdAndUpdate, }; service.blameRequestDbService = { findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }), }; service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined); await expect( service.submitExpertReplyV2( "v2-claim", { ...blankPricingReply, carPrice: "1000000" }, { sub: "expert-1", role: RoleEnum.FIELD_EXPERT, }, ), ).rejects.toBeInstanceOf(BadRequestException); expect(findByIdAndUpdate).not.toHaveBeenCalled(); }); it("returns a Persian, field-specific error when a V2 price is below the minimum", async () => { const service = createService() as any; const findByIdAndUpdate = jest.fn(); service.claimCaseDbService = { findById: jest.fn().mockResolvedValue({ blameRequestId: "blame-1", status: ClaimCaseStatus.EXPERT_REVIEWING, workflow: { locked: true, lockedBy: { actorId: "expert-1" } }, }), findByIdAndUpdate, }; service.blameRequestDbService = { findById: jest.fn().mockResolvedValue({ type: "CAR_BODY" }), }; service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); service.snapshotDamageExpert = jest.fn().mockResolvedValue(undefined); await expect( service.submitExpertReplyV2( "v2-claim", { description: "Damage assessment", carPrice: "1000000", parts: [ { partId: 201, typeOfDamage: TypeOfDamage.Repair, price: "99,999", salary: "100000", totalPayment: "100000", factorNeeded: false, }, ], }, { sub: "expert-1", role: RoleEnum.FIELD_EXPERT }, ), ).rejects.toMatchObject({ response: { code: "EXPERT_REPLY_VALIDATION_ERROR", field: "parts[0].price", message: expect.stringContaining("حداقل"), }, }); expect(findByIdAndUpdate).not.toHaveBeenCalled(); }); }); describe("ExpertClaimService damaged-part audit", () => { it("archives removed capture evidence before replacing the live arrays", async () => { const service = createService() as any; const expertId = "66ec0e480e321873c0900001"; const findByIdAndUpdate = jest.fn().mockResolvedValue(undefined); service.claimCaseDbService = { findById: jest.fn().mockResolvedValue({ _id: "66ec0e480e321873c0900002", status: ClaimCaseStatus.EXPERT_REVIEWING, workflow: { locked: true, lockedBy: { actorId: expertId } }, vehicle: { carType: "SEDAN" }, damage: { selectedParts: [ { id: 101, name: "hood", side: "front", label_fa: "کاپوت", catalogKey: "front_hood", }, { id: 202, name: "door", side: "left", label_fa: "درب چپ", catalogKey: "left_door", }, ], }, media: { damagedParts: [ { path: "claims/hood.jpg", fileName: "hood.jpg" }, { path: "claims/door.jpg", fileName: "door.jpg" }, ], }, }), findByIdAndUpdate, }; service.assertExpertActorOnClaim = jest.fn().mockResolvedValue(undefined); service.snapshotDamageExpert = jest.fn().mockResolvedValue({ firstName: "Expert", }); await service.updateClaimDamagedPartsV2( "66ec0e480e321873c0900002", { selectedParts: [ { id: 202, name: "door", side: "left", label_fa: "درب چپ", catalogKey: "left_door", }, ], }, { sub: expertId, fullName: "Expert One", role: RoleEnum.FIELD_EXPERT, }, ); expect(findByIdAndUpdate).toHaveBeenCalledTimes(1); const update = findByIdAndUpdate.mock.calls[0][1]; expect(update.$set["media.damagedParts"]).toEqual([ expect.objectContaining({ path: "claims/door.jpg" }), ]); expect(update.$push["damage.partSelectionHistory"]).toEqual( expect.objectContaining({ changedBy: expect.objectContaining({ actorId: expertId }), removedParts: [ expect.objectContaining({ id: 101, capture: expect.objectContaining({ path: "claims/hood.jpg" }), }), ], }), ); }); }); describe("ExpertClaimService FileReviewer assignment", () => { it("lets the assigned reviewer reopen a file while it is still awaiting FileReviewer work", async () => { const service = createService() as any; const reviewerId = new Types.ObjectId(); const clientId = new Types.ObjectId(); const blameId = new Types.ObjectId(); const claimId = new Types.ObjectId(); service.expireClaimWorkflowLockV2IfStale = jest.fn().mockResolvedValue(undefined); service.claimCaseDbService = { findById: jest.fn().mockResolvedValue({ _id: claimId, blameRequestId: blameId, status: ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS, }), }; service.blameRequestDbService = { findById: jest.fn().mockResolvedValue({ _id: blameId, type: BlameRequestType.CAR_BODY, status: "WAITING_FOR_FILE_REVIEWER", assignedFileReviewerId: reviewerId, parties: [ { role: "FIRST", person: { clientId }, }, ], }), }; service.fanavaranLocationService = { assertMakerReviewerBranchCompatible: jest.fn().mockResolvedValue(undefined), }; service.assertExpertActorOnClaim = jest.fn(); await expect( service.assignClaimForReviewV2(String(claimId), { sub: String(reviewerId), role: RoleEnum.FILE_REVIEWER, clientKey: String(clientId), }), ).resolves.toMatchObject({ success: true, status: "already_assigned_to_you", }); expect(service.assertExpertActorOnClaim).not.toHaveBeenCalled(); }); }); describe("ExpertClaimService branch-scoped claim views", () => { it("shows branch-scoped claims only to damage experts in that branch", async () => { const service = createService() as any; const branchId = new Types.ObjectId(); const otherBranchId = new Types.ObjectId(); service.damageExpertDbService = { findById: jest.fn().mockResolvedValue({ branchId }), }; service.blameRequestDbService = { find: jest.fn().mockResolvedValue([]), }; const inBranch = { _id: "in-branch", branchId }; const otherBranch = { _id: "other-branch", branchId: otherBranchId }; const legacyUnscoped = { _id: "legacy-unscoped" }; await expect( service.filterDamageExpertClaimsByBranch( [inBranch, otherBranch, legacyUnscoped], { sub: V2_EXPERT_ID, role: RoleEnum.DAMAGE_EXPERT }, ), ).resolves.toEqual([inBranch, legacyUnscoped]); }); });