@@ -520,4 +520,197 @@ describe('RolesService', () => {
520520 ) ;
521521 } ) ;
522522 } ) ;
523+
524+ describe ( 'filterMembersWithPermission' , ( ) => {
525+ const organizationId = 'org_1' ;
526+
527+ it ( 'returns empty array when members list is empty' , async ( ) => {
528+ const result = await service . filterMembersWithPermission (
529+ organizationId ,
530+ [ ] ,
531+ 'task' ,
532+ 'update' ,
533+ ) ;
534+ expect ( result ) . toEqual ( [ ] ) ;
535+ expect ( mockDb . organizationRole . findMany ) . not . toHaveBeenCalled ( ) ;
536+ } ) ;
537+
538+ it ( 'keeps built-in roles that grant the permission (owner has task:update)' , async ( ) => {
539+ const members = [
540+ { id : 'm1' , role : 'owner' } ,
541+ { id : 'm2' , role : 'admin' } ,
542+ ] ;
543+ const result = await service . filterMembersWithPermission (
544+ organizationId ,
545+ members ,
546+ 'task' ,
547+ 'update' ,
548+ ) ;
549+ expect ( result . map ( ( m ) => m . id ) . sort ( ) ) . toEqual ( [ 'm1' , 'm2' ] ) ;
550+ } ) ;
551+
552+ it ( 'excludes built-in roles that lack the permission (employee has no task perms)' , async ( ) => {
553+ const members = [
554+ { id : 'm1' , role : 'employee' } ,
555+ { id : 'm2' , role : 'contractor' } ,
556+ { id : 'm3' , role : 'owner' } ,
557+ ] ;
558+ const result = await service . filterMembersWithPermission (
559+ organizationId ,
560+ members ,
561+ 'task' ,
562+ 'update' ,
563+ ) ;
564+ expect ( result . map ( ( m ) => m . id ) ) . toEqual ( [ 'm3' ] ) ;
565+ } ) ;
566+
567+ it ( 'excludes auditor for task:update but keeps them for task:read' , async ( ) => {
568+ const members = [ { id : 'm1' , role : 'auditor' } ] ;
569+
570+ const forUpdate = await service . filterMembersWithPermission (
571+ organizationId ,
572+ members ,
573+ 'task' ,
574+ 'update' ,
575+ ) ;
576+ expect ( forUpdate ) . toEqual ( [ ] ) ;
577+
578+ const forRead = await service . filterMembersWithPermission (
579+ organizationId ,
580+ members ,
581+ 'task' ,
582+ 'read' ,
583+ ) ;
584+ expect ( forRead . map ( ( m ) => m . id ) ) . toEqual ( [ 'm1' ] ) ;
585+ } ) ;
586+
587+ it ( 'treats comma-separated roles as a union (employee,admin gets included)' , async ( ) => {
588+ const members = [ { id : 'm1' , role : 'employee,admin' } ] ;
589+ const result = await service . filterMembersWithPermission (
590+ organizationId ,
591+ members ,
592+ 'task' ,
593+ 'update' ,
594+ ) ;
595+ expect ( result . map ( ( m ) => m . id ) ) . toEqual ( [ 'm1' ] ) ;
596+ } ) ;
597+
598+ it ( 'includes a member whose custom role grants the permission' , async ( ) => {
599+ ( mockDb . organizationRole . findMany as jest . Mock ) . mockResolvedValue ( [
600+ {
601+ name : 'compliance-lead' ,
602+ permissions : JSON . stringify ( {
603+ task : [ 'read' , 'update' ] ,
604+ app : [ 'read' ] ,
605+ } ) ,
606+ } ,
607+ ] ) ;
608+ const members = [ { id : 'm1' , role : 'compliance-lead' } ] ;
609+ const result = await service . filterMembersWithPermission (
610+ organizationId ,
611+ members ,
612+ 'task' ,
613+ 'update' ,
614+ ) ;
615+ expect ( result . map ( ( m ) => m . id ) ) . toEqual ( [ 'm1' ] ) ;
616+ expect ( mockDb . organizationRole . findMany ) . toHaveBeenCalledTimes ( 1 ) ;
617+ } ) ;
618+
619+ it ( 'excludes a member whose custom role lacks the permission' , async ( ) => {
620+ ( mockDb . organizationRole . findMany as jest . Mock ) . mockResolvedValue ( [
621+ {
622+ name : 'readonly' ,
623+ permissions : JSON . stringify ( { task : [ 'read' ] } ) ,
624+ } ,
625+ ] ) ;
626+ const members = [ { id : 'm1' , role : 'readonly' } ] ;
627+ const result = await service . filterMembersWithPermission (
628+ organizationId ,
629+ members ,
630+ 'task' ,
631+ 'update' ,
632+ ) ;
633+ expect ( result ) . toEqual ( [ ] ) ;
634+ } ) ;
635+
636+ it ( 'excludes members with null, empty, or unknown roles' , async ( ) => {
637+ ( mockDb . organizationRole . findMany as jest . Mock ) . mockResolvedValue ( [ ] ) ;
638+ const members = [
639+ { id : 'm1' , role : null } ,
640+ { id : 'm2' , role : '' } ,
641+ { id : 'm3' , role : 'nonexistent-role' } ,
642+ ] ;
643+ const result = await service . filterMembersWithPermission (
644+ organizationId ,
645+ members ,
646+ 'task' ,
647+ 'update' ,
648+ ) ;
649+ expect ( result ) . toEqual ( [ ] ) ;
650+ } ) ;
651+
652+ it ( 'makes exactly one DB query regardless of member count' , async ( ) => {
653+ ( mockDb . organizationRole . findMany as jest . Mock ) . mockResolvedValue ( [
654+ {
655+ name : 'custom-a' ,
656+ permissions : JSON . stringify ( { task : [ 'update' ] } ) ,
657+ } ,
658+ ] ) ;
659+ const members = Array . from ( { length : 25 } , ( _ , i ) => ( {
660+ id : `m${ i } ` ,
661+ role : i % 2 === 0 ? 'custom-a' : 'employee' ,
662+ } ) ) ;
663+ const result = await service . filterMembersWithPermission (
664+ organizationId ,
665+ members ,
666+ 'task' ,
667+ 'update' ,
668+ ) ;
669+ expect ( result . length ) . toBe ( 13 ) ; // 0,2,4,...,24 → 13 members
670+ expect ( mockDb . organizationRole . findMany ) . toHaveBeenCalledTimes ( 1 ) ;
671+ } ) ;
672+
673+ it ( 'skips the DB query when all roles are built-in' , async ( ) => {
674+ const members = [
675+ { id : 'm1' , role : 'owner' } ,
676+ { id : 'm2' , role : 'admin,auditor' } ,
677+ { id : 'm3' , role : 'employee' } ,
678+ ] ;
679+ await service . filterMembersWithPermission (
680+ organizationId ,
681+ members ,
682+ 'app' ,
683+ 'read' ,
684+ ) ;
685+ expect ( mockDb . organizationRole . findMany ) . not . toHaveBeenCalled ( ) ;
686+ } ) ;
687+
688+ it ( 'parses permissions that are already objects (not strings)' , async ( ) => {
689+ ( mockDb . organizationRole . findMany as jest . Mock ) . mockResolvedValue ( [
690+ {
691+ name : 'object-role' ,
692+ permissions : { task : [ 'update' ] } ,
693+ } ,
694+ ] ) ;
695+ const members = [ { id : 'm1' , role : 'object-role' } ] ;
696+ const result = await service . filterMembersWithPermission (
697+ organizationId ,
698+ members ,
699+ 'task' ,
700+ 'update' ,
701+ ) ;
702+ expect ( result . map ( ( m ) => m . id ) ) . toEqual ( [ 'm1' ] ) ;
703+ } ) ;
704+
705+ it ( 'trims whitespace around comma-separated role names' , async ( ) => {
706+ const members = [ { id : 'm1' , role : 'employee , admin' } ] ;
707+ const result = await service . filterMembersWithPermission (
708+ organizationId ,
709+ members ,
710+ 'task' ,
711+ 'update' ,
712+ ) ;
713+ expect ( result . map ( ( m ) => m . id ) ) . toEqual ( [ 'm1' ] ) ;
714+ } ) ;
715+ } ) ;
523716} ) ;
0 commit comments