@@ -100,7 +100,14 @@ describe('GET /manage/tenants/:tenantId/apps — project access filtering', () =
100100 const res = await app . request ( '/manage/tenants/tenant-1/apps' ) ;
101101
102102 expect ( res . status ) . toBe ( 200 ) ;
103- expect ( listAppsPaginatedMock ) . not . toHaveBeenCalled ( ) ;
103+ // The main scoped listing is short-circuited; only the unscoped
104+ // tenantHasAnyApps probe runs (one call, with `limit: 1`).
105+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledTimes ( 1 ) ;
106+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledWith ( {
107+ scopes : { tenantId : 'tenant-1' } ,
108+ pagination : { page : 1 , limit : 1 } ,
109+ type : undefined ,
110+ } ) ;
104111
105112 const body = await res . json ( ) ;
106113 expect ( body . data ) . toEqual ( [ ] ) ;
@@ -151,3 +158,149 @@ describe('GET /manage/tenants/:tenantId/apps — project access filtering', () =
151158 } ) ;
152159 } ) ;
153160} ) ;
161+
162+ describe ( 'GET /manage/tenants/:tenantId/apps — role and tenantHasAnyApps response fields' , ( ) => {
163+ beforeEach ( ( ) => {
164+ vi . clearAllMocks ( ) ;
165+ listAppsPaginatedMock . mockResolvedValue ( emptyPage ) ;
166+ } ) ;
167+
168+ afterEach ( ( ) => {
169+ vi . restoreAllMocks ( ) ;
170+ } ) ;
171+
172+ it ( 'returns role and tenantHasAnyApps=true when a member with no project access is in a tenant with apps elsewhere' , async ( ) => {
173+ listUsableProjectIdsMock . mockResolvedValue ( [ ] ) ;
174+ // The handler's early-return path runs the unscoped tenantHasAnyApps probe.
175+ listAppsPaginatedMock . mockResolvedValueOnce ( {
176+ data : [ { id : 'app-elsewhere' } ] ,
177+ pagination : { page : 1 , limit : 1 , total : 1 , pages : 1 } ,
178+ } ) ;
179+
180+ const app = buildHarness ( { userId : 'user-no-projects' , tenantRole : 'member' } ) ;
181+ const res = await app . request ( '/manage/tenants/tenant-1/apps?type=support_copilot' ) ;
182+
183+ expect ( res . status ) . toBe ( 200 ) ;
184+ const body = await res . json ( ) ;
185+ expect ( body . data ) . toEqual ( [ ] ) ;
186+ expect ( body . role ) . toBe ( 'member' ) ;
187+ expect ( body . tenantHasAnyApps ) . toBe ( true ) ;
188+ // Only the unscoped probe runs — main listing is short-circuited.
189+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledTimes ( 1 ) ;
190+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledWith ( {
191+ scopes : { tenantId : 'tenant-1' } ,
192+ pagination : { page : 1 , limit : 1 } ,
193+ type : 'support_copilot' ,
194+ } ) ;
195+ } ) ;
196+
197+ it ( 'returns tenantHasAnyApps=false when a member with no project access is in a tenant with zero apps' , async ( ) => {
198+ listUsableProjectIdsMock . mockResolvedValue ( [ ] ) ;
199+ listAppsPaginatedMock . mockResolvedValueOnce ( emptyPage ) ;
200+
201+ const app = buildHarness ( { userId : 'user-no-projects' , tenantRole : 'member' } ) ;
202+ const res = await app . request ( '/manage/tenants/tenant-1/apps' ) ;
203+
204+ expect ( res . status ) . toBe ( 200 ) ;
205+ const body = await res . json ( ) ;
206+ expect ( body . data ) . toEqual ( [ ] ) ;
207+ expect ( body . role ) . toBe ( 'member' ) ;
208+ expect ( body . tenantHasAnyApps ) . toBe ( false ) ;
209+ } ) ;
210+
211+ it ( 'fires a second unscoped query when a member has projects but the scoped result is empty' , async ( ) => {
212+ listUsableProjectIdsMock . mockResolvedValue ( [ 'proj-a' ] ) ;
213+ // First call (scoped to user's projects) returns empty.
214+ listAppsPaginatedMock . mockResolvedValueOnce ( emptyPage ) ;
215+ // Second call (unscoped tenant probe) finds apps elsewhere.
216+ listAppsPaginatedMock . mockResolvedValueOnce ( {
217+ data : [ { id : 'app-elsewhere' } ] ,
218+ pagination : { page : 1 , limit : 1 , total : 1 , pages : 1 } ,
219+ } ) ;
220+
221+ const app = buildHarness ( { userId : 'user-non-admin' , tenantRole : 'member' } ) ;
222+ const res = await app . request ( '/manage/tenants/tenant-1/apps' ) ;
223+
224+ expect ( res . status ) . toBe ( 200 ) ;
225+ const body = await res . json ( ) ;
226+ expect ( body . data ) . toEqual ( [ ] ) ;
227+ expect ( body . role ) . toBe ( 'member' ) ;
228+ expect ( body . tenantHasAnyApps ) . toBe ( true ) ;
229+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledTimes ( 2 ) ;
230+ expect ( listAppsPaginatedMock ) . toHaveBeenNthCalledWith ( 1 , {
231+ scopes : { tenantId : 'tenant-1' , projectIds : [ 'proj-a' ] } ,
232+ pagination : { page : 1 , limit : 10 } ,
233+ type : undefined ,
234+ } ) ;
235+ expect ( listAppsPaginatedMock ) . toHaveBeenNthCalledWith ( 2 , {
236+ scopes : { tenantId : 'tenant-1' } ,
237+ pagination : { page : 1 , limit : 1 } ,
238+ type : undefined ,
239+ } ) ;
240+ } ) ;
241+
242+ it ( 'skips the second query and returns tenantHasAnyApps=true when the scoped result is non-empty' , async ( ) => {
243+ listUsableProjectIdsMock . mockResolvedValue ( [ 'proj-a' ] ) ;
244+ listAppsPaginatedMock . mockResolvedValueOnce ( {
245+ data : [
246+ {
247+ id : 'app-a' ,
248+ tenantId : 'tenant-1' ,
249+ projectId : 'proj-a' ,
250+ name : 'A' ,
251+ type : 'support_copilot' ,
252+ enabled : true ,
253+ config : { type : 'support_copilot' , supportCopilot : { } } ,
254+ defaultAgentId : null ,
255+ defaultProjectId : 'proj-a' ,
256+ lastUsedAt : null ,
257+ createdAt : '2025-01-01T00:00:00.000Z' ,
258+ updatedAt : '2025-01-01T00:00:00.000Z' ,
259+ } ,
260+ ] ,
261+ pagination : { page : 1 , limit : 10 , total : 1 , pages : 1 } ,
262+ } ) ;
263+
264+ const app = buildHarness ( { userId : 'user-non-admin' , tenantRole : 'member' } ) ;
265+ const res = await app . request ( '/manage/tenants/tenant-1/apps' ) ;
266+
267+ expect ( res . status ) . toBe ( 200 ) ;
268+ const body = await res . json ( ) ;
269+ expect ( body . data ) . toHaveLength ( 1 ) ;
270+ expect ( body . role ) . toBe ( 'member' ) ;
271+ expect ( body . tenantHasAnyApps ) . toBe ( true ) ;
272+ // No second probe — non-empty result is authoritative.
273+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledTimes ( 1 ) ;
274+ } ) ;
275+
276+ it ( 'returns role=admin and tenantHasAnyApps=false without a second query when an admin sees an empty list' , async ( ) => {
277+ listAppsPaginatedMock . mockResolvedValue ( emptyPage ) ;
278+
279+ const app = buildHarness ( { userId : 'user-admin' , tenantRole : 'admin' } ) ;
280+ const res = await app . request ( '/manage/tenants/tenant-1/apps' ) ;
281+
282+ expect ( res . status ) . toBe ( 200 ) ;
283+ const body = await res . json ( ) ;
284+ expect ( body . data ) . toEqual ( [ ] ) ;
285+ expect ( body . role ) . toBe ( 'admin' ) ;
286+ // Admins see the whole tenant, so empty is authoritative — no extra DB hit.
287+ expect ( body . tenantHasAnyApps ) . toBe ( false ) ;
288+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledTimes ( 1 ) ;
289+ } ) ;
290+
291+ it ( 'returns role=owner and tenantHasAnyApps=true for an owner with apps' , async ( ) => {
292+ listAppsPaginatedMock . mockResolvedValueOnce ( {
293+ data : [ { id : 'app-a' } ] ,
294+ pagination : { page : 1 , limit : 10 , total : 1 , pages : 1 } ,
295+ } ) ;
296+
297+ const app = buildHarness ( { userId : 'user-owner' , tenantRole : 'owner' } ) ;
298+ const res = await app . request ( '/manage/tenants/tenant-1/apps' ) ;
299+
300+ expect ( res . status ) . toBe ( 200 ) ;
301+ const body = await res . json ( ) ;
302+ expect ( body . role ) . toBe ( 'owner' ) ;
303+ expect ( body . tenantHasAnyApps ) . toBe ( true ) ;
304+ expect ( listAppsPaginatedMock ) . toHaveBeenCalledTimes ( 1 ) ;
305+ } ) ;
306+ } ) ;
0 commit comments