Skip to content

Commit b193f35

Browse files
committed
feat: add tests for scopes and organizations hooks, including loading states and navigation guards
1 parent 96f6055 commit b193f35

5 files changed

Lines changed: 825 additions & 24 deletions

File tree

src/authz-module/data/api.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
getLibrary,
77
getPermissionsByRole,
88
revokeUserRoles,
9+
getScopes,
10+
getOrganizations,
911
} from './api';
1012

1113
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -180,3 +182,107 @@ describe('revokeUserRoles', () => {
180182
expect(result).toEqual(mockResponse);
181183
});
182184
});
185+
186+
describe('getScopes', () => {
187+
const mockScopesData = {
188+
results: [{
189+
id: 'lib:123', name: 'Test Library', org: 'testorg', contextType: 'library',
190+
}],
191+
count: 1,
192+
next: null,
193+
previous: null,
194+
};
195+
196+
it('builds URL with default page and pageSize when no optional params', async () => {
197+
mockGet.mockResolvedValue({ data: mockScopesData });
198+
199+
const result = await getScopes({});
200+
201+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
202+
expect(calledUrl.searchParams.get('page')).toBe('1');
203+
expect(calledUrl.searchParams.get('page_size')).toBe('10');
204+
expect(calledUrl.searchParams.get('search')).toBeNull();
205+
expect(calledUrl.searchParams.get('org')).toBeNull();
206+
expect(calledUrl.searchParams.get('management_permission_only')).toBeNull();
207+
expect(result).toEqual(mockScopesData);
208+
});
209+
210+
it('appends search param when provided', async () => {
211+
mockGet.mockResolvedValue({ data: mockScopesData });
212+
213+
await getScopes({ search: 'mylib' });
214+
215+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
216+
expect(calledUrl.searchParams.get('search')).toBe('mylib');
217+
});
218+
219+
it('appends org param when provided', async () => {
220+
mockGet.mockResolvedValue({ data: mockScopesData });
221+
222+
await getScopes({ org: 'testorg' });
223+
224+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
225+
expect(calledUrl.searchParams.get('org')).toBe('testorg');
226+
});
227+
228+
it('appends management_permission_only when set to true', async () => {
229+
mockGet.mockResolvedValue({ data: mockScopesData });
230+
231+
await getScopes({ managementPermissionOnly: true });
232+
233+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
234+
expect(calledUrl.searchParams.get('management_permission_only')).toBe('true');
235+
});
236+
237+
it('uses provided page and pageSize', async () => {
238+
mockGet.mockResolvedValue({ data: mockScopesData });
239+
240+
await getScopes({ page: 3, pageSize: 25 });
241+
242+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
243+
expect(calledUrl.searchParams.get('page')).toBe('3');
244+
expect(calledUrl.searchParams.get('page_size')).toBe('25');
245+
});
246+
247+
it('does not append managementPermissionOnly when false', async () => {
248+
mockGet.mockResolvedValue({ data: mockScopesData });
249+
250+
await getScopes({ managementPermissionOnly: false });
251+
252+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
253+
expect(calledUrl.searchParams.get('management_permission_only')).toBeNull();
254+
});
255+
});
256+
257+
describe('getOrganizations', () => {
258+
it('returns organizations from data.results when present', async () => {
259+
const mockData = {
260+
results: [{ org: 'org1', name: 'Org One' }, { org: 'org2', name: 'Org Two' }],
261+
};
262+
mockGet.mockResolvedValue({ data: mockData });
263+
264+
const result = await getOrganizations();
265+
266+
const calledUrl = new URL(mockGet.mock.calls[0][0]);
267+
expect(calledUrl.pathname).toBe('/api/authz/v1/organizations/');
268+
expect(result).toEqual(mockData.results);
269+
});
270+
271+
it('falls back to data directly when results is not present', async () => {
272+
const mockData = [{ org: 'org1', name: 'Org One' }];
273+
mockGet.mockResolvedValue({ data: mockData });
274+
275+
const result = await getOrganizations();
276+
277+
expect(result).toEqual(mockData);
278+
});
279+
280+
it('accepts contextType param (currently unused in URL but accepted)', async () => {
281+
mockGet.mockResolvedValue({ data: { results: [] } });
282+
283+
const result = await getOrganizations('course');
284+
285+
expect(result).toEqual([]);
286+
expect(mockGet).toHaveBeenCalled();
287+
});
288+
});

