@@ -438,6 +438,151 @@ describe('shared resource retention across workspace departure and account erasu
438438 }
439439 )
440440
441+ it . each ( [ 'removal' , 'deletion' ] as const ) (
442+ 'serializes Project successor removal when %s holds the workspace first' ,
443+ async ( first ) => {
444+ const fixture = await seedResources ( false , false )
445+ const successorId = generateId ( )
446+ const grantId = generateId ( )
447+ userIds . push ( successorId )
448+ await db . insert ( user ) . values ( {
449+ id : successorId ,
450+ name : 'Project successor' ,
451+ email : `${ successorId } @example.test` ,
452+ emailVerified : true ,
453+ createdAt : new Date ( ) ,
454+ updatedAt : new Date ( ) ,
455+ } )
456+ await db
457+ . update ( project )
458+ . set ( { ownerId : fixture . departingId } )
459+ . where ( eq ( project . id , fixture . projectId ) )
460+ await db
461+ . update ( permissions )
462+ . set ( { permissionType : 'write' } )
463+ . where ( eq ( permissions . entityId , fixture . workspaceId ) )
464+ await db . insert ( permissions ) . values ( {
465+ id : grantId ,
466+ userId : successorId ,
467+ entityId : fixture . workspaceId ,
468+ entityType : 'workspace' ,
469+ permissionType : 'admin' ,
470+ } )
471+ expect ( ( await getAccountDeletionPlan ( fixture . departingId ) ) . blockers ) . toEqual ( [ ] )
472+ const ready = createDeferred < number > ( )
473+ const release = createDeferred < void > ( )
474+ const holding = db . transaction ( async ( tx ) => {
475+ if ( first === 'removal' ) {
476+ await tx
477+ . select ( )
478+ . from ( workspace )
479+ . where ( eq ( workspace . id , fixture . workspaceId ) )
480+ . for ( 'no key update' )
481+ } else {
482+ await tx
483+ . select ( )
484+ . from ( knowledgeBase )
485+ . where ( eq ( knowledgeBase . id , fixture . kbId ) )
486+ . for ( 'update' )
487+ }
488+ const [ connection ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
489+ ready . resolve ( connection . pid )
490+ await release . promise
491+ if ( first === 'removal' ) {
492+ return revokeWorkspaceAccessTx ( tx , {
493+ workspaceId : fixture . workspaceId ,
494+ userId : successorId ,
495+ expectedPermissionId : grantId ,
496+ } )
497+ }
498+ } )
499+ const holderPid = await ready . promise
500+ const holderOutcome = Promise . allSettled ( [ holding ] )
501+ const deleting = Promise . allSettled ( [ deleteUserAccount ( fixture . departingId ) ] )
502+ let removal : Promise < PromiseSettledResult < unknown > [ ] > | undefined
503+ try {
504+ await expect
505+ . poll (
506+ async ( ) =>
507+ (
508+ await db . execute (
509+ sql `SELECT pid FROM pg_stat_activity WHERE ${ holderPid } = ANY(pg_blocking_pids(pid))`
510+ )
511+ ) . length ,
512+ { timeout : 5000 }
513+ )
514+ . toBeGreaterThan ( 0 )
515+ if ( first === 'deletion' ) {
516+ removal = Promise . allSettled ( [
517+ db . transaction ( ( tx ) =>
518+ revokeWorkspaceAccessTx ( tx , {
519+ workspaceId : fixture . workspaceId ,
520+ userId : successorId ,
521+ expectedPermissionId : grantId ,
522+ } )
523+ ) ,
524+ ] )
525+ await expect
526+ . poll (
527+ async ( ) =>
528+ (
529+ await db . execute (
530+ sql `SELECT waiter.pid FROM pg_stat_activity waiter WHERE EXISTS (SELECT 1 FROM pg_stat_activity deleter WHERE ${ holderPid } = ANY(pg_blocking_pids(deleter.pid)) AND deleter.pid = ANY(pg_blocking_pids(waiter.pid)))`
531+ )
532+ ) . length ,
533+ { timeout : 5000 }
534+ )
535+ . toBeGreaterThan ( 0 )
536+ }
537+ } finally {
538+ release . resolve ( )
539+ }
540+ expect ( ( await holderOutcome ) . filter ( ( result ) => result . status === 'rejected' ) ) . toEqual ( [ ] )
541+ const deleted = await deleting
542+ if ( first === 'removal' ) {
543+ expect ( deleted ) . toMatchObject ( [
544+ {
545+ status : 'rejected' ,
546+ reason : {
547+ name : 'ProjectConflictError' ,
548+ message : expect . stringContaining ( 'Give a teammate admin access' ) ,
549+ } ,
550+ } ,
551+ ] )
552+ expect (
553+ await db
554+ . select ( { ownerId : project . ownerId } )
555+ . from ( project )
556+ . where ( eq ( project . id , fixture . projectId ) )
557+ ) . toEqual ( [ { ownerId : fixture . departingId } ] )
558+ expect (
559+ await db
560+ . select ( { userId : knowledgeBase . userId } )
561+ . from ( knowledgeBase )
562+ . where ( eq ( knowledgeBase . id , fixture . kbId ) )
563+ ) . toEqual ( [ { userId : fixture . departingId } ] )
564+ await db . insert ( permissions ) . values ( {
565+ id : generateId ( ) ,
566+ userId : successorId ,
567+ entityId : fixture . workspaceId ,
568+ entityType : 'workspace' ,
569+ permissionType : 'admin' ,
570+ } )
571+ await deleteUserAccount ( fixture . departingId )
572+ } else {
573+ expect ( deleted . filter ( ( result ) => result . status === 'rejected' ) ) . toEqual ( [ ] )
574+ expect ( ( await removal ) ?. filter ( ( result ) => result . status === 'rejected' ) ) . toEqual ( [ ] )
575+ }
576+ expect (
577+ await db
578+ . select ( { ownerId : project . ownerId } )
579+ . from ( project )
580+ . where ( eq ( project . id , fixture . projectId ) )
581+ ) . toEqual ( [ { ownerId : successorId } ] )
582+ await assertRetained ( fixture )
583+ }
584+ )
585+
441586 it . each ( [
442587 [ 'shared' , 'user' ] ,
443588 [ 'shared' , 'workspace' ] ,
0 commit comments