Skip to content

Commit edc9dc8

Browse files
test(finding-notifier): add unit tests for FindingNotifierService (#2158)
Co-authored-by: Lewis Carhart <lewis@trycomp.ai>
1 parent c478509 commit edc9dc8

2 files changed

Lines changed: 199 additions & 1 deletion

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { isUserUnsubscribed } from '@trycompai/email';
2+
import { sendEmail } from '../email/resend';
3+
import { NovuService } from '../notifications/novu.service';
4+
import { FindingNotifierService } from './finding-notifier.service';
5+
6+
jest.mock(
7+
'@db',
8+
() => ({
9+
FindingType: {
10+
soc2: 'soc2',
11+
iso27001: 'iso27001',
12+
},
13+
FindingStatus: {
14+
open: 'open',
15+
ready_for_review: 'ready_for_review',
16+
needs_revision: 'needs_revision',
17+
closed: 'closed',
18+
},
19+
db: {
20+
organization: {
21+
findUnique: jest.fn(),
22+
},
23+
task: {
24+
findUnique: jest.fn(),
25+
},
26+
member: {
27+
findMany: jest.fn(),
28+
},
29+
user: {
30+
findUnique: jest.fn(),
31+
},
32+
evidenceSubmission: {
33+
findUnique: jest.fn(),
34+
},
35+
},
36+
}),
37+
{ virtual: true },
38+
);
39+
40+
jest.mock('@trycompai/email', () => ({
41+
isUserUnsubscribed: jest.fn(),
42+
}));
43+
44+
jest.mock('../email/resend', () => ({
45+
sendEmail: jest.fn(),
46+
}));
47+
48+
jest.mock(
49+
'../email/templates/finding-notification',
50+
() => ({
51+
FindingNotificationEmail: jest.fn(),
52+
}),
53+
{ virtual: true },
54+
);
55+
56+
const mockDbModule: {
57+
db: {
58+
organization: {
59+
findUnique: jest.Mock;
60+
};
61+
task: {
62+
findUnique: jest.Mock;
63+
};
64+
member: {
65+
findMany: jest.Mock;
66+
};
67+
user: {
68+
findUnique: jest.Mock;
69+
};
70+
evidenceSubmission: {
71+
findUnique: jest.Mock;
72+
};
73+
};
74+
FindingType: {
75+
soc2: 'soc2';
76+
iso27001: 'iso27001';
77+
};
78+
} = jest.requireMock('@db');
79+
const { db, FindingType } = mockDbModule;
80+
81+
describe('FindingNotifierService', () => {
82+
const mockedDb = db;
83+
const mockedSendEmail = sendEmail as jest.MockedFunction<typeof sendEmail>;
84+
const mockedIsUserUnsubscribed = isUserUnsubscribed as jest.MockedFunction<
85+
typeof isUserUnsubscribed
86+
>;
87+
const novuTriggerMock = jest.fn();
88+
const novuServiceMock = {
89+
trigger: novuTriggerMock,
90+
} as unknown as NovuService;
91+
const service = new FindingNotifierService(novuServiceMock);
92+
93+
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL;
94+
95+
beforeEach(() => {
96+
jest.clearAllMocks();
97+
98+
process.env.NEXT_PUBLIC_APP_URL = 'https://app.trycomp.ai';
99+
100+
mockedDb.organization.findUnique.mockResolvedValue({
101+
name: 'Acme',
102+
});
103+
mockedDb.task.findUnique.mockResolvedValue({
104+
assignee: null,
105+
});
106+
mockedDb.member.findMany.mockResolvedValue([
107+
{
108+
role: 'admin',
109+
user: {
110+
id: 'usr_admin',
111+
email: 'admin@example.com',
112+
name: 'Admin User',
113+
},
114+
},
115+
]);
116+
mockedDb.user.findUnique.mockResolvedValue({
117+
id: 'usr_submitter',
118+
email: 'submitter@example.com',
119+
name: 'Submitter User',
120+
});
121+
122+
mockedIsUserUnsubscribed.mockResolvedValue(false);
123+
mockedSendEmail.mockResolvedValue({ id: 'email_123', message: 'queued' });
124+
novuTriggerMock.mockResolvedValue(undefined);
125+
});
126+
127+
afterAll(() => {
128+
process.env.NEXT_PUBLIC_APP_URL = originalAppUrl;
129+
});
130+
131+
describe('notifyFindingCreated', () => {
132+
it('builds task URLs for task-targeted findings', async () => {
133+
await service.notifyFindingCreated({
134+
organizationId: 'org_123',
135+
findingId: 'fdg_123',
136+
taskId: 'tsk_123',
137+
taskTitle: 'Review vendor controls',
138+
findingContent: 'Task finding',
139+
findingType: FindingType.soc2,
140+
actorUserId: 'usr_actor',
141+
actorName: 'Actor',
142+
});
143+
144+
expect(novuTriggerMock).toHaveBeenCalledWith(
145+
expect.objectContaining({
146+
payload: expect.objectContaining({
147+
findingUrl: 'https://app.trycomp.ai/org_123/tasks/tsk_123',
148+
}),
149+
}),
150+
);
151+
});
152+
153+
it('builds submission URLs for submission-targeted findings', async () => {
154+
await service.notifyFindingCreated({
155+
organizationId: 'org_123',
156+
findingId: 'fdg_234',
157+
evidenceSubmissionId: 'sub_123',
158+
evidenceSubmissionFormType: 'meeting',
159+
evidenceSubmissionSubmittedById: 'usr_submitter',
160+
findingContent: 'Submission finding',
161+
findingType: FindingType.soc2,
162+
actorUserId: 'usr_actor',
163+
actorName: 'Actor',
164+
});
165+
166+
expect(novuTriggerMock).toHaveBeenCalledWith(
167+
expect.objectContaining({
168+
payload: expect.objectContaining({
169+
findingUrl:
170+
'https://app.trycomp.ai/org_123/documents/meeting/submissions/sub_123',
171+
}),
172+
}),
173+
);
174+
});
175+
176+
it('builds document URLs for evidenceFormType-only findings', async () => {
177+
await service.notifyFindingCreated({
178+
organizationId: 'org_123',
179+
findingId: 'fdg_345',
180+
evidenceSubmissionFormType: 'meeting',
181+
findingContent: 'Form type finding',
182+
findingType: FindingType.soc2,
183+
actorUserId: 'usr_actor',
184+
actorName: 'Actor',
185+
});
186+
187+
expect(novuTriggerMock).toHaveBeenCalledWith(
188+
expect.objectContaining({
189+
payload: expect.objectContaining({
190+
findingUrl: 'https://app.trycomp.ai/org_123/documents/meeting',
191+
}),
192+
}),
193+
);
194+
});
195+
});
196+
});

apps/api/src/findings/finding-notifier.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,9 @@ export class FindingNotifierService {
343343
const findingUrl =
344344
evidenceSubmissionId && evidenceSubmissionFormType
345345
? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`
346-
: `${getAppUrl()}/${organizationId}/tasks/${taskId}`;
346+
: evidenceSubmissionFormType
347+
? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}`
348+
: `${getAppUrl()}/${organizationId}/tasks/${taskId}`;
347349
const typeLabel = TYPE_LABELS[findingType];
348350
const statusLabel = newStatus ? STATUS_LABELS[newStatus] : undefined;
349351

0 commit comments

Comments
 (0)