src/authz-module/data/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export interface OrganizationItem {
147147

148148
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
149149
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
150-
// if (params.contextType) { url.searchParams.set('context_type', params.contextType); }
150+
if (params.contextType) { url.searchParams.set('context_type', params.contextType); }
151151
if (params.search) { url.searchParams.set('search', params.search); }
152152
if (params.org) { url.searchParams.set('org', params.org); }
153153
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
@@ -159,7 +159,7 @@ export const getScopes = async (params: GetScopesParams): Promise<GetScopesRespo
159159

160160
export const getOrganizations = async (contextType?: string): Promise<OrganizationItem[]> => {
161161
const url = new URL(getApiUrl('/api/authz/v1/organizations/'));
162-
// if (contextType) { url.searchParams.set('context_type', contextType); }
162+
if (contextType) { url.searchParams.set('context_type', contextType); }
163163
const { data } = await getAuthenticatedHttpClient().get(url);
164164
return camelCaseObject(data.results ?? data);
165165
};

src/authz-module/data/hooks.test.tsx

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
55
import {
66
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
7-
useValidateUsers,
7+
useValidateUsers, useScopes, useOrganizations, useManagedScopeOrgs,
88
} from './hooks';
99

1010
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -290,6 +290,183 @@ describe('useValidateUsers', () => {
290290
});
291291
});
292292

