feat: create the libraries context
This commit is contained in:
committed by
Adolfo R. Brandes
parent
b825a8bdd9
commit
26e28aeb96
125
src/authz-module/libraries-manager/context.test.tsx
Normal file
125
src/authz-module/libraries-manager/context.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import { useLibrary } from './data/hooks';
|
||||
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@src/helpers/useValidateUserPermissions', () => ({
|
||||
useValidateUserPermissions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./data/hooks', () => ({
|
||||
useLibrary: jest.fn(),
|
||||
}));
|
||||
|
||||
const TestComponent = () => {
|
||||
const context = useLibraryAuthZ();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="username">{context.username}</div>
|
||||
<div data-testid="libraryId">{context.libraryId}</div>
|
||||
<div data-testid="canManageTeam">{context.canManageTeam ? 'true' : 'false'}</div>
|
||||
<div data-testid="libraryName">{context.libraryName}</div>
|
||||
<div data-testid="libraryOrg">{context.libraryOrg}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LibraryAuthZProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' });
|
||||
|
||||
(useLibrary as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
title: 'Test Library',
|
||||
org: 'Test Org',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('provides the correct context values to consumers', () => {
|
||||
(useValidateUserPermissions as jest.Mock).mockReturnValue({
|
||||
data: [
|
||||
{ allowed: true }, // canViewTeam
|
||||
{ allowed: true }, // canManageTeam
|
||||
],
|
||||
});
|
||||
|
||||
renderWrapper(
|
||||
<LibraryAuthZProvider>
|
||||
<TestComponent />
|
||||
</LibraryAuthZProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('username')).toHaveTextContent('testuser');
|
||||
expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123');
|
||||
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true');
|
||||
expect(screen.getByTestId('libraryName')).toHaveTextContent('Test Library');
|
||||
expect(screen.getByTestId('libraryOrg')).toHaveTextContent('Test Org');
|
||||
});
|
||||
|
||||
it('throws error when user lacks both view and manage permissions', () => {
|
||||
(useValidateUserPermissions as jest.Mock).mockReturnValue({
|
||||
data: [
|
||||
{ allowed: false }, // canViewTeam
|
||||
{ allowed: false }, // canManageTeam
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
renderWrapper(
|
||||
<LibraryAuthZProvider>
|
||||
<TestComponent />
|
||||
</LibraryAuthZProvider>
|
||||
);
|
||||
}).toThrow('NoAccess');
|
||||
});
|
||||
|
||||
it('provides context when user can view but not manage team', () => {
|
||||
(useValidateUserPermissions as jest.Mock).mockReturnValue({
|
||||
data: [
|
||||
{ allowed: true }, // canViewTeam
|
||||
{ allowed: false }, // canManageTeam
|
||||
],
|
||||
});
|
||||
|
||||
renderWrapper(
|
||||
<LibraryAuthZProvider>
|
||||
<TestComponent />
|
||||
</LibraryAuthZProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('canManageTeam')).toHaveTextContent('false');
|
||||
});
|
||||
|
||||
it('throws error when libraryId is missing', () => {
|
||||
(useParams as jest.Mock).mockReturnValue({}); // No libraryId
|
||||
|
||||
expect(() => {
|
||||
renderWrapper(
|
||||
<LibraryAuthZProvider>
|
||||
<TestComponent />
|
||||
</LibraryAuthZProvider>
|
||||
);;
|
||||
}).toThrow('MissingLibrary');
|
||||
});
|
||||
|
||||
it('throws error when useLibraryAuthZ is used outside provider', () => {
|
||||
const BrokenComponent = () => {
|
||||
useLibraryAuthZ();
|
||||
return null;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
renderWrapper(<BrokenComponent />);
|
||||
}).toThrow('useLibraryAuthZ must be used within an LibraryAuthZProvider');
|
||||
});
|
||||
});
|
||||
76
src/authz-module/libraries-manager/context.tsx
Normal file
76
src/authz-module/libraries-manager/context.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
createContext, useContext, useMemo, ReactNode,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions';
|
||||
import { useLibrary } from './data/hooks';
|
||||
|
||||
const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team'];
|
||||
|
||||
export type AppContextType = {
|
||||
authenticatedUser: {
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
type LibraryAuthZContextType = {
|
||||
canManageTeam: boolean;
|
||||
username: string;
|
||||
libraryId: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
libraryName: string;
|
||||
libraryOrg: string;
|
||||
};
|
||||
|
||||
const LibraryAuthZContext = createContext<LibraryAuthZContextType | undefined>(undefined);
|
||||
|
||||
type AuthZProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }) => {
|
||||
const { libraryId } = useParams<{ libraryId: string }>();
|
||||
const { authenticatedUser } = useContext(AppContext) as AppContextType;
|
||||
|
||||
// TODO: Implement a custom error view
|
||||
if (!libraryId) {
|
||||
throw new Error('MissingLibrary');
|
||||
}
|
||||
const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, object: libraryId }));
|
||||
|
||||
const { data: userPermissions } = useValidateUserPermissions(permissions);
|
||||
const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions;
|
||||
|
||||
if (!canViewTeam && !canManageTeam) {
|
||||
throw new Error('NoAccess');
|
||||
}
|
||||
|
||||
const { data: libraryMetadata } = useLibrary(libraryId);
|
||||
|
||||
const value = useMemo((): LibraryAuthZContextType => ({
|
||||
username: authenticatedUser.username,
|
||||
libraryId,
|
||||
libraryName: libraryMetadata.title,
|
||||
libraryOrg: libraryMetadata.org,
|
||||
roles: [],
|
||||
permissions: [],
|
||||
canManageTeam,
|
||||
}), [libraryId, authenticatedUser.username, canManageTeam]);
|
||||
|
||||
return (
|
||||
<LibraryAuthZContext.Provider value={value}>
|
||||
{children}
|
||||
</LibraryAuthZContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLibraryAuthZ = (): LibraryAuthZContextType => {
|
||||
const context = useContext(LibraryAuthZContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useLibraryAuthZ must be used within an LibraryAuthZProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
26
src/authz-module/libraries-manager/data/api.ts
Normal file
26
src/authz-module/libraries-manager/data/api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { LibraryMetadata, TeamMember } from '@src/authz-module/constants';
|
||||
import { getApiUrl, getStudioApiUrl } from '@src/helpers/utils';
|
||||
|
||||
export interface GetTeamMembersResponse {
|
||||
members: TeamMember[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// TODO: replece api path once is created
|
||||
export const getTeamMembers = async (libraryId: string): Promise<TeamMember[]> => {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${libraryId}`));
|
||||
return data.results;
|
||||
};
|
||||
|
||||
|
||||
// TODO: this should be replaced in the future with Console API
|
||||
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));
|
||||
return {
|
||||
id: data.id,
|
||||
org: data.org,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
};
|
||||
};
|
||||
115
src/authz-module/libraries-manager/data/hooks.test.tsx
Normal file
115
src/authz-module/libraries-manager/data/hooks.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useLibrary, useTeamMembers } from './hooks';
|
||||
import * as api from './api';
|
||||
|
||||
const mockMembers = [
|
||||
{
|
||||
displayName: 'Alice',
|
||||
username: 'user1',
|
||||
email: 'alice@example.com',
|
||||
roles: ['admin', 'author'],
|
||||
},
|
||||
{
|
||||
displayName: 'Bob',
|
||||
username: 'user2',
|
||||
email: 'bob@example.com',
|
||||
roles: ['collaborator'],
|
||||
},
|
||||
];
|
||||
|
||||
const mockLibrary = {
|
||||
id: 'lib:123',
|
||||
org: 'demo-org',
|
||||
title: 'Test Library',
|
||||
slug: 'test-library',
|
||||
};
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider
|
||||
client={queryClient}
|
||||
>{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('useTeamMembers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns data when API call succeeds', async () => {
|
||||
jest.spyOn(api, 'getTeamMembers').mockResolvedValue(mockMembers);
|
||||
|
||||
const { result } = renderHook(() => useTeamMembers('lib:123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123');
|
||||
expect(result.current.data).toEqual(mockMembers);
|
||||
});
|
||||
|
||||
it('handles error when API call fails', async () => {
|
||||
jest
|
||||
.spyOn(api, 'getTeamMembers')
|
||||
.mockRejectedValue(new Error('API failure'));
|
||||
|
||||
const { result } = renderHook(() => useTeamMembers('lib:123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123');
|
||||
expect(result.current.error).toBeDefined();
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLibrary', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns metadata on success', async () => {
|
||||
jest.spyOn(api, 'getLibrary').mockResolvedValue(mockLibrary);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useLibrary('lib123'),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockLibrary);
|
||||
expect(api.getLibrary).toHaveBeenCalledWith('lib123');
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on error', () => {
|
||||
jest
|
||||
.spyOn(api, 'getLibrary')
|
||||
.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const wrapper = createWrapper();
|
||||
try {
|
||||
renderHook(() => useLibrary('lib123'), { wrapper });
|
||||
} catch (e) {
|
||||
expect(e).toEqual(new Error('Not found'));
|
||||
}
|
||||
|
||||
expect(api.getLibrary).toHaveBeenCalledWith('lib123');
|
||||
});
|
||||
});
|
||||
37
src/authz-module/libraries-manager/data/hooks.ts
Normal file
37
src/authz-module/libraries-manager/data/hooks.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { LibraryMetadata, TeamMember } from '@src/authz-module/constants';
|
||||
import { getLibrary, getTeamMembers } from './api';
|
||||
|
||||
/**
|
||||
* React Query hook to fetch all team members for a specific library.
|
||||
* It retrieves the full list of members who have access to the given library.
|
||||
*
|
||||
* @param libraryId - The unique identifier of the library
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123');
|
||||
* ```
|
||||
*/
|
||||
export const useTeamMembers = (libraryId: string) => useQuery<TeamMember[], Error>({
|
||||
queryKey: ['team-members', libraryId],
|
||||
queryFn: () => getTeamMembers(libraryId),
|
||||
staleTime: 1000 * 60 * 30, // refetch after 30 minutes
|
||||
});
|
||||
|
||||
/**
|
||||
* React Query hook to retrive the inforation of the current library.
|
||||
*
|
||||
* @param libraryId - The unique ID of the library.
|
||||
*
|
||||
* @example
|
||||
* const { data, isLoading, isError } = useLibrary('lib:123',);
|
||||
*
|
||||
*/
|
||||
export function useLibrary(libraryId: string) {
|
||||
return useSuspenseQuery<LibraryMetadata, Error>({
|
||||
queryKey: ['library-metadata', libraryId],
|
||||
queryFn: () => getLibrary(libraryId),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user