feat: create the libraries context

This commit is contained in:
Diana Olarte
2025-09-24 23:00:22 +10:00
committed by Adolfo R. Brandes
parent b825a8bdd9
commit 26e28aeb96
5 changed files with 379 additions and 0 deletions

View 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');
});
});

View 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;
};

View 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,
};
};

View 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');
});
});

View 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,
});
}