293+
describe('useScopes', () => {
294+
beforeEach(() => {
295+
jest.clearAllMocks();
296+
});
297+
298+
const makeScopesResponse = (next: string | null = null) => ({
299+
results: [{
300+
id: 'lib:123', name: 'Test Library', org: 'testorg', contextType: 'library',
301+
}],
302+
count: 1,
303+
next,
304+
previous: null,
305+
});
306+
307+
it('returns pages data on success', async () => {
308+
getAuthenticatedHttpClient.mockReturnValue({
309+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }),
310+
});
311+
312+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
313+
314+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
315+
316+
expect(result.current.data?.pages).toHaveLength(1);
317+
expect(result.current.data?.pages[0].results).toHaveLength(1);
318+
});
319+
320+
it('hasNextPage is false when next is null', async () => {
321+
getAuthenticatedHttpClient.mockReturnValue({
322+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }),
323+
});
324+
325+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
326+
327+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
328+
expect(result.current.hasNextPage).toBe(false);
329+
});
330+
331+
it('hasNextPage is true when next URL has page param', async () => {
332+
getAuthenticatedHttpClient.mockReturnValue({
333+
get: jest.fn().mockResolvedValue({
334+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'),
335+
}),
336+
});
337+
338+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
339+
340+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
341+
expect(result.current.hasNextPage).toBe(true);
342+
});
343+
344+
it('hasNextPage is false when next URL has no page param', async () => {
345+
getAuthenticatedHttpClient.mockReturnValue({
346+
get: jest.fn().mockResolvedValue({
347+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'),
348+
}),
349+
});
350+
351+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
352+
353+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
354+
expect(result.current.hasNextPage).toBe(false);
355+
});
356+
357+
it('hasNextPage is false when next is an invalid URL', async () => {
358+
getAuthenticatedHttpClient.mockReturnValue({
359+
get: jest.fn().mockResolvedValue({
360+
data: makeScopesResponse('not-a-valid-url'),
361+
}),
362+
});
363+
364+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
365+
366+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
367+
expect(result.current.hasNextPage).toBe(false);
368+
});
369+
370+
it('handles error when API call fails', async () => {
371+
getAuthenticatedHttpClient.mockReturnValue({
372+
get: jest.fn().mockRejectedValue(new Error('Network error')),
373+
});
374+
375+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
376+
377+
await waitFor(() => expect(result.current.isError).toBe(true));
378+
expect(result.current.error).toBeDefined();
379+
});
380+
});
381+
382+
describe('useOrganizations', () => {
383+
beforeEach(() => {
384+
jest.clearAllMocks();
385+
});
386+
387+
it('returns organizations on success', async () => {
388+
const mockOrgs = [{ org: 'org1', name: 'Org One' }];
389+
getAuthenticatedHttpClient.mockReturnValue({
390+
get: jest.fn().mockResolvedValue({ data: { results: mockOrgs } }),
391+
});
392+
393+
const { result } = renderHook(() => useOrganizations('library'), { wrapper: createWrapper() });
394+
395+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
396+
expect(result.current.data).toEqual(mockOrgs);
397+
});
398+
399+
it('handles error when API fails', async () => {
400+
getAuthenticatedHttpClient.mockReturnValue({
401+
get: jest.fn().mockRejectedValue(new Error('Failed')),
402+
});
403+
404+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
405+
406+
await waitFor(() => expect(result.current.isError).toBe(true));
407+
});
408+
});
409+
410+
describe('useManagedScopeOrgs', () => {
411+
beforeEach(() => {
412+
jest.clearAllMocks();
413+
});
414+
415+
it('does not fetch when contextType is undefined', async () => {
416+
const mockGet = jest.fn();
417+
getAuthenticatedHttpClient.mockReturnValue({ get: mockGet });
418+
419+
const { result } = renderHook(() => useManagedScopeOrgs(undefined), { wrapper: createWrapper() });
420+
421+
// Query is disabled, so it should not be loading or have fetched
422+
expect(result.current.isFetching).toBe(false);
423+
expect(mockGet).not.toHaveBeenCalled();
424+
});
425+
426+
it('fetches and returns a Set of orgs when contextType is provided', async () => {
427+
const mockScopesResponse = {
428+
results: [
429+
{
430+
id: 'lib:123', name: 'Lib 1', org: 'org1', contextType: 'library',
431+
},
432+
{
433+
id: 'lib:456', name: 'Lib 2', org: 'org2', contextType: 'library',
434+
},
435+
{
436+
id: 'lib:789', name: 'Lib 3', org: '', contextType: 'library',
437+
},
438+
],
439+
count: 3,
440+
next: null,
441+
previous: null,
442+
};
443+
getAuthenticatedHttpClient.mockReturnValue({
444+
get: jest.fn().mockResolvedValue({ data: mockScopesResponse }),
445+
});
446+
447+
const { result } = renderHook(() => useManagedScopeOrgs('library'), { wrapper: createWrapper() });
448+
449+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
450+
451+
const orgs = result.current.data as Set<string>;
452+
expect(orgs.has('org1')).toBe(true);
453+
expect(orgs.has('org2')).toBe(true);
454+
// empty string org is filtered out
455+
expect(orgs.has('')).toBe(false);
456+
expect(orgs.size).toBe(2);
457+
});
458+
459+
it('handles error when API fails', async () => {
460+
getAuthenticatedHttpClient.mockReturnValue({
461+
get: jest.fn().mockRejectedValue(new Error('API error')),
462+
});
463+
464+
const { result } = renderHook(() => useManagedScopeOrgs('course'), { wrapper: createWrapper() });
465+
466+
await waitFor(() => expect(result.current.isError).toBe(true));
467+
});
468+
});
469+
293470
describe('useRevokeUserRoles', () => {
294471
beforeEach(() => {
295472
jest.clearAllMocks();

0 commit comments

Comments
 (0)