@@ -20,12 +20,14 @@ const scope = (workspaceId = 'ws-a'): TeamResourceRequestScope => ({
2020 canShare : true ,
2121} ) ;
2222
23- function fixture ( ) {
23+ function fixture ( options : { projectInitiallyExists ?: boolean } = { } ) {
2424 const calls : string [ ] = [ ] ;
2525 const shared = new Set < string > ( ) ;
26- const projectVisibility = new Map < string , 'personal' | 'team' > ( [
27- [ 'project-brand' , 'personal' ] ,
28- ] ) ;
26+ const projectVisibility = new Map < string , 'personal' | 'team' > (
27+ options . projectInitiallyExists === false
28+ ? [ ]
29+ : [ [ 'project-brand' , 'personal' ] ] ,
30+ ) ;
2931 let failResourceShare = false ;
3032 let failResourceUnshare = false ;
3133 let resourceUnshareFailuresRemaining = 0 ;
@@ -35,7 +37,16 @@ function fixture() {
3537 let authoritativeShared : Set < string > | null = null ;
3638 let authoritativeReadError : Error | null = null ;
3739 let authoritativeCanUnshare = true ;
38- let projectCreatorMemberId : string | null = 'member-owner' ;
40+ let hubOwnerMemberId : string | undefined = 'member-owner' ;
41+ let projectBinding : {
42+ workspaceId : string ;
43+ createdByWorkspaceMemberId : string ;
44+ } | undefined = options . projectInitiallyExists === false
45+ ? undefined
46+ : {
47+ workspaceId : 'ws-a' ,
48+ createdByWorkspaceMemberId : 'member-owner' ,
49+ } ;
3950
4051 const resource : TeamResourceShareService = {
4152 configured : true ,
@@ -61,24 +72,47 @@ function fixture() {
6172 if ( readOptions ?. authoritative ) {
6273 if ( authoritativeReadError ) throw authoritativeReadError ;
6374 return [ ...( authoritativeShared ?? shared ) ]
64- . map ( ( id ) => ( { id, canUnshare : authoritativeCanUnshare } ) ) ;
75+ . map ( ( id ) => ( {
76+ id,
77+ ...( hubOwnerMemberId ? { ownerMemberId : hubOwnerMemberId } : { } ) ,
78+ canUnshare : authoritativeCanUnshare ,
79+ } ) ) ;
6580 }
66- return [ ...shared ] . map ( ( id ) => ( { id, canUnshare : true } ) ) ;
81+ return [ ...shared ] . map ( ( id ) => ( {
82+ id,
83+ ...( hubOwnerMemberId ? { ownerMemberId : hubOwnerMemberId } : { } ) ,
84+ canUnshare : true ,
85+ } ) ) ;
6786 } ,
6887 isShared ( resourceId ) {
6988 return shared . has ( resourceId ) ;
7089 } ,
7190 } ;
91+ const ensureProjectId = vi . fn ( async (
92+ resourceId : string ,
93+ requestScope : TeamResourceRequestScope ,
94+ ) => {
95+ expect ( resourceId ) . toBe ( 'user:brand' ) ;
96+ expect ( requestScope . principal ) . toMatchObject ( {
97+ teamId : 'ws-a' ,
98+ memberId : 'member-owner' ,
99+ } ) ;
100+ calls . push ( 'project:ensure:project-brand' ) ;
101+ projectVisibility . set ( 'project-brand' , 'personal' ) ;
102+ projectBinding = {
103+ workspaceId : requestScope . principal . teamId ,
104+ createdByWorkspaceMemberId : requestScope . principal . memberId ,
105+ } ;
106+ return 'project-brand' ;
107+ } ) ;
72108 const prepare = vi . fn ( createDesignSystemBackingProjectPreparer ( {
73109 resolveProjectId : ( resourceId ) => {
74110 expect ( resourceId ) . toBe ( 'user:brand' ) ;
75- return 'project-brand' ;
111+ return projectVisibility . has ( 'project-brand' ) ? 'project-brand' : null ;
76112 } ,
113+ ensureProjectId,
77114 projectExists : ( projectId ) => projectVisibility . has ( projectId ) ,
78- getProjectBinding : ( ) => ( {
79- workspaceId : 'ws-a' ,
80- createdByWorkspaceMemberId : projectCreatorMemberId ,
81- } ) ,
115+ getProjectBinding : ( ) => projectBinding ,
82116 async publishProject ( projectId ) {
83117 calls . push ( `project:team:${ projectId } ` ) ;
84118 if ( failNextProjectShare ) {
@@ -109,6 +143,7 @@ function fixture() {
109143 shared,
110144 projectVisibility,
111145 prepare,
146+ ensureProjectId,
112147 service,
113148 failResourceShare : ( ) => { failResourceShare = true ; } ,
114149 failResourceUnshare : ( ) => { failResourceUnshare = true ; } ,
@@ -125,8 +160,8 @@ function fixture() {
125160 denyAuthoritativeUnshare : ( ) => {
126161 authoritativeCanUnshare = false ;
127162 } ,
128- clearProjectCreator : ( ) => {
129- projectCreatorMemberId = null ;
163+ clearHubOwner : ( ) => {
164+ hubOwnerMemberId = undefined ;
130165 } ,
131166 } ;
132167}
@@ -149,6 +184,55 @@ describe('design-system team share linked backing project', () => {
149184 ] ) ;
150185 } ) ;
151186
187+ it ( 'ensures and binds a missing backing project in the exact Workspace before direct share' , async ( ) => {
188+ const f = fixture ( { projectInitiallyExists : false } ) ;
189+
190+ await expect ( f . service . share ( 'user:brand' , scope ( ) ) ) . resolves . toEqual ( { version : 1 } ) ;
191+
192+ expect ( f . ensureProjectId ) . toHaveBeenCalledOnce ( ) ;
193+ expect ( f . projectVisibility . get ( 'project-brand' ) ) . toBe ( 'team' ) ;
194+ expect ( [ ...f . shared ] ) . toEqual ( [ 'user:brand' ] ) ;
195+ expect ( f . calls ) . toEqual ( [
196+ 'project:ensure:project-brand' ,
197+ 'resource:share:user:brand' ,
198+ 'project:team:project-brand' ,
199+ ] ) ;
200+ } ) ;
201+
202+ it ( 'compensates a failed direct share without leaving the ensured project in Team' , async ( ) => {
203+ const f = fixture ( { projectInitiallyExists : false } ) ;
204+ f . failNextProjectShare ( ) ;
205+
206+ await expect ( f . service . share ( 'user:brand' , scope ( ) ) )
207+ . rejects . toThrow ( 'project publish failed' ) ;
208+
209+ expect ( f . ensureProjectId ) . toHaveBeenCalledOnce ( ) ;
210+ expect ( f . projectVisibility . get ( 'project-brand' ) ) . toBe ( 'personal' ) ;
211+ expect ( f . shared . size ) . toBe ( 0 ) ;
212+ expect ( f . calls ) . toEqual ( [
213+ 'project:ensure:project-brand' ,
214+ 'resource:share:user:brand' ,
215+ 'project:team:project-brand' ,
216+ 'resource:unshare:user:brand' ,
217+ ] ) ;
218+ } ) ;
219+
220+ it ( 'keeps an ensured backing project Personal when design-system publication fails' , async ( ) => {
221+ const f = fixture ( { projectInitiallyExists : false } ) ;
222+ f . failResourceShare ( ) ;
223+
224+ await expect ( f . service . share ( 'user:brand' , scope ( ) ) )
225+ . rejects . toThrow ( 'design-system publish failed' ) ;
226+
227+ expect ( f . ensureProjectId ) . toHaveBeenCalledOnce ( ) ;
228+ expect ( f . projectVisibility . get ( 'project-brand' ) ) . toBe ( 'personal' ) ;
229+ expect ( f . shared . size ) . toBe ( 0 ) ;
230+ expect ( f . calls ) . toEqual ( [
231+ 'project:ensure:project-brand' ,
232+ 'resource:share:user:brand' ,
233+ ] ) ;
234+ } ) ;
235+
152236 it ( 'leaves the project Personal when the design-system publish fails' , async ( ) => {
153237 const f = fixture ( ) ;
154238 f . failResourceShare ( ) ;
@@ -349,20 +433,36 @@ describe('design-system team share linked backing project', () => {
349433 } ;
350434
351435 await expect ( f . service . sharedResources ( adminScope ) ) . resolves . toEqual ( [
352- { id : 'user:brand' , canUnshare : false } ,
436+ { id : 'user:brand' , ownerMemberId : 'member-owner' , canUnshare : false } ,
353437 ] ) ;
354438 } ) ;
355439
356- it ( 'fails closed when no exact linked-project creator can be proven ' , async ( ) => {
440+ it ( 'fails closed when the hub omits exact linked-project creator evidence ' , async ( ) => {
357441 const f = fixture ( ) ;
358442 await f . service . share ( 'user:brand' , scope ( ) ) ;
359- f . clearProjectCreator ( ) ;
443+ f . clearHubOwner ( ) ;
360444
361445 await expect ( f . service . sharedResources ( scope ( ) ) ) . resolves . toEqual ( [
362446 { id : 'user:brand' , canUnshare : false } ,
363447 ] ) ;
364448 } ) ;
365449
450+ it ( 'derives list capability without running full project preparation per resource' , async ( ) => {
451+ const f = fixture ( ) ;
452+ f . shared . add ( 'user:brand' ) ;
453+ f . shared . add ( 'user:brand-two' ) ;
454+ f . shared . add ( 'user:brand-three' ) ;
455+ f . prepare . mockClear ( ) ;
456+
457+ await expect ( f . service . sharedResources ( scope ( ) ) ) . resolves . toEqual ( [
458+ { id : 'user:brand' , ownerMemberId : 'member-owner' , canUnshare : true } ,
459+ { id : 'user:brand-two' , ownerMemberId : 'member-owner' , canUnshare : true } ,
460+ { id : 'user:brand-three' , ownerMemberId : 'member-owner' , canUnshare : true } ,
461+ ] ) ;
462+
463+ expect ( f . prepare ) . not . toHaveBeenCalled ( ) ;
464+ } ) ;
465+
366466 it ( 'fails closed across Workspace A→B before either hub mutation runs' , async ( ) => {
367467 const f = fixture ( ) ;
368468
0 commit comments