Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion apps/api/src/soa/soa.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('SOAController', () => {
batchSearchSOAQuestions: jest.fn(),
processSOAQuestionWithContent: jest.fn(),
saveAnswersToDatabase: jest.fn(),
updateConfigurationWithResults: jest.fn(),
countAnsweredAnswers: jest.fn(),
updateDocumentAfterAutoFill: jest.fn(),
createDocument: jest.fn(),
ensureSetup: jest.fn(),
Expand Down
15 changes: 5 additions & 10 deletions apps/api/src/soa/soa.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,12 @@ export class SOAController {
userId,
);

// Update configuration with results
await this.soaService.updateConfigurationWithResults(
configuration.id,
questions,
successfulResults,
// Update document answered count from this org's persisted answers,
// scoped to the document's configured questions.
const answeredCount = await this.soaService.countAnsweredAnswers(
dto.documentId,
questions.map((q) => q.id),
);

// Update document
const answeredCount = successfulResults.filter(
(r) => r.isApplicable !== null,
).length;
await this.soaService.updateDocumentAfterAutoFill(
dto.documentId,
questions.length,
Expand Down
173 changes: 164 additions & 9 deletions apps/api/src/soa/soa.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jest.mock('@db', () => ({
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
sOADocument: {
findFirst: jest.fn(),
Expand All @@ -28,6 +29,7 @@ jest.mock('@db', () => ({
user: { findUnique: jest.fn() },
organization: { findUnique: jest.fn() },
sOAAnswer: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$transaction: jest.fn((ops: unknown[]) => Promise.all(ops)),
},
}));

Expand All @@ -47,9 +49,8 @@ jest.mock('./utils/soa-answer-parser', () => ({
}));
jest.mock('./utils/soa-storage', () => ({
saveAnswersToDatabase: jest.fn(),
updateConfigurationWithResults: jest.fn(),
updateDocumentAfterAutoFill: jest.fn(),
getAnsweredCountFromConfiguration: jest.fn(),
countAnsweredAnswers: jest.fn(),
updateDocumentAnsweredCount: jest.fn(),
checkIfFullyRemote: jest.fn(),
}));
Expand Down Expand Up @@ -487,7 +488,7 @@ describe('SOAService', () => {
);
});

it('maps document data and delegates to generateSOAExportFile', async () => {
it('sources applicability + justification from this org\'s answers, not the shared configuration', async () => {
const generated = {
fileBuffer: Buffer.from('pdf'),
mimeType: 'application/pdf',
Expand All @@ -506,6 +507,8 @@ describe('SOAService', () => {
version: 2,
framework: { name: 'ISO 27001' },
configuration: {
// The shared configuration must NOT drive applicability/justification.
// These stale values would previously bleed into every org's export.
questions: [
{
id: 'q-1',
Expand All @@ -515,7 +518,7 @@ describe('SOAService', () => {
title: 'Control title',
control_objective: 'Objective',
isApplicable: true,
justification: 'Mapped justification',
justification: 'Another org shared-config justification',
},
},
{
Expand All @@ -531,7 +534,14 @@ describe('SOAService', () => {
email: 'approver@example.com',
},
},
answers: [{ questionId: 'q-2', answer: 'Fallback answer' }],
// This org's own answers.
answers: [
{
questionId: 'q-1',
answer: 'Our own justification',
isApplicable: false,
},
],
});

const result = await service.exportDocument(dto);
Expand All @@ -545,10 +555,11 @@ describe('SOAService', () => {
closure: 'A.5',
title: 'Control title',
control_objective: 'Objective',
isApplicable: true,
justification: 'Mapped justification',
// From the org's own answer — NOT the shared config's `true`.
isApplicable: false,
justification: 'Our own justification',
},
answer: null,
answer: 'Our own justification',
},
{
id: 'q-2',
Expand All @@ -557,10 +568,11 @@ describe('SOAService', () => {
closure: null,
title: null,
control_objective: null,
// No answer for this org → blank, not another org's data.
isApplicable: null,
justification: null,
},
answer: 'Fallback answer',
answer: null,
},
],
'ISO 27001',
Expand All @@ -579,4 +591,147 @@ describe('SOAService', () => {
expect(result).toEqual(generated);
});
});

describe('saveAnswer', () => {
const baseDto = {
organizationId: 'org-1',
documentId: 'doc-1',
questionId: 'q-1',
};
const userId = 'user-1';

beforeEach(() => {
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({
id: 'doc-1',
totalQuestions: 5,
configuration: { questions: [{ id: 'q-1' }] },
});
(mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.sOAAnswer.create as jest.Mock).mockResolvedValue({ id: 'ans-1' });
});

it('throws NotFoundException when document not found', async () => {
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.saveAnswer(
{ ...baseDto, isApplicable: true, justification: 'x' },
userId,
),
).rejects.toThrow(NotFoundException);
});

it('rejects a question that is not in the document configuration', async () => {
await expect(
service.saveAnswer(
{
...baseDto,
questionId: 'q-not-in-config',
isApplicable: true,
justification: 'x',
},
userId,
),
).rejects.toThrow(BadRequestException);
expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled();
});

it('requires a justification when a control is not applicable', async () => {
await expect(
service.saveAnswer(
{ ...baseDto, isApplicable: false, justification: ' ' },
userId,
),
).rejects.toThrow(BadRequestException);
expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled();
});

it('does not retire the previous answer when validation fails', async () => {
// A rejected save must leave the prior answer intact — validation runs
// before any write, and the retire + create happen in one transaction.
(mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue({
id: 'ans-prev',
answerVersion: 1,
isApplicable: true,
answer: 'Previously applicable',
});

await expect(
service.saveAnswer(
{ ...baseDto, isApplicable: false, justification: '' },
userId,
),
).rejects.toThrow(BadRequestException);

expect(mockDb.sOAAnswer.update).not.toHaveBeenCalled();
expect(mockDb.$transaction).not.toHaveBeenCalled();
expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled();
});

it('preserves the existing applicability when isApplicable is omitted', async () => {
(mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue({
id: 'ans-prev',
answerVersion: 1,
isApplicable: false,
answer: 'Prior justification',
});

// A partial edit that only sends a justification must not wipe the Yes/No.
await service.saveAnswer(
{ ...baseDto, justification: 'Updated justification' },
userId,
);

expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
isApplicable: false,
answer: 'Updated justification',
}),
});
});

it('persists applicability + justification on the answer', async () => {
await service.saveAnswer(
{
...baseDto,
isApplicable: false,
justification: 'Not applicable because reasons',
},
userId,
);

expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
documentId: 'doc-1',
questionId: 'q-1',
answer: 'Not applicable because reasons',
isApplicable: false,
status: 'manual',
isLatestAnswer: true,
}),
});
});

it('keeps the justification for applicable (YES) answers', async () => {
await service.saveAnswer(
{ ...baseDto, isApplicable: true, justification: 'We do this' },
userId,
);

expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
answer: 'We do this',
isApplicable: true,
}),
});
});

it('never writes per-organization data to the shared configuration', async () => {
await service.saveAnswer(
{ ...baseDto, isApplicable: true, justification: 'We do this' },
userId,
);

expect(mockDb.sOAFrameworkConfiguration.update).not.toHaveBeenCalled();
});
});
});
Loading
Loading