Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 203 additions & 2 deletions apps/api/src/trigger/policies/update-policy-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { db } from '@db';
import { generateObject } from 'ai';
import { processPolicyUpdate } from './update-policy-helpers';
import {
processPolicyUpdate,
updatePolicyInDatabase,
} from './update-policy-helpers';

jest.mock('@db', () => ({
db: {
organization: { findUnique: jest.fn() },
policy: { findUnique: jest.fn(), update: jest.fn() },
frameworkEditorPolicyTemplate: { findUnique: jest.fn() },
policyVersion: { create: jest.fn(), deleteMany: jest.fn() },
policyVersion: {
findFirst: jest.fn(),
create: jest.fn(),
deleteMany: jest.fn(),
},
$transaction: jest.fn(),
},
Prisma: {
PrismaClientKnownRequestError: class PrismaClientKnownRequestError {},
},
PolicyStatus: {
draft: 'draft',
published: 'published',
needs_review: 'needs_review',
},
}));

jest.mock('@trigger.dev/sdk', () => ({
Expand Down Expand Up @@ -79,6 +94,7 @@ describe('processPolicyUpdate (individual policy regeneration)', () => {
cb({
policy: { update: jest.fn() },
policyVersion: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn(({ data }: { data: { content: unknown[] } }) => {
storedContent = data.content;
return { id: 'pv_1' };
Expand Down Expand Up @@ -127,3 +143,188 @@ describe('processPolicyUpdate (individual policy regeneration)', () => {
expect(result.policyName).toBe('Information Security Policy');
});
});

// CS-766: Regenerating a PUBLISHED, signed policy must not touch the live
// version. It must append a new DRAFT version (for the approval workflow) while
// leaving policy.content, currentVersionId, signedBy, pdfUrl and the existing
// versions intact. Only publishing that draft (elsewhere) clears signedBy and
// re-triggers signing.
describe('updatePolicyInDatabase (published policy regeneration)', () => {
const REGEN_CONTENT = [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Regenerated draft content' }],
},
];

let txPolicyUpdate: jest.Mock;
let txVersionCreate: jest.Mock;
let txVersionDeleteMany: jest.Mock;
let txVersionFindFirst: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();

// A published policy: v1 is the current, signed, live version.
(db.policy.findUnique as jest.Mock).mockResolvedValue({
id: 'pol_1',
status: 'published',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Published v1' }] },
],
currentVersionId: 'pv_1',
signedBy: ['mem_a', 'mem_b'],
pdfUrl: 'org_1/policies/pol_1/v1.pdf',
versions: [{ id: 'pv_1', pdfUrl: null, version: 1 }],
});

txPolicyUpdate = jest.fn();
txVersionCreate = jest.fn(() => ({ id: 'pv_2' }));
txVersionDeleteMany = jest.fn();
txVersionFindFirst = jest.fn().mockResolvedValue({ version: 1 });

(db.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: unknown) => Promise<unknown>) =>
cb({
policy: { update: txPolicyUpdate },
policyVersion: {
findFirst: txVersionFindFirst,
create: txVersionCreate,
deleteMany: txVersionDeleteMany,
},
}),
);
});

it('appends a new draft version and preserves the published version + signatures', async () => {
await updatePolicyInDatabase('pol_1', REGEN_CONTENT, 'mem_regen');

// Existing versions (and their PDFs) must survive — the published version
// must not be destroyed.
expect(txVersionDeleteMany).not.toHaveBeenCalled();

// A brand-new version is appended at the next number (not overwriting v1).
expect(txVersionCreate).toHaveBeenCalledTimes(1);
const createData = txVersionCreate.mock.calls[0][0].data;
expect(createData.version).toBe(2);
expect(createData.changelog).toBe('Regenerated policy content');
expect(JSON.stringify(createData.content)).toContain(
'Regenerated draft content',
);

// The published policy row must NOT be mutated: no signature wipe, no live
// content swap, no currentVersion repoint.
const policyUpdateData = txPolicyUpdate.mock.calls.map(
(call) => (call[0] as { data?: Record<string, unknown> })?.data ?? {},
);
for (const data of policyUpdateData) {
expect(data).not.toHaveProperty('signedBy');
expect(data).not.toHaveProperty('content');
expect(data).not.toHaveProperty('currentVersionId');
}
});
});

// CS-766 follow-up: Regenerating a DRAFT policy (never published, unsigned) must
// SURFACE the regenerated content. The editor renders the current version's
// content (falling back to policy.content), so regeneration overwrites the
// current draft version IN PLACE and syncs policy.content/draftContent — it must
// NOT append an unattached version that leaves the draft showing stale text.
describe('updatePolicyInDatabase (draft policy regeneration)', () => {
const REGEN_CONTENT = [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Regenerated draft content' }],
},
];

let txPolicyUpdate: jest.Mock;
let txVersionUpdate: jest.Mock;
let txVersionCreate: jest.Mock;
let txVersionDeleteMany: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();

// A draft policy uploaded as a PDF: displayFormat is 'PDF' and both the
// policy and its current version carry a stale pdfUrl (the old document).
(db.policy.findUnique as jest.Mock).mockResolvedValue({
id: 'pol_1',
status: 'draft',
currentVersionId: 'pv_1',
displayFormat: 'PDF',
pdfUrl: 'org_1/policies/pol_1/uploaded.pdf',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Stale draft' }] },
],
signedBy: [],
versions: [
{ id: 'pv_1', pdfUrl: 'org_1/policies/pol_1/v1.pdf', version: 1 },
],
});

txPolicyUpdate = jest.fn();
txVersionUpdate = jest.fn();
txVersionCreate = jest.fn(() => ({ id: 'pv_2' }));
txVersionDeleteMany = jest.fn();

(db.$transaction as jest.Mock).mockImplementation(
async (cb: (tx: unknown) => Promise<unknown>) =>
cb({
policy: { update: txPolicyUpdate },
policyVersion: {
update: txVersionUpdate,
create: txVersionCreate,
deleteMany: txVersionDeleteMany,
findFirst: jest.fn().mockResolvedValue({ version: 1 }),
},
}),
);
});

