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
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@ jest.mock('@db', () => {
const dbMock = {
frameworkEditorFramework: {
findUnique: jest.fn(),
delete: jest.fn(),
},
frameworkEditorRequirement: {
findMany: jest.fn(),
deleteMany: jest.fn(),
},
frameworkEditorControlTemplate: {
update: jest.fn(),
},
frameworkInstance: {
deleteMany: jest.fn(),
},
frameworkVersion: {
deleteMany: jest.fn(),
},
$transaction: jest.fn((ops: unknown[]) => Promise.all(ops)),
};
return { db: dbMock, Prisma: { PrismaClientKnownRequestError: class {} } };
});

import { BadRequestException, ConflictException } from '@nestjs/common';
import { db } from '@db';
import { db, Prisma } from '@db';
import { FrameworkEditorFrameworkService } from './framework.service';

const mockDb = db as jest.Mocked<typeof db>;
Expand Down Expand Up @@ -87,3 +96,59 @@ describe('FrameworkEditorFrameworkService.linkControl', () => {
expect(mockDb.frameworkEditorControlTemplate.update).not.toHaveBeenCalled();
});
});

describe('FrameworkEditorFrameworkService.delete (FRAME-13)', () => {
let service: FrameworkEditorFrameworkService;

beforeEach(() => {
service = new FrameworkEditorFrameworkService();
jest.clearAllMocks();
(mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({
id: 'frk_1',
});
});

it('deletes instances, versions, requirements, then the framework — in that order', async () => {
const result = await service.delete('frk_1');

expect(result).toEqual({ message: 'Framework deleted successfully' });
expect(mockDb.frameworkInstance.deleteMany).toHaveBeenCalledWith({
where: { frameworkId: 'frk_1' },
});
expect(mockDb.frameworkVersion.deleteMany).toHaveBeenCalledWith({
where: { frameworkId: 'frk_1' },
});
expect(mockDb.frameworkEditorRequirement.deleteMany).toHaveBeenCalledWith({
where: { frameworkId: 'frk_1' },
});
expect(mockDb.frameworkEditorFramework.delete).toHaveBeenCalledWith({
where: { id: 'frk_1' },
});

// Order matters: instances free the currentVersion Restrict FK before
// versions are removed, and requirements before the framework itself.
const order = [
(mockDb.frameworkInstance.deleteMany as jest.Mock).mock.invocationCallOrder[0],
(mockDb.frameworkVersion.deleteMany as jest.Mock).mock.invocationCallOrder[0],
(mockDb.frameworkEditorRequirement.deleteMany as jest.Mock).mock
.invocationCallOrder[0],
(mockDb.frameworkEditorFramework.delete as jest.Mock).mock.invocationCallOrder[0],
];
expect(order).toEqual([...order].sort((a, b) => a - b));
});

it('maps a residual FK conflict (P2003) to a ConflictException', async () => {
// Runtime uses the mocked class (ignores ctor args); the args satisfy the
// real Prisma type, and Object.assign sets the code the catch checks.
const fkError = Object.assign(
new Prisma.PrismaClientKnownRequestError('FK constraint', {
code: 'P2003',
clientVersion: '0',
}),
{ code: 'P2003' },
);
(mockDb.$transaction as jest.Mock).mockRejectedValueOnce(fkError);

await expect(service.delete('frk_1')).rejects.toBeInstanceOf(ConflictException);
});
});
16 changes: 15 additions & 1 deletion apps/api/src/framework-editor/framework/framework.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,22 @@ export class FrameworkEditorFrameworkService {
async delete(id: string) {
await this.findById(id);

// A framework may have published versions and org-level instances that
// reference it. Delete the dependency graph in order inside one transaction:
// 1. instances — cascades their org controls/maps/links/sync-operations
// AND frees the Restrict FK on
// FrameworkInstance.currentVersionId -> FrameworkVersion
// 2. versions — now unreferenced by instances or sync-operations
// 3. requirements — Restrict FK back to the framework
// 4. the framework — cascades the editor-side control/policy/task/document
// links + ISMS docs
// Deleting the framework directly would cascade versions and instances
// together, which trips the currentVersionId Restrict (P2003) depending on
// cascade order; the explicit ordering avoids that.
try {
await db.$transaction([
db.frameworkInstance.deleteMany({ where: { frameworkId: id } }),
db.frameworkVersion.deleteMany({ where: { frameworkId: id } }),
db.frameworkEditorRequirement.deleteMany({
where: { frameworkId: id },
}),
Expand All @@ -115,7 +129,7 @@ export class FrameworkEditorFrameworkService {
error.code === 'P2003'
) {
throw new ConflictException(
'Cannot delete framework: it is referenced by existing framework instances',
'Could not delete framework: it still has references that could not be removed automatically',
);
}
throw error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,6 @@ vi.mock('../../../components/table', () => ({

vi.mock('./components/EditFrameworkDialog', () => ({ EditFrameworkDialog: () => null }));
vi.mock('./components/DeleteFrameworkDialog', () => ({ DeleteFrameworkDialog: () => null }));
vi.mock('./versions/components/PublishVersionDialog', () => ({
PublishVersionDialog: () => null,
}));
vi.mock('./versions/hooks/useFrameworkVersions', () => ({
useFrameworkVersions: () => ({ data: [], refetch: vi.fn() }),
}));
vi.mock('@/app/lib/api-client', () => ({ apiClient: vi.fn() }));
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import {
useRequirementChangeTracking,
type RequirementGridRow,
} from './hooks/useRequirementChangeTracking';
import { PublishVersionDialog } from './versions/components/PublishVersionDialog';
import { useFrameworkVersions } from './versions/hooks/useFrameworkVersions';

interface FrameworkDetails {
id: string;
Expand Down Expand Up @@ -78,12 +76,6 @@ export function FrameworkRequirementsClientPage({
// Row whose large description editor is currently open — highlighted so the
// edited row is obvious behind the (semi-transparent) editor dialog.
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
// "Save and Commit" saves the edits then opens the publish flow (FRAME-4).
const [isPublishOpen, setIsPublishOpen] = useState(false);
const { data: publishedVersions, refetch: refetchVersions } = useFrameworkVersions(
frameworkDetails.id,
);
const latestPublishedVersion = publishedVersions?.[0]?.version;

const initialGridData: RequirementGridRow[] = useMemo(
() =>
Expand Down Expand Up @@ -115,13 +107,6 @@ export function FrameworkRequirementsClientPage({
changesSummary,
} = useRequirementChangeTracking(initialGridData, frameworkDetails.id);

// Save edits, then (only if they all persisted) open the publish dialog so
// the accumulated changes can be committed as a new version.
const handleSaveAndCommit = useCallback(async () => {
const ok = await handleCommit();
if (ok) setIsPublishOpen(true);
}, [handleCommit]);

const uniqueFamilies = useMemo(() => {
const families = new Set<string>();
for (const row of data) {
Expand Down Expand Up @@ -319,11 +304,8 @@ export function FrameworkRequirementsClientPage({
<Button variant="outline" onClick={handleCancel} size="sm" className="rounded-xs">
Cancel
</Button>
<Button variant="outline" onClick={handleCommit} size="sm" className="rounded-xs">
Save as Draft
</Button>
<Button onClick={handleSaveAndCommit} size="sm" className="rounded-xs">
Save and Commit
<Button onClick={handleCommit} size="sm" className="rounded-xs">
Commit Changes
</Button>
</>
)}
Expand Down Expand Up @@ -448,17 +430,6 @@ export function FrameworkRequirementsClientPage({
frameworkName={frameworkDetails.name}
/>
)}
<PublishVersionDialog
frameworkId={frameworkDetails.id}
open={isPublishOpen}
onClose={() => setIsPublishOpen(false)}
latestVersion={latestPublishedVersion}
onPublished={() => {
setIsPublishOpen(false);
void refetchVersions();
router.refresh();
}}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ export function DeleteFrameworkDialog({
Are you sure you want to delete {`"${frameworkName}"`}?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the framework and all of its
associated requirements.
This action cannot be undone. This will permanently delete the framework, all of its
requirements and published versions, and remove it from any organizations currently
tracking it.
{error && <p className="text-destructive mt-2 text-sm font-medium">Error: {error}</p>}
</AlertDialogDescription>
</AlertDialogHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,6 @@ export function useRequirementChangeTracking(
// Re-sync the grid with server truth (ids, timestamps, links).
router.refresh();
}

// Report success so callers (e.g. "Save and Commit") can chain a publish
// only when every edit persisted cleanly.
return results.errors.length === 0;
}, [data, createdIds, updatedIds, deletedIds, frameworkId, router]);

const handleCancel = useCallback(() => {
Expand Down
Loading