Skip to content

Commit 9ff96b8

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

4 files changed

Lines changed: 803 additions & 22 deletions

File tree

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

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

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

Lines changed: 170 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,175 @@ describe('useValidateUsers', () => {
290290
});
291291
});
292292

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

src/authz-module/libraries-manager/LibrariesUserManager.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ jest.mock('@edx/frontend-platform/logging', () => ({
1111
logError: jest.fn(),
1212
}));
1313

14+
const mockNavigate = jest.fn();
15+
1416
jest.mock('react-router-dom', () => ({
1517
...jest.requireActual('react-router-dom'),
1618
useParams: jest.fn(),
19+
useNavigate: () => mockNavigate,
1720
}));
1821

1922
jest.mock('./context', () => ({
@@ -162,6 +165,82 @@ describe('LibrariesUserManager', () => {
162165
expect(navLinkLibraryTeamManagement).toHaveAttribute('href', '/authz/libraries/lib:123');
163166
});
164167

168+
describe('Navigation guards', () => {
169+
it('redirects to team path when canManageTeam is false', () => {
170+
(useLibraryAuthZ as jest.Mock).mockReturnValue({
171+
...defaultMockData,
172+
canManageTeam: false,
173+
});
174+
175+
renderComponent();
176+
177+
expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123');
178+
});
179+
180+
it('redirects to team path when user is not found after loading completes', async () => {
181+
(useTeamMembers as jest.Mock).mockReturnValue({
182+
data: { results: [] },
183+
isLoading: false,
184+
isFetching: false,
185+
});
186+
187+
renderComponent();
188+
189+
await waitFor(() => {
190+
expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123');
191+
});
192+
});
193+
194+
it('does not redirect while member data is still fetching', () => {
195+
(useTeamMembers as jest.Mock).mockReturnValue({
196+
data: undefined,
197+
isLoading: true,
198+
isFetching: true,
199+
});
200+
201+
renderComponent();
202+
203+
// navigate should only be called for canManageTeam=true case (not at all here)
204+
expect(mockNavigate).not.toHaveBeenCalled();
205+
});
206+
});
207+
208+
describe('Loading state', () => {
209+
it('renders skeleton while loading team member data', () => {
210+
(useTeamMembers as jest.Mock).mockReturnValue({
211+
data: undefined,
212+
isLoading: true,
213+
isFetching: true,
214+
});
215+
216+
renderComponent();
217+
218+
// Skeleton renders as a placeholder element
219+
const skeletons = document.querySelectorAll('[aria-label="loading..."], .react-loading-skeleton, span[style]');
220+
// Just verify the component renders without crashing in loading state
221+
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
222+
});
223+
});
224+
225+
describe('Assign role button', () => {
226+
it('navigates to assign-role wizard when Assign Role button is clicked', async () => {
227+
const user = userEvent.setup();
228+
renderComponent();
229+
230+
// There are two "Assign Role" elements: the mocked AssignNewRoleTrigger and the real Button
231+
// The real Button navigates; use getAllByText and click the one that is a <button>
232+
const assignRoleButtons = screen.getAllByText('Assign Role');
233+
const realButton = assignRoleButtons.find(
234+
(el) => el.tagName === 'BUTTON' || el.closest('button[class*="btn-primary"]') !== null,
235+
);
236+
await user.click(realButton || assignRoleButtons[0]);
237+
238+
expect(mockNavigate).toHaveBeenCalledWith(
239+
'/authz/assign-role?scope=lib:123&users=testuser',
240+
);
241+
});
242+
});
243+
165244
describe('Revoking User Role Flow', () => {
166245
it('opens confirmation modal when delete role button is clicked', async () => {
167246
const user = userEvent.setup();

0 commit comments

Comments
 (0)