11import { Test , TestingModule } from '@nestjs/testing' ;
2- import { BadRequestException , ForbiddenException } from '@nestjs/common' ;
32import { PeopleInviteService } from './people-invite.service' ;
43import { TimelinesService } from '../timelines/timelines.service' ;
54
65jest . mock ( '@db' , ( ) => ( {
6+ BackgroundCheckStatus : {
7+ invited : 'invited' ,
8+ in_progress : 'in_progress' ,
9+ in_review : 'in_review' ,
10+ completed : 'completed' ,
11+ completed_with_flags : 'completed_with_flags' ,
12+ failed : 'failed' ,
13+ cancelled : 'cancelled' ,
14+ } ,
715 db : {
816 organization : {
917 findUnique : jest . fn ( ) ,
@@ -23,15 +31,85 @@ jest.mock('@db', () => ({
2331 employeeTrainingVideoCompletion : {
2432 createMany : jest . fn ( ) ,
2533 } ,
34+ frameworkInstance : {
35+ findFirst : jest . fn ( ) ,
36+ } ,
37+ organizationRole : {
38+ findMany : jest . fn ( ) . mockResolvedValue ( [ ] ) ,
39+ } ,
40+ } ,
41+ } ) ) ;
42+
43+ jest . mock ( '@trycompai/auth' , ( ) => ( {
44+ BUILT_IN_ROLE_PERMISSIONS : {
45+ owner : {
46+ organization : [ 'read' , 'update' , 'delete' ] ,
47+ member : [ 'create' , 'read' , 'update' , 'delete' ] ,
48+ app : [ 'read' ] ,
49+ } ,
50+ admin : {
51+ organization : [ 'read' , 'update' ] ,
52+ member : [ 'create' , 'read' , 'update' , 'delete' ] ,
53+ app : [ 'read' ] ,
54+ } ,
55+ auditor : {
56+ member : [ 'create' , 'read' ] ,
57+ app : [ 'read' ] ,
58+ } ,
59+ employee : {
60+ policy : [ 'read' ] ,
61+ portal : [ 'read' , 'update' ] ,
62+ } ,
63+ contractor : {
64+ policy : [ 'read' ] ,
65+ portal : [ 'read' , 'update' ] ,
66+ } ,
67+ } ,
68+ BUILT_IN_ROLE_OBLIGATIONS : {
69+ owner : { compliance : true } ,
70+ admin : { } ,
71+ auditor : { } ,
72+ employee : { compliance : true } ,
73+ contractor : { compliance : true } ,
74+ } ,
75+ isRestrictedRole : ( role : string ) =>
76+ role === 'employee' || role === 'contractor' ,
77+ parseRoleObligations : ( value : unknown ) => {
78+ try {
79+ const parsed = typeof value === 'string' ? JSON . parse ( value ) : value ;
80+ return parsed && typeof parsed === 'object' ? parsed : { } ;
81+ } catch {
82+ return { } ;
83+ }
84+ } ,
85+ parseRolePermissions : ( value : unknown ) => {
86+ try {
87+ const parsed = typeof value === 'string' ? JSON . parse ( value ) : value ;
88+ return parsed && typeof parsed === 'object' ? parsed : null ;
89+ } catch {
90+ return null ;
91+ }
2692 } ,
2793} ) ) ;
2894
2995jest . mock ( '../email/trigger-email' , ( ) => ( {
3096 triggerEmail : jest . fn ( ) . mockResolvedValue ( { id : 'trigger_123' } ) ,
3197} ) ) ;
3298
99+ jest . mock ( '../frameworks/frameworks-timeline.helper' , ( ) => ( {
100+ checkAutoCompletePhases : jest . fn ( ) . mockResolvedValue ( undefined ) ,
101+ } ) ) ;
102+
103+ const mockInviteEmail = jest . fn ( ) . mockReturnValue ( 'mocked-app-element' ) ;
33104jest . mock ( '../email/templates/invite-member' , ( ) => ( {
34- InviteEmail : jest . fn ( ) . mockReturnValue ( 'mocked-react-element' ) ,
105+ InviteEmail : ( ...args : unknown [ ] ) => mockInviteEmail ( ...args ) ,
106+ } ) ) ;
107+
108+ const mockInvitePortalEmail = jest
109+ . fn ( )
110+ . mockReturnValue ( 'mocked-portal-element' ) ;
111+ jest . mock ( '@trycompai/email' , ( ) => ( {
112+ InvitePortalEmail : ( ...args : unknown [ ] ) => mockInvitePortalEmail ( ...args ) ,
35113} ) ) ;
36114
37115import { db } from '@db' ;
@@ -68,52 +146,51 @@ describe('PeopleInviteService', () => {
68146 callerRole : 'admin,owner' ,
69147 } ;
70148
71- it ( 'should throw ForbiddenException for unauthorized roles ' , async ( ) => {
72- await expect (
73- service . inviteMembers ( {
74- ... baseParams ,
75- callerRole : 'employee' ,
76- invites : [ { email : 'test@example.com' , roles : [ 'employee' ] } ] ,
77- } ) ,
78- ) . rejects . toThrow ( ForbiddenException ) ;
149+ it ( 'should return error for employee caller trying to invite ' , async ( ) => {
150+ const results = await service . inviteMembers ( {
151+ ... baseParams ,
152+ callerRole : 'employee' ,
153+ invites : [ { email : 'test@example.com' , roles : [ ' employee'] } ] ,
154+ } ) ;
155+
156+ expect ( results [ 0 ] . success ) . toBe ( false ) ;
79157 } ) ;
80158
81- it ( 'should restrict auditors to only invite auditors ' , async ( ) => {
159+ it ( 'should restrict auditors from assigning privileged roles ' , async ( ) => {
82160 const results = await service . inviteMembers ( {
83161 ...baseParams ,
84162 callerRole : 'auditor' ,
85163 invites : [ { email : 'test@example.com' , roles : [ 'admin' ] } ] ,
86164 } ) ;
87165
88166 expect ( results [ 0 ] . success ) . toBe ( false ) ;
89- expect ( results [ 0 ] . error ) . toContain ( 'Auditors can only invite ' ) ;
167+ expect ( results [ 0 ] . error ) . toContain ( 'privileged roles ' ) ;
90168 } ) ;
91169
92- it ( 'should allow auditors to invite other auditors' , async ( ) => {
93- // inviteWithCheck path: user doesn't exist → create invitation
94- ( mockDb . user . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
170+ it ( 'should allow auditors to invite restricted roles' , async ( ) => {
95171 ( mockDb . organization . findUnique as jest . Mock ) . mockResolvedValue ( {
96172 name : 'Test Org' ,
97173 } ) ;
98- ( mockDb . invitation . create as jest . Mock ) . mockResolvedValue ( {
99- id : 'inv_auditor' ,
174+ ( mockDb . user . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
175+ ( mockDb . user . create as jest . Mock ) . mockResolvedValue ( {
176+ id : 'usr_emp' ,
177+ email : 'emp@example.com' ,
100178 } ) ;
179+ ( mockDb . member . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
180+ ( mockDb . member . create as jest . Mock ) . mockResolvedValue ( {
181+ id : 'mem_emp' ,
182+ } ) ;
183+ (
184+ mockDb . employeeTrainingVideoCompletion . createMany as jest . Mock
185+ ) . mockResolvedValue ( { count : 5 } ) ;
101186
102187 const results = await service . inviteMembers ( {
103188 ...baseParams ,
104189 callerRole : 'auditor' ,
105- invites : [ { email : 'auditor @example.com' , roles : [ 'auditor ' ] } ] ,
190+ invites : [ { email : 'emp @example.com' , roles : [ 'employee ' ] } ] ,
106191 } ) ;
107192
108193 expect ( results [ 0 ] . success ) . toBe ( true ) ;
109- expect ( mockDb . invitation . create ) . toHaveBeenCalledWith (
110- expect . objectContaining ( {
111- data : expect . objectContaining ( {
112- email : 'auditor@example.com' ,
113- role : 'auditor' ,
114- } ) ,
115- } ) ,
116- ) ;
117194 } ) ;
118195
119196 it ( 'should add employee without invitation for employee/contractor roles' , async ( ) => {
@@ -301,5 +378,130 @@ describe('PeopleInviteService', () => {
301378 } ) ,
302379 ) ;
303380 } ) ;
381+
382+ describe ( 'email flow by role combination' , ( ) => {
383+ function setupNewUserInvite ( ) {
384+ ( mockDb . user . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
385+ ( mockDb . organization . findUnique as jest . Mock ) . mockResolvedValue ( {
386+ name : 'Test Org' ,
387+ } ) ;
388+ ( mockDb . invitation . create as jest . Mock ) . mockResolvedValue ( {
389+ id : 'inv_new' ,
390+ } ) ;
391+ }
392+
393+ it ( 'admin + employee with portal checked: sends single app email with portal link' , async ( ) => {
394+ setupNewUserInvite ( ) ;
395+
396+ const results = await service . inviteMembers ( {
397+ ...baseParams ,
398+ invites : [
399+ {
400+ email : 'both@example.com' ,
401+ roles : [ 'admin' , 'employee' ] ,
402+ sendPortalEmail : true ,
403+ } ,
404+ ] ,
405+ } ) ;
406+
407+ expect ( results [ 0 ] . success ) . toBe ( true ) ;
408+ expect ( mockTriggerEmail ) . toHaveBeenCalledTimes ( 1 ) ;
409+ expect ( mockInviteEmail ) . toHaveBeenCalledWith (
410+ expect . objectContaining ( {
411+ organizationName : 'Test Org' ,
412+ portalLink : expect . stringContaining ( 'org_123' ) ,
413+ } ) ,
414+ ) ;
415+ expect ( mockInvitePortalEmail ) . not . toHaveBeenCalled ( ) ;
416+ } ) ;
417+
418+ it ( 'employee only with portal checked: sends portal-only email' , async ( ) => {
419+ ( mockDb . organization . findUnique as jest . Mock ) . mockResolvedValue ( {
420+ name : 'Test Org' ,
421+ } ) ;
422+ ( mockDb . user . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
423+ ( mockDb . user . create as jest . Mock ) . mockResolvedValue ( {
424+ id : 'usr_emp' ,
425+ email : 'emp@example.com' ,
426+ } ) ;
427+ ( mockDb . member . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
428+ ( mockDb . member . create as jest . Mock ) . mockResolvedValue ( {
429+ id : 'mem_emp' ,
430+ } ) ;
431+ (
432+ mockDb . employeeTrainingVideoCompletion . createMany as jest . Mock
433+ ) . mockResolvedValue ( { count : 5 } ) ;
434+
435+ const results = await service . inviteMembers ( {
436+ ...baseParams ,
437+ invites : [
438+ {
439+ email : 'emp@example.com' ,
440+ roles : [ 'employee' ] ,
441+ sendPortalEmail : true ,
442+ } ,
443+ ] ,
444+ } ) ;
445+
446+ expect ( results [ 0 ] . success ) . toBe ( true ) ;
447+ expect ( mockTriggerEmail ) . toHaveBeenCalledTimes ( 1 ) ;
448+ expect ( mockInvitePortalEmail ) . toHaveBeenCalledWith (
449+ expect . objectContaining ( {
450+ organizationName : 'Test Org' ,
451+ email : 'emp@example.com' ,
452+ } ) ,
453+ ) ;
454+ expect ( mockInviteEmail ) . not . toHaveBeenCalled ( ) ;
455+ } ) ;
456+
457+ it ( 'admin only (no portal): sends app email without portal link' , async ( ) => {
458+ setupNewUserInvite ( ) ;
459+
460+ const results = await service . inviteMembers ( {
461+ ...baseParams ,
462+ invites : [
463+ {
464+ email : 'admin@example.com' ,
465+ roles : [ 'admin' ] ,
466+ sendPortalEmail : false ,
467+ } ,
468+ ] ,
469+ } ) ;
470+
471+ expect ( results [ 0 ] . success ) . toBe ( true ) ;
472+ expect ( mockTriggerEmail ) . toHaveBeenCalledTimes ( 1 ) ;
473+ expect ( mockInviteEmail ) . toHaveBeenCalledWith (
474+ expect . objectContaining ( { organizationName : 'Test Org' } ) ,
475+ ) ;
476+ expect ( mockInviteEmail ) . toHaveBeenCalledWith (
477+ expect . not . objectContaining ( {
478+ portalLink : expect . anything ( ) ,
479+ } ) ,
480+ ) ;
481+ expect ( mockInvitePortalEmail ) . not . toHaveBeenCalled ( ) ;
482+ } ) ;
483+
484+ it ( 'admin with portal checked but no compliance obligation: sends app email without portal' , async ( ) => {
485+ setupNewUserInvite ( ) ;
486+
487+ const results = await service . inviteMembers ( {
488+ ...baseParams ,
489+ invites : [
490+ {
491+ email : 'admin2@example.com' ,
492+ roles : [ 'admin' ] ,
493+ sendPortalEmail : true ,
494+ } ,
495+ ] ,
496+ } ) ;
497+
498+ expect ( results [ 0 ] . success ) . toBe ( true ) ;
499+ expect ( mockTriggerEmail ) . toHaveBeenCalledTimes ( 1 ) ;
500+ expect ( mockInviteEmail ) . toHaveBeenCalledWith (
501+ expect . objectContaining ( { organizationName : 'Test Org' } ) ,
502+ ) ;
503+ expect ( mockInvitePortalEmail ) . not . toHaveBeenCalled ( ) ;
504+ } ) ;
505+ } ) ;
304506 } ) ;
305507} ) ;
0 commit comments