Skip to content

Commit 7390387

Browse files
committed
feat: create the libraries context
1 parent 6293659 commit 7390387

5 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { screen } from '@testing-library/react';
2+
import { useParams } from 'react-router-dom';
3+
import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions';
4+
import { renderWrapper } from '@src/setupTest';
5+
import { useLibrary } from './data/hooks';
6+
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
7+
8+
jest.mock('react-router-dom', () => ({
9+
...jest.requireActual('react-router-dom'),
10+
useParams: jest.fn(),
11+
}));
12+
13+
jest.mock('@src/helpers/useValidateUserPermissions', () => ({
14+
useValidateUserPermissions: jest.fn(),
15+
}));
16+
17+
jest.mock('./data/hooks', () => ({
18+
useLibrary: jest.fn(),
19+
}));
20+
21+
const TestComponent = () => {
22+
const context = useLibraryAuthZ();
23+
return (
24+
<div>
25+
<div data-testid="username">{context.username}</div>
26+
<div data-testid="libraryId">{context.libraryId}</div>
27+
<div data-testid="canManageTeam">{context.canManageTeam ? 'true' : 'false'}</div>
28+
<div data-testid="libraryName">{context.libraryName}</div>
29+
<div data-testid="libraryOrg">{context.libraryOrg}</div>
30+
</div>
31+
);
32+
};
33+
34+
describe('LibraryAuthZProvider', () => {
35+
36+
beforeEach(() => {
37+
jest.clearAllMocks();
38+
(useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' });
39+
40+
(useLibrary as jest.Mock).mockReturnValue({
41+
data: {
42+
title: 'Test Library',
43+
org: 'Test Org',
44+
},
45+
});
46+
});
47+
48+
it('provides the correct context values to consumers', () => {
49+
(useValidateUserPermissions as jest.Mock).mockReturnValue({
50+
data: [
51+
{ allowed: true }, // canViewTeam
52+
{ allowed: true }, // canManageTeam
53+
],
54+
});
55+
56+
renderWrapper(
57+
<LibraryAuthZProvider>
58+
<TestComponent />
59+
</LibraryAuthZProvider>
60+
);
61+
62+
expect(screen.getByTestId('username')).toHaveTextContent('testuser');
63+
expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123');
64+
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true');
65+
expect(screen.getByTestId('libraryName')).toHaveTextContent('Test Library');
66+
expect(screen.getByTestId('libraryOrg')).toHaveTextContent('Test Org');
67+
});
68+
69+
it('throws error when user lacks both view and manage permissions', () => {
70+
(useValidateUserPermissions as jest.Mock).mockReturnValue({
71+
data: [
72+
{ allowed: false }, // canViewTeam
73+
{ allowed: false }, // canManageTeam
74+
],
75+
});
76+
77+
expect(() => {
78+
renderWrapper(
79+
<LibraryAuthZProvider>
80+
<TestComponent />
81+
</LibraryAuthZProvider>
82+
);
83+
}).toThrow('NoAccess');
84+
});
85+
86+
it('provides context when user can view but not manage team', () => {
87+
(useValidateUserPermissions as jest.Mock).mockReturnValue({
88+
data: [
89+
{ allowed: true }, // canViewTeam
90+
{ allowed: false }, // canManageTeam
91+
],
92+
});
93+
94+
renderWrapper(
95+
<LibraryAuthZProvider>
96+
<TestComponent />
97+
</LibraryAuthZProvider>
98+
);
99+
100+
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('false');
101+
});
102+
103+
it('throws error when libraryId is missing', () => {
104+
(useParams as jest.Mock).mockReturnValue({}); // No libraryId
105+
106+
expect(() => {
107+
renderWrapper(
108+
<LibraryAuthZProvider>
109+
<TestComponent />
110+
</LibraryAuthZProvider>
111+
);;
112+
}).toThrow('MissingLibrary');
113+
});
114+
115+
it('throws error when useLibraryAuthZ is used outside provider', () => {
116+
const BrokenComponent = () => {
117+
useLibraryAuthZ();
118+
return null;
119+
};
120+
121+
expect(() => {
122+
renderWrapper(<BrokenComponent />);
123+
}).toThrow('useLibraryAuthZ must be used within an LibraryAuthZProvider');
124+
});
125+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
createContext, useContext, useMemo, ReactNode,
3+
} from 'react';
4+
import { useParams } from 'react-router-dom';
5+
import { AppContext } from '@edx/frontend-platform/react';
6+
import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions';
7+
import { useLibrary } from './data/hooks';
8+
9+
const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team'];
10+
11+
export type AppContextType = {
12+
authenticatedUser: {
13+
username: string;
14+
email: string;
15+
};
16+
};
17+
18+
type LibraryAuthZContextType = {
19+
canManageTeam: boolean;
20+
username: string;
21+
libraryId: string;
22+
roles: string[];
23+
permissions: string[];
24+
libraryName: string;
25+
libraryOrg: string;
26+
};
27+
28+
const LibraryAuthZContext = createContext<LibraryAuthZContextType | undefined>(undefined);
29+
30+
type AuthZProviderProps = {
31+
children: ReactNode;
32+
};
33+
34+
export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }) => {
35+
const { libraryId } = useParams<{ libraryId: string }>();
36+
const { authenticatedUser } = useContext(AppContext) as AppContextType;
37+
38+
// TODO: Implement a custom error view
39+
if (!libraryId) {
40+
throw new Error('MissingLibrary');
41+
}
42+
const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, object: libraryId }));
43+
44+
const { data: userPermissions } = useValidateUserPermissions(permissions);
45+
const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions;
46+
47+
if (!canViewTeam && !canManageTeam) {
48+
throw new Error('NoAccess');
49+
}
50+
51+
const { data: libraryMetadata } = useLibrary(libraryId);
52+
53+
const value = useMemo((): LibraryAuthZContextType => ({
54+
username: authenticatedUser.username,
55+
libraryId,
56+
libraryName: libraryMetadata.title,
57+
libraryOrg: libraryMetadata.org,
58+
roles: [],
59+
permissions: [],
60+
canManageTeam,
61+
}), [libraryId, authenticatedUser.username, canManageTeam]);
62+
63+
return (
64+
<LibraryAuthZContext.Provider value={value}>
65+
{children}
66+
</LibraryAuthZContext.Provider>
67+
);
68+
};
69+
70+
export const useLibraryAuthZ = (): LibraryAuthZContextType => {
71+
const context = useContext(LibraryAuthZContext);
72+
if (context === undefined) {
73+
throw new Error('useLibraryAuthZ must be used within an LibraryAuthZProvider');
74+
}
75+
return context;
76+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
2+
import { LibraryMetadata, TeamMember } from '@src/authz-module/constants';
3+
import { getApiUrl, getStudioApiUrl } from '@src/helpers/utils';
4+
5+
export interface GetTeamMembersResponse {
6+
members: TeamMember[];
7+
totalCount: number;
8+
}
9+
10+
// TODO: replece api path once is created
11+
export const getTeamMembers = async (libraryId: string): Promise<TeamMember[]> => {
12+
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${libraryId}`));
13+
return data.results;
14+
};
15+
16+
17+
// TODO: this should be replaced in the future with Console API
18+
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
19+
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));
20+
return {
21+
id: data.id,
22+
org: data.org,
23+
title: data.title,
24+
slug: data.slug,
25+
};
26+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { ReactNode } from 'react';
2+
import { renderHook, waitFor } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { useLibrary, useTeamMembers } from './hooks';
5+
import * as api from './api';
6+
7+
const mockMembers = [
8+
{
9+
displayName: 'Alice',
10+
username: 'user1',
11+
email: 'alice@example.com',
12+
roles: ['admin', 'author'],
13+
},
14+
{
15+
displayName: 'Bob',
16+
username: 'user2',
17+
email: 'bob@example.com',
18+
roles: ['collaborator'],
19+
},
20+
];
21+
22+
const mockLibrary = {
23+
id: 'lib:123',
24+
org: 'demo-org',
25+
title: 'Test Library',
26+
slug: 'test-library',
27+
};
28+
29+
const createWrapper = () => {
30+
const queryClient = new QueryClient({
31+
defaultOptions: {
32+
queries: {
33+
retry: false,
34+
},
35+
},
36+
});
37+
38+
const wrapper = ({ children }: { children: ReactNode }) => (
39+
<QueryClientProvider
40+
client={queryClient}
41+
>{children}
42+
</QueryClientProvider>
43+
);
44+
45+
return wrapper;
46+
};
47+
48+
describe('useTeamMembers', () => {
49+
beforeEach(() => {
50+
jest.clearAllMocks();
51+
});
52+
53+
it('returns data when API call succeeds', async () => {
54+
jest.spyOn(api, 'getTeamMembers').mockResolvedValue(mockMembers);
55+
56+
const { result } = renderHook(() => useTeamMembers('lib:123'), {
57+
wrapper: createWrapper(),
58+
});
59+
60+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
61+
62+
expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123');
63+
expect(result.current.data).toEqual(mockMembers);
64+
});
65+
66+
it('handles error when API call fails', async () => {
67+
jest
68+
.spyOn(api, 'getTeamMembers')
69+
.mockRejectedValue(new Error('API failure'));
70+
71+
const { result } = renderHook(() => useTeamMembers('lib:123'), {
72+
wrapper: createWrapper(),
73+
});
74+
75+
await waitFor(() => expect(result.current.isError).toBe(true));
76+
77+
expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123');
78+
expect(result.current.error).toBeDefined();
79+
expect(result.current.data).toBeUndefined();
80+
});
81+
});
82+
83+
describe('useLibrary', () => {
84+
beforeEach(() => {
85+
jest.clearAllMocks();
86+
});
87+
88+
it('returns metadata on success', async () => {
89+
jest.spyOn(api, 'getLibrary').mockResolvedValue(mockLibrary);
90+
91+
const { result } = renderHook(
92+
() => useLibrary('lib123'),
93+
{ wrapper: createWrapper() },
94+
);
95+
await waitFor(() => {
96+
expect(result.current.data).toEqual(mockLibrary);
97+
expect(api.getLibrary).toHaveBeenCalledWith('lib123');
98+
});
99+
});
100+
101+
it('throws on error', () => {
102+
jest
103+
.spyOn(api, 'getLibrary')
104+
.mockRejectedValue(new Error('Not found'));
105+
106+
const wrapper = createWrapper();
107+
try {
108+
renderHook(() => useLibrary('lib123'), { wrapper });
109+
} catch (e) {
110+
expect(e).toEqual(new Error('Not found'));
111+
}
112+
113+
expect(api.getLibrary).toHaveBeenCalledWith('lib123');
114+
});
115+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
2+
import { LibraryMetadata, TeamMember } from '@src/authz-module/constants';
3+
import { getLibrary, getTeamMembers } from './api';
4+
5+
/**
6+
* React Query hook to fetch all team members for a specific library.
7+
* It retrieves the full list of members who have access to the given library.
8+
*
9+
* @param libraryId - The unique identifier of the library
10+
*
11+
* @example
12+
* ```tsx
13+
* const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123');
14+
* ```
15+
*/
16+
export const useTeamMembers = (libraryId: string) => useQuery<TeamMember[], Error>({
17+
queryKey: ['team-members', libraryId],
18+
queryFn: () => getTeamMembers(libraryId),
19+
staleTime: 1000 * 60 * 30, // refetch after 30 minutes
20+
});
21+
22+
/**
23+
* React Query hook to retrive the inforation of the current library.
24+
*
25+
* @param libraryId - The unique ID of the library.
26+
*
27+
* @example
28+
* const { data, isLoading, isError } = useLibrary('lib:123',);
29+
*
30+
*/
31+
export function useLibrary(libraryId: string) {
32+
return useSuspenseQuery<LibraryMetadata, Error>({
33+
queryKey: ['library-metadata', libraryId],
34+
queryFn: () => getLibrary(libraryId),
35+
retry: false,
36+
});
37+
}

0 commit comments

Comments
 (0)