it('overwrites the current draft version in place and syncs policy content (no unattached version)', async () => {
await updatePolicyInDatabase('pol_1', REGEN_CONTENT, 'mem_regen');

// The regenerated content overwrites the CURRENT draft version in place so
// the editor (which reads currentVersion.content) surfaces it.
expect(txVersionUpdate).toHaveBeenCalledTimes(1);
const versionUpdate = txVersionUpdate.mock.calls[0][0];
expect(versionUpdate.where.id).toBe('pv_1');
expect(JSON.stringify(versionUpdate.data.content)).toContain(
'Regenerated draft content',
);

// No unattached extra version is appended (and nothing is deleted) for a
// draft — the working version is edited in place.
expect(txVersionCreate).not.toHaveBeenCalled();
expect(txVersionDeleteMany).not.toHaveBeenCalled();

// policy.content AND draftContent advance to the regenerated content so the
// draft no longer shows stale text; currentVersionId is not repointed.
expect(txPolicyUpdate).toHaveBeenCalledTimes(1);
const policyUpdate = txPolicyUpdate.mock.calls[0][0].data;
expect(JSON.stringify(policyUpdate.content)).toContain(
'Regenerated draft content',
);
expect(JSON.stringify(policyUpdate.draftContent)).toContain(
'Regenerated draft content',
);
expect(policyUpdate).not.toHaveProperty('currentVersionId');
});

it('clears stale PDF references and switches to EDITOR display when the draft was uploaded as a PDF', async () => {
await updatePolicyInDatabase('pol_1', REGEN_CONTENT, 'mem_regen');

// Regeneration produces EDITOR content: the policy must switch back to the
// editor and drop its stale policy-level PDF, otherwise the page opens on
// the PDF tab / export keeps serving the old uploaded document.
const policyUpdate = txPolicyUpdate.mock.calls[0][0].data;
expect(policyUpdate.displayFormat).toBe('EDITOR');
expect(policyUpdate.pdfUrl).toBeNull();

// The current version's stale PDF (used first by render/export via
// currentVersion.pdfUrl ?? policy.pdfUrl) must be cleared too.
const versionUpdate = txVersionUpdate.mock.calls[0][0];
expect(versionUpdate.data.pdfUrl).toBeNull();
});
});
Loading
Loading