diff --git a/apps/api/src/policies/policies.service.spec.ts b/apps/api/src/policies/policies.service.spec.ts index 07ab440b1a..35bd93ab33 100644 --- a/apps/api/src/policies/policies.service.spec.ts +++ b/apps/api/src/policies/policies.service.spec.ts @@ -1,4 +1,5 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { PoliciesService } from './policies.service'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; @@ -8,10 +9,18 @@ jest.mock('@db', () => ({ policy: { findMany: jest.fn(), findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + policyVersion: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), update: jest.fn(), }, member: { findMany: jest.fn(), + findFirst: jest.fn(), }, auditLog: { createMany: jest.fn(), @@ -46,8 +55,19 @@ jest.mock('../utils/compliance-filters', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports const { db } = require('@db') as { db: { - policy: { findMany: jest.Mock; findFirst: jest.Mock; update: jest.Mock }; - member: { findMany: jest.Mock }; + policy: { + findMany: jest.Mock; + findFirst: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + policyVersion: { + findUnique: jest.Mock; + findFirst: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + member: { findMany: jest.Mock; findFirst: jest.Mock }; auditLog: { createMany: jest.Mock }; $transaction: jest.Mock; }; @@ -61,12 +81,17 @@ const { filterComplianceMembers: mockedFilterComplianceMembers } = require('../u describe('PoliciesService', () => { let service: PoliciesService; + const mockAttachmentsService = { + copyPolicyVersionPdf: jest.fn(), + deletePolicyVersionPdf: jest.fn(), + }; + beforeEach(async () => { jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ PoliciesService, - { provide: AttachmentsService, useValue: {} }, + { provide: AttachmentsService, useValue: mockAttachmentsService }, { provide: PolicyPdfRendererService, useValue: {} }, ], }).compile(); @@ -216,4 +241,114 @@ describe('PoliciesService', () => { ]); }); }); + + describe('createVersion', () => { + const organizationId = 'org_123'; + const policyId = 'pol_1'; + const userId = 'usr_1'; + + const setupHappyPath = ({ + policyContent, + currentVersionContent, + policyPdfUrl, + currentVersionPdfUrl, + }: { + policyContent: unknown[]; + currentVersionContent: unknown[] | null; + policyPdfUrl: string | null; + currentVersionPdfUrl: string | null; + }) => { + db.member.findFirst.mockResolvedValue({ id: 'mem_1' }); + db.policy.findUnique.mockResolvedValue({ + id: policyId, + organizationId, + content: policyContent, + pdfUrl: policyPdfUrl, + currentVersion: currentVersionContent + ? { + id: 'pv_1', + content: currentVersionContent, + pdfUrl: currentVersionPdfUrl, + } + : null, + versions: [], + }); + db.$transaction.mockImplementation(async (cb: (tx: unknown) => unknown) => + cb({ + policyVersion: { + findFirst: jest.fn().mockResolvedValue({ version: 1 }), + create: jest.fn().mockResolvedValue({ id: 'pv_2' }), + }, + }), + ); + }; + + it('creates a version when editor content is empty but a PDF exists', async () => { + setupHappyPath({ + policyContent: [], + currentVersionContent: [], + policyPdfUrl: 's3://bucket/policy.pdf', + currentVersionPdfUrl: 's3://bucket/policy.pdf', + }); + mockAttachmentsService.copyPolicyVersionPdf.mockResolvedValue( + 's3://bucket/new.pdf', + ); + + const result = await service.createVersion( + policyId, + organizationId, + {}, + userId, + ); + + expect(result).toEqual({ versionId: 'pv_2', version: 2 }); + expect(mockAttachmentsService.copyPolicyVersionPdf).toHaveBeenCalled(); + }); + + it('creates a version when both editor content is empty and no PDF exists', async () => { + setupHappyPath({ + policyContent: [], + currentVersionContent: [], + policyPdfUrl: null, + currentVersionPdfUrl: null, + }); + + const result = await service.createVersion( + policyId, + organizationId, + {}, + userId, + ); + + expect(result).toEqual({ versionId: 'pv_2', version: 2 }); + expect(mockAttachmentsService.copyPolicyVersionPdf).not.toHaveBeenCalled(); + }); + + it('creates a version with non-empty editor content', async () => { + setupHappyPath({ + policyContent: [{ type: 'paragraph' }], + currentVersionContent: [{ type: 'paragraph' }], + policyPdfUrl: null, + currentVersionPdfUrl: null, + }); + + const result = await service.createVersion( + policyId, + organizationId, + {}, + userId, + ); + + expect(result).toEqual({ versionId: 'pv_2', version: 2 }); + }); + + it('throws NotFound when the policy does not exist', async () => { + db.member.findFirst.mockResolvedValue({ id: 'mem_1' }); + db.policy.findUnique.mockResolvedValue(null); + + await expect( + service.createVersion(policyId, organizationId, {}, userId), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); }); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 4e9a7010d8..4fd8358246 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -599,15 +599,11 @@ export class PoliciesService { sourceVersion = requestedVersion; } - const contentForVersion = sourceVersion + const contentForVersion = (sourceVersion ? (sourceVersion.content as Prisma.InputJsonValue[]) - : (policy.content as Prisma.InputJsonValue[]); + : (policy.content as Prisma.InputJsonValue[])) ?? []; const sourcePdfUrl = sourceVersion?.pdfUrl ?? policy.pdfUrl; - if (!contentForVersion || contentForVersion.length === 0) { - throw new BadRequestException('No content to create version from'); - } - // S3 copy is done AFTER the transaction to prevent orphaned files on retry let createdVersion: { versionId: string; version: number } | null = null;