From fb55f69ad993e2c2c4715e7cb8dd2abba4e593ed Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 9 Oct 2025 15:43:21 -0600 Subject: [PATCH 01/24] feat: add backup view for libraries v2 --- src/header/Header.tsx | 8 +- src/header/hooks.jsx | 12 +- src/header/messages.js | 10 + src/library-authoring/LibraryLayout.tsx | 15 +- .../backup-restore/LibraryBackupPage.tsx | 211 ++++++++++++++++++ .../backup-restore/data/api.ts | 16 ++ .../backup-restore/data/constants.ts | 17 ++ .../backup-restore/data/hooks.ts | 29 +++ src/library-authoring/backup-restore/index.ts | 1 + .../backup-restore/messages.ts | 66 ++++++ src/library-authoring/routes.ts | 2 + 11 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 src/library-authoring/backup-restore/LibraryBackupPage.tsx create mode 100644 src/library-authoring/backup-restore/data/api.ts create mode 100644 src/library-authoring/backup-restore/data/constants.ts create mode 100644 src/library-authoring/backup-restore/data/hooks.ts create mode 100644 src/library-authoring/backup-restore/index.ts create mode 100644 src/library-authoring/backup-restore/messages.ts diff --git a/src/header/Header.tsx b/src/header/Header.tsx index 4b0325291a..fde2159502 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -39,7 +39,7 @@ const Header = ({ const contentMenuItems = useContentMenuItems(contextId); const settingMenuItems = useSettingMenuItems(contextId); - const toolsMenuItems = useToolsMenuItems(contextId); + const toolsMenuItems = useToolsMenuItems(contextId, isLibrary); const mainMenuDropdowns = !isLibrary ? [ { id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, @@ -56,7 +56,11 @@ const Header = ({ buttonTitle: intl.formatMessage(messages['header.links.tools']), items: toolsMenuItems, }, - ] : []; + ] : [{ + id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`, + buttonTitle: intl.formatMessage(messages['header.links.tools']), + items: toolsMenuItems, + }]; const getOutlineLink = () => { if (isLibrary) { diff --git a/src/header/hooks.jsx b/src/header/hooks.jsx index 069eac7dc6..311375e578 100644 --- a/src/header/hooks.jsx +++ b/src/header/hooks.jsx @@ -89,7 +89,7 @@ export const useSettingMenuItems = courseId => { return items; }; -export const useToolsMenuItems = courseId => { +export const useToolsMenuItems = (courseId, isLibrary = false) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const waffleFlags = useWaffleFlags(); @@ -123,5 +123,13 @@ export const useToolsMenuItems = courseId => { ), }] : []), ]; - return items; + + const libraryItems = [ + { + href: `/library/${courseId}/backup`, + title: intl.formatMessage(messages['header.links.exportLibrary']), + }, + ]; + + return isLibrary ? libraryItems : items; }; diff --git a/src/header/messages.js b/src/header/messages.js index 71cd07be15..19690721e4 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -96,11 +96,21 @@ const messages = defineMessages({ defaultMessage: 'Import', description: 'Link to Studio Import page', }, + 'header.links.importLibrary': { + id: 'header.links.importLibrary', + defaultMessage: 'Import', + description: 'Link to Studio Import Library page', + }, 'header.links.exportCourse': { id: 'header.links.exportCourse', defaultMessage: 'Export Course', description: 'Link to Studio Export page', }, + 'header.links.exportLibrary': { + id: 'header.links.exportLibrary', + defaultMessage: 'Backup to local archive', + description: 'Link to Studio Export Library page', + }, 'header.links.optimizer': { id: 'header.links.optimizer', defaultMessage: 'Course Optimizer', diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index cc7d27df30..350bd5c39c 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -6,17 +6,18 @@ import { useParams, } from 'react-router-dom'; -import { ROUTES } from './routes'; +import { LibraryBackupPage } from '@src/library-authoring/backup-restore'; import LibraryAuthoringPage from './LibraryAuthoringPage'; +import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { LibraryProvider } from './common/context/LibraryContext'; import { SidebarProvider } from './common/context/SidebarContext'; -import { CreateCollectionModal } from './create-collection'; -import { CreateContainerModal } from './create-container'; -import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; -import { LibraryUnitPage } from './units'; +import { CreateCollectionModal } from './create-collection'; +import { CreateContainerModal } from './create-container'; +import { ROUTES } from './routes'; import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections'; +import { LibraryUnitPage } from './units'; const LibraryLayoutWrapper: React.FC = ({ children }) => { const { @@ -85,6 +86,10 @@ const LibraryLayout = () => ( path={ROUTES.UNIT} Component={LibraryUnitPage} /> + ); diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.tsx new file mode 100644 index 0000000000..2c8fbdb588 --- /dev/null +++ b/src/library-authoring/backup-restore/LibraryBackupPage.tsx @@ -0,0 +1,211 @@ +import { getConfig } from '@edx/frontend-platform'; +import { + Alert, + Button, + Container, +} from '@openedx/paragon'; +import { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Helmet } from 'react-helmet'; + +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Download, Loop, Newsstand } from '@openedx/paragon/icons'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import { LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants'; +import { useCreateLibraryBackup, useGetLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/hooks'; +import NotFoundAlert from '../../generic/NotFoundAlert'; +import Header from '../../header'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useContentLibrary } from '../data/apiHooks'; +import messages from './messages'; + +export const LibraryBackupPage = () => { + const intl = useIntl(); + const { libraryId } = useLibraryContext(); + const [taskId, setTaskId] = useState(''); + const [isMutationInProgress, setIsMutationInProgress] = useState(false); + const timeoutRef = useRef(null); + const { data: libraryData } = useContentLibrary(libraryId); + + const mutation = useCreateLibraryBackup(libraryId); + const backupStatus = useGetLibraryBackupStatus(libraryId, taskId); + + // Clean up timeout on unmount + useEffect(() => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, []); + + const handleDownload = useCallback((url: string) => { + try { + // Create a temporary anchor element for better download handling + const link = document.createElement('a'); + link.href = url; + link.download = `${libraryData?.slug || 'library'}-backup.tar.gz`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + // Fallback to window.location.href if the above fails + window.location.href = url; + } + }, [libraryData?.slug]); + + const handleDownloadBackup = useCallback(() => { + // If backup is ready, download it immediately + if (backupStatus.data?.state === LibraryBackupStatus.Succeeded && backupStatus.data.url) { + const fullUrl = `${getConfig().STUDIO_BASE_URL}${backupStatus.data.url}`; + handleDownload(fullUrl); + return; + } + + // If no backup in progress, create a new one + if (!taskId) { + setIsMutationInProgress(true); + mutation.mutate(undefined, { + onSuccess: (data) => { + setTaskId(data.task_id); + // Clear task id after 5 minutes to allow new backups + timeoutRef.current = setTimeout(() => { + setTaskId(''); + setIsMutationInProgress(false); + timeoutRef.current = null; + }, 5 * 60 * 1000); + }, + onError: () => { + setIsMutationInProgress(false); + }, + }); + } + }, [taskId, backupStatus.data, mutation, handleDownload]); + + // Auto-download when backup becomes ready + useEffect(() => { + if (backupStatus.data?.state === LibraryBackupStatus.Succeeded && backupStatus.data.url) { + const fullUrl = `${getConfig().STUDIO_BASE_URL}${backupStatus.data.url}`; + handleDownload(fullUrl); + setIsMutationInProgress(false); + } + }, [backupStatus.data?.state, backupStatus.data?.url, handleDownload]); + + // Reset mutation progress when backup fails + useEffect(() => { + if (backupStatus.data?.state === LibraryBackupStatus.Failed) { + setIsMutationInProgress(false); + } + }, [backupStatus.data?.state]); + + const backupState = backupStatus.data?.state; + const isBackupInProgress = isMutationInProgress || (taskId && ( + backupState === LibraryBackupStatus.Pending + || backupState === LibraryBackupStatus.Exporting + )); + const hasBackupFailed = backupState === LibraryBackupStatus.Failed; + const hasBackupSucceeded = backupState === LibraryBackupStatus.Succeeded; + + // Show error message for failed mutation + const mutationError = mutation.error as Error | null; + + if (!libraryData) { + return ; + } + + const getButtonText = () => { + if (isBackupInProgress) { + if (isMutationInProgress && !backupState) { + return intl.formatMessage(messages.backupPending); + } + return backupState === LibraryBackupStatus.Pending + ? intl.formatMessage(messages.backupPending) : intl.formatMessage(messages.backupExporting); + } + if (hasBackupSucceeded && backupStatus.data?.url) { + return intl.formatMessage(messages.downloadReadyButton); + } + return intl.formatMessage(messages.createBackupButton); + }; + + const getButtonIcon = () => { + if (isBackupInProgress) { + return Loop; + } + return Download; + }; + + return ( +
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ +
+ + {/* Error Messages */} + {hasBackupFailed && ( +
+ + {intl.formatMessage(messages.backupFailedError)} + +
+ )} + {mutationError && ( +
+ + {intl.formatMessage(messages.mutationError, { error: mutationError.message })} + +
+ )} + + +
+

{intl.formatMessage(messages.backupDescription)}

+
+ +
+
+
+ + {libraryData.title} +
+ {`${libraryData.org} / ${libraryData.slug}`} +
+ +
+
+
+
+
+ ); +}; diff --git a/src/library-authoring/backup-restore/data/api.ts b/src/library-authoring/backup-restore/data/api.ts new file mode 100644 index 0000000000..af57e70d71 --- /dev/null +++ b/src/library-authoring/backup-restore/data/api.ts @@ -0,0 +1,16 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { CreateLibraryBackupResponse, GetLibraryBackupStatusResponse } from '@src/library-authoring/backup-restore/data/constants'; + +const getApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}/${path || ''}`; + +export const createLibraryBackup = async (libraryId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().post(getApiUrl(`api/libraries/v2/${libraryId}/backup/`)); + return data; +}; + +export const getLibraryBackupStatus = async (libraryId: string, taskId: string): +Promise => { + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`api/libraries/v2/${libraryId}/backup/?task_id=${taskId}`)); + return data; +}; diff --git a/src/library-authoring/backup-restore/data/constants.ts b/src/library-authoring/backup-restore/data/constants.ts new file mode 100644 index 0000000000..9c6309c173 --- /dev/null +++ b/src/library-authoring/backup-restore/data/constants.ts @@ -0,0 +1,17 @@ +export interface CreateLibraryBackupResponse { + task_id: string; +} + +export interface GetLibraryBackupStatusResponse { + state: LibraryBackupStatus; + url: string; +} + +export enum LibraryBackupStatus { + Pending = 'Pending', + Succeeded = 'Succeeded', + Exporting = 'Exporting', + Failed = 'Failed', +} + +export const LIBRARY_BACKUP_MUTATION_KEY = 'create-library-backup'; diff --git a/src/library-authoring/backup-restore/data/hooks.ts b/src/library-authoring/backup-restore/data/hooks.ts new file mode 100644 index 0000000000..45e1d16f7b --- /dev/null +++ b/src/library-authoring/backup-restore/data/hooks.ts @@ -0,0 +1,29 @@ +import { createLibraryBackup, getLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/api'; +import { GetLibraryBackupStatusResponse, LIBRARY_BACKUP_MUTATION_KEY } from '@src/library-authoring/backup-restore/data/constants'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +/** + * React Query hook to fetch backup status for a specific library and taskId + * the taskID is returned when creating a backup + * + * @param libraryId - The unique identifier of the library + * @param taskId - The unique identifier of the backup task + * + * @example + * ```tsx + * const { data, isLoading, isError } = useGetLibraryBackupStatus('lib:123', 'task:456abc'); + * ``` + */ +export const useGetLibraryBackupStatus = (libraryId: string, taskId: string) => useQuery({ + queryKey: ['library-backup-status', libraryId, taskId], + queryFn: () => getLibraryBackupStatus(libraryId, taskId), + enabled: !!taskId, // Only run the query if taskId is provided + refetchInterval: (data) => (data?.state === 'Pending' || data?.state === 'Exporting' ? 2000 : false), +}); + +export const useCreateLibraryBackup = (libraryId: string) => useMutation({ + mutationKey: [LIBRARY_BACKUP_MUTATION_KEY, libraryId], + mutationFn: () => createLibraryBackup(libraryId), + cacheTime: 60, // Cache for 1 minute to prevent rapid re-creation of backups +}); diff --git a/src/library-authoring/backup-restore/index.ts b/src/library-authoring/backup-restore/index.ts new file mode 100644 index 0000000000..16529fad50 --- /dev/null +++ b/src/library-authoring/backup-restore/index.ts @@ -0,0 +1 @@ +export { LibraryBackupPage } from './LibraryBackupPage'; diff --git a/src/library-authoring/backup-restore/messages.ts b/src/library-authoring/backup-restore/messages.ts new file mode 100644 index 0000000000..9d623021eb --- /dev/null +++ b/src/library-authoring/backup-restore/messages.ts @@ -0,0 +1,66 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + backupPageTitle: { + id: 'course-authoring.library-authoring.backup-page.title', + defaultMessage: 'Library Backup', + description: 'Title for the library backup page', + }, + backupPageSubtitle: { + id: 'course-authoring.library-authoring.backup-page.subtitle', + defaultMessage: 'Tools', + description: 'Subtitle for the library backup page', + }, + backupFailedError: { + id: 'course-authoring.library-authoring.backup-page.error.backup-failed', + defaultMessage: 'There was an error creating the backup. Please try again later.', + description: 'Error message when backup creation fails', + }, + mutationError: { + id: 'course-authoring.library-authoring.backup-page.error.mutation-failed', + defaultMessage: 'Failed to start backup: {error}', + description: 'Error message when backup mutation fails', + }, + backupPending: { + id: 'course-authoring.library-authoring.backup-page.status.pending', + defaultMessage: 'Preparing to download...', + description: 'Message shown when backup is in pending state', + }, + backupExporting: { + id: 'course-authoring.library-authoring.backup-page.status.exporting', + defaultMessage: 'Your backup is being exported...', + description: 'Message shown when backup is being exported', + }, + backupDescription: { + id: 'course-authoring.library-authoring.backup-page.description', + defaultMessage: 'Local backups are stored on your machine and are not automatically synced. They will not contain any edit history. You can restore a local backup as a new library on this or another learning site. Anyone who can access the local backup file can view all its content.', + description: 'Description of what library backups are and how they work', + }, + createBackupButton: { + id: 'course-authoring.library-authoring.backup-page.button.create', + defaultMessage: 'INITIAL Download Library Backup', + description: 'Button text to create and download a new backup', + }, + downloadReadyButton: { + id: 'course-authoring.library-authoring.backup-page.button.download-ready', + defaultMessage: 'DIFFERENT Download Library Backup', + description: 'Button text when backup is ready for download', + }, + creatingBackupButton: { + id: 'course-authoring.library-authoring.backup-page.button.creating', + defaultMessage: 'Creating Backup...', + description: 'Button text when backup is being created', + }, + exportingBackupButton: { + id: 'course-authoring.library-authoring.backup-page.button.exporting', + defaultMessage: 'Exporting...', + description: 'Button text when backup is being exported', + }, + downloadAriaLabel: { + id: 'course-authoring.library-authoring.backup-page.button.aria-label', + defaultMessage: '{buttonText} for {libraryTitle}', + description: 'Aria label for the download button', + }, +}); + +export default messages; diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index a65781c297..82c1813298 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -40,6 +40,8 @@ export const ROUTES = { // LibraryUnitPage route: // * with a selected containerId and/or an optionally selected componentId. UNIT: '/unit/:containerId/:selectedItemId?', + // LibraryBackupPage route: + BACKUP: '/backup', }; export enum ContentType { From 16c65b4d86a4578600aaf0500a9f1213236ddf44 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 9 Oct 2025 16:01:20 -0600 Subject: [PATCH 02/24] chore: updated paths and cleanup --- src/header/messages.js | 7 +------ .../backup-restore/LibraryBackupPage.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/header/messages.js b/src/header/messages.js index 19690721e4..b755b9dc5d 100644 --- a/src/header/messages.js +++ b/src/header/messages.js @@ -96,11 +96,6 @@ const messages = defineMessages({ defaultMessage: 'Import', description: 'Link to Studio Import page', }, - 'header.links.importLibrary': { - id: 'header.links.importLibrary', - defaultMessage: 'Import', - description: 'Link to Studio Import Library page', - }, 'header.links.exportCourse': { id: 'header.links.exportCourse', defaultMessage: 'Export Course', @@ -109,7 +104,7 @@ const messages = defineMessages({ 'header.links.exportLibrary': { id: 'header.links.exportLibrary', defaultMessage: 'Backup to local archive', - description: 'Link to Studio Export Library page', + description: 'Link to Studio Backup Library page', }, 'header.links.optimizer': { id: 'header.links.optimizer', diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.tsx index 2c8fbdb588..4de8dc6a6f 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.tsx @@ -17,11 +17,11 @@ import { Download, Loop, Newsstand } from '@openedx/paragon/icons'; import SubHeader from '@src/generic/sub-header/SubHeader'; import { LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants'; import { useCreateLibraryBackup, useGetLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/hooks'; -import NotFoundAlert from '../../generic/NotFoundAlert'; -import Header from '../../header'; -import { useLibraryContext } from '../common/context/LibraryContext'; -import { useContentLibrary } from '../data/apiHooks'; -import messages from './messages'; +import NotFoundAlert from '@src/generic/NotFoundAlert'; +import Header from '@src/header'; +import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext'; +import { useContentLibrary } from '@src/library-authoring/data/apiHooks'; +import messages from '@src/library-authoring/backup-restore/messages'; export const LibraryBackupPage = () => { const intl = useIntl(); From f051ec977063c1c9278e94573cf1d1b31a23a004 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 13 Oct 2025 09:51:32 -0600 Subject: [PATCH 03/24] chore: cleanup text --- src/library-authoring/backup-restore/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/backup-restore/messages.ts b/src/library-authoring/backup-restore/messages.ts index 9d623021eb..99b24a68d1 100644 --- a/src/library-authoring/backup-restore/messages.ts +++ b/src/library-authoring/backup-restore/messages.ts @@ -38,12 +38,12 @@ const messages = defineMessages({ }, createBackupButton: { id: 'course-authoring.library-authoring.backup-page.button.create', - defaultMessage: 'INITIAL Download Library Backup', + defaultMessage: 'Download Library Backup', description: 'Button text to create and download a new backup', }, downloadReadyButton: { id: 'course-authoring.library-authoring.backup-page.button.download-ready', - defaultMessage: 'DIFFERENT Download Library Backup', + defaultMessage: 'Download Library Backup', description: 'Button text when backup is ready for download', }, creatingBackupButton: { From 70aefaa4bab8db6f37817a65daacf1c49f6e9923 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Mon, 13 Oct 2025 09:51:42 -0600 Subject: [PATCH 04/24] chore: added test --- .../backup-restore/LibraryBackupPage.test.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/library-authoring/backup-restore/LibraryBackupPage.test.tsx diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx new file mode 100644 index 0000000000..d77e578d2f --- /dev/null +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -0,0 +1,75 @@ +import { + initializeMocks, + render, + screen, + fireEvent, +} from '@src/testUtils'; +import { LibraryBackupPage } from './LibraryBackupPage'; + +// Mock the hooks/context used by the page so we can render it in isolation. +jest.mock('@src/library-authoring/common/context/LibraryContext', () => ({ + useLibraryContext: () => ({ libraryId: 'lib:TestOrg:test-lib' }), +})); + +jest.mock('@src/library-authoring/data/apiHooks', () => ({ + useContentLibrary: () => ({ + data: { + title: 'My Test Library', + slug: 'test-lib', + org: 'TestOrg', + }, + }), +})); + +// Mutable mocks varied per test +const mockMutate = jest.fn(); +let mockStatusData: any = {}; +jest.mock('@src/library-authoring/backup-restore/data/hooks', () => ({ + useCreateLibraryBackup: () => ({ + mutate: mockMutate, + error: null, + }), + useGetLibraryBackupStatus: () => ({ + data: mockStatusData, + }), +})); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + mockMutate.mockReset(); + mockStatusData = {}; // reset status for each test + }); + + it('renders the backup page title and initial download button', () => { + mockStatusData = {}; // no state yet + render(); + expect(screen.getByText('Library Backup')).toBeVisible(); + const button = screen.getByRole('button', { name: /Download Library Backup/ }); + expect(button).toBeEnabled(); + // aria-label includes library title via template + expect(button).toHaveAttribute('aria-label', expect.stringContaining('My Test Library')); + }); + + it('shows pending state disables button after starting backup', async () => { + mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + mockStatusData = { state: 'Pending' }; + }); + render(); + // More specific matcher for the initial state (Download Library Backup) + const initialButton = screen.getByRole('button', { name: /Download Library Backup/i }); + expect(initialButton).toBeEnabled(); + fireEvent.click(initialButton); + const pendingButton = await screen.findByRole('button', { name: /Preparing to download/i }); + expect(pendingButton).toBeDisabled(); + }); + + it('shows succeeded state uses ready text', () => { + mockStatusData = { state: 'Succeeded', url: '/fake/path.tar.gz' }; + render(); + const button = screen.getByRole('button'); + // When succeeded the text should be the download ready variant + expect(button).toHaveTextContent(/Download Library Backup/); // substring still present + }); +}); From 834753802bd8a9264c3fb47c11956af50276aa09 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Tue, 14 Oct 2025 11:23:38 -0600 Subject: [PATCH 05/24] chore: fix contracts after rebase --- src/library-authoring/backup-restore/data/hooks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/backup-restore/data/hooks.ts b/src/library-authoring/backup-restore/data/hooks.ts index 45e1d16f7b..7cbfd87896 100644 --- a/src/library-authoring/backup-restore/data/hooks.ts +++ b/src/library-authoring/backup-restore/data/hooks.ts @@ -1,5 +1,5 @@ import { createLibraryBackup, getLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/api'; -import { GetLibraryBackupStatusResponse, LIBRARY_BACKUP_MUTATION_KEY } from '@src/library-authoring/backup-restore/data/constants'; +import { GetLibraryBackupStatusResponse, LIBRARY_BACKUP_MUTATION_KEY, LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants'; import { useMutation, useQuery } from '@tanstack/react-query'; /** @@ -19,11 +19,12 @@ Error>({ queryKey: ['library-backup-status', libraryId, taskId], queryFn: () => getLibraryBackupStatus(libraryId, taskId), enabled: !!taskId, // Only run the query if taskId is provided - refetchInterval: (data) => (data?.state === 'Pending' || data?.state === 'Exporting' ? 2000 : false), + refetchInterval: (query) => (query.state.data?.state === LibraryBackupStatus.Pending + || query.state.data?.state === LibraryBackupStatus.Exporting ? 2000 : false), }); export const useCreateLibraryBackup = (libraryId: string) => useMutation({ mutationKey: [LIBRARY_BACKUP_MUTATION_KEY, libraryId], mutationFn: () => createLibraryBackup(libraryId), - cacheTime: 60, // Cache for 1 minute to prevent rapid re-creation of backups + gcTime: 60, // Cache for 1 minute to prevent rapid re-creation of backups }); From f69e8757cd2938863df992784f62dc8a840ed592 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Tue, 14 Oct 2025 15:45:07 -0600 Subject: [PATCH 06/24] chore: more tests to improve coverage --- .../backup-restore/data/api.test.ts | 31 ++++++++++ .../backup-restore/data/hooks.test.tsx | 61 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/library-authoring/backup-restore/data/api.test.ts create mode 100644 src/library-authoring/backup-restore/data/hooks.test.tsx diff --git a/src/library-authoring/backup-restore/data/api.test.ts b/src/library-authoring/backup-restore/data/api.test.ts new file mode 100644 index 0000000000..87617ad534 --- /dev/null +++ b/src/library-authoring/backup-restore/data/api.test.ts @@ -0,0 +1,31 @@ +import { createLibraryBackup, getLibraryBackupStatus } from './api'; + +// Internal helper for URL construction +const getApiUrl = (path) => `http://studio.test/${path || ''}`; + +describe('backup-restore api', () => { + it('should call createLibraryBackup and return a promise', async () => { + await expect(createLibraryBackup('test-library-id')).rejects.toBeDefined(); + }); + + it('should call getLibraryBackupStatus and return a promise', async () => { + await expect(getLibraryBackupStatus('test-library-id', 'test-task-id')).rejects.toBeDefined(); + }); + + it('should throw if libraryId is missing for createLibraryBackup', async () => { + // @ts-expect-error + await expect(createLibraryBackup()).rejects.toBeDefined(); + }); + + it('should throw if libraryId or taskId is missing for getLibraryBackupStatus', async () => { + // @ts-expect-error + await expect(getLibraryBackupStatus()).rejects.toBeDefined(); + // @ts-expect-error + await expect(getLibraryBackupStatus('test-library-id')).rejects.toBeDefined(); + }); + + it('should build correct URL for backup', () => { + expect(getApiUrl('api/libraries/v2/abc/backup/')).toBe('http://studio.test/api/libraries/v2/abc/backup/'); + expect(getApiUrl('')).toBe('http://studio.test/'); + }); +}); diff --git a/src/library-authoring/backup-restore/data/hooks.test.tsx b/src/library-authoring/backup-restore/data/hooks.test.tsx new file mode 100644 index 0000000000..92a475db83 --- /dev/null +++ b/src/library-authoring/backup-restore/data/hooks.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { initializeMocks } from '@src/testUtils'; +import { LibraryBackupStatus } from './constants'; +import { useGetLibraryBackupStatus, useCreateLibraryBackup } from './hooks'; + +// Mock API functions +jest.mock('@src/library-authoring/backup-restore/data/api', () => ({ + createLibraryBackup: jest.fn(async () => ({ task_id: 'task-abc' })), + getLibraryBackupStatus: jest.fn(async (_libraryId: string, _taskId: string) => ({ + state: LibraryBackupStatus.Pending, + url: '', + })), +})); + +const { createLibraryBackup, getLibraryBackupStatus } = jest.requireMock('@src/library-authoring/backup-restore/data/api'); + +describe('backup-restore hooks', () => { + const libraryId = 'lib:Org:example'; + + const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function namingToMakeEslintHappy({ children }: { children: React.ReactNode }) { + return {children}; + }; + }; + + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + it('useGetLibraryBackupStatus does not fetch when taskId is empty', async () => { + const wrapper = createWrapper(); + renderHook(() => useGetLibraryBackupStatus(libraryId, ''), { wrapper }); + // Allow microtasks to flush to prevent false positives + await new Promise(res => setTimeout(res, 0)); + expect(getLibraryBackupStatus).not.toHaveBeenCalled(); + }); + + it('useGetLibraryBackupStatus fetches when taskId provided and sets data to Pending', async () => { + const wrapper = createWrapper(); + const taskId = 'task-123'; + const { result } = renderHook(() => useGetLibraryBackupStatus(libraryId, taskId), { wrapper }); + await waitFor(() => { + expect(getLibraryBackupStatus).toHaveBeenCalledWith(libraryId, taskId); + expect(result.current.data).toBeDefined(); + }); + expect(result.current.data?.state).toBe(LibraryBackupStatus.Pending); + }); + + it('useCreateLibraryBackup mutation returns task id', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useCreateLibraryBackup(libraryId), { wrapper }); + await result.current.mutateAsync(); + expect(createLibraryBackup).toHaveBeenCalledWith(libraryId); + }); +}); From 2c3f5c7872b49249b79052d4e10dff2b60179ea0 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 10:16:44 -0600 Subject: [PATCH 07/24] chore: more test for coverage --- .../backup-restore/LibraryBackupPage.test.tsx | 126 +++++++++++++++--- 1 file changed, 105 insertions(+), 21 deletions(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index d77e578d2f..ec16297632 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -1,26 +1,31 @@ import { + fireEvent, initializeMocks, render, screen, - fireEvent, } from '@src/testUtils'; +import { LibraryBackupStatus } from './data/constants'; import { LibraryBackupPage } from './LibraryBackupPage'; +import messages from './messages'; // Mock the hooks/context used by the page so we can render it in isolation. jest.mock('@src/library-authoring/common/context/LibraryContext', () => ({ useLibraryContext: () => ({ libraryId: 'lib:TestOrg:test-lib' }), })); -jest.mock('@src/library-authoring/data/apiHooks', () => ({ - useContentLibrary: () => ({ - data: { - title: 'My Test Library', - slug: 'test-lib', - org: 'TestOrg', - }, +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, }), })); +const mockLibraryData: { data: any } = { data: {} }; + +jest.mock('@src/library-authoring/data/apiHooks', () => ({ + useContentLibrary: () => (mockLibraryData), +})); + // Mutable mocks varied per test const mockMutate = jest.fn(); let mockStatusData: any = {}; @@ -38,38 +43,117 @@ describe('', () => { beforeEach(() => { initializeMocks(); mockMutate.mockReset(); - mockStatusData = {}; // reset status for each test + mockStatusData = {}; + mockLibraryData.data = { + title: 'My Test Library', + slug: 'test-lib', + org: 'TestOrg', + }; + }); + + it('returns NotFoundAlert if no libraryData', () => { + mockLibraryData.data = undefined; + + render(); + + expect(screen.getByText(/Not Found/i)).toBeVisible(); }); it('renders the backup page title and initial download button', () => { - mockStatusData = {}; // no state yet + mockStatusData = {}; render(); - expect(screen.getByText('Library Backup')).toBeVisible(); - const button = screen.getByRole('button', { name: /Download Library Backup/ }); + expect(screen.getByText(messages.backupPageTitle.defaultMessage)).toBeVisible(); + const button = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); expect(button).toBeEnabled(); - // aria-label includes library title via template - expect(button).toHaveAttribute('aria-label', expect.stringContaining('My Test Library')); }); it('shows pending state disables button after starting backup', async () => { mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { onSuccess({ task_id: 'task-123' }); - mockStatusData = { state: 'Pending' }; + mockStatusData = { state: LibraryBackupStatus.Pending }; }); render(); - // More specific matcher for the initial state (Download Library Backup) - const initialButton = screen.getByRole('button', { name: /Download Library Backup/i }); + const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); expect(initialButton).toBeEnabled(); fireEvent.click(initialButton); - const pendingButton = await screen.findByRole('button', { name: /Preparing to download/i }); + const pendingText = await screen.findByText(messages.backupPending.defaultMessage); + const pendingButton = pendingText.closest('button'); expect(pendingButton).toBeDisabled(); }); - it('shows succeeded state uses ready text', () => { + it('shows exporting state disables button and changes text', async () => { + mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + mockStatusData = { state: LibraryBackupStatus.Exporting }; + }); + render(); + const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); + fireEvent.click(initialButton); + const exportingText = await screen.findByText(messages.backupExporting.defaultMessage); + const exportingButton = exportingText.closest('button'); + expect(exportingButton).toBeDisabled(); + }); + + it('shows succeeded state uses ready text and triggers download', () => { mockStatusData = { state: 'Succeeded', url: '/fake/path.tar.gz' }; + const downloadSpy = jest.spyOn(document, 'createElement'); + render(); + const button = screen.getByRole('button'); + expect(button).toHaveTextContent(/Download Library Backup/); + fireEvent.click(button); + expect(downloadSpy).toHaveBeenCalledWith('a'); + downloadSpy.mockRestore(); + }); + + it('shows failed state and error alert', () => { + mockStatusData = { state: LibraryBackupStatus.Failed }; + render(); + expect(screen.getByText(messages.backupFailedError.defaultMessage)).toBeVisible(); + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + }); + + it('covers timeout cleanup on unmount', () => { + mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + mockStatusData = { state: LibraryBackupStatus.Pending }; + }); + const { unmount } = render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + unmount(); + // No assertion needed, just coverage for cleanup + }); + + it('covers fallback download logic', () => { + mockStatusData = { state: LibraryBackupStatus.Succeeded, url: '/fake/path.tar.gz' }; + // Spy on createElement to force click failure for anchor + const originalCreate = document.createElement.bind(document); + const createSpy = jest.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const el = originalCreate(tagName); + if (tagName === 'a') { + // Force failure when click is invoked + (el as any).click = () => { throw new Error('fail'); }; + } + return el; + }); + // Stub window.location.href writable + const originalLocation = window.location; + // Use a minimal fake location object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + delete window.location; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.location = { href: '' }; render(); const button = screen.getByRole('button'); - // When succeeded the text should be the download ready variant - expect(button).toHaveTextContent(/Download Library Backup/); // substring still present + fireEvent.click(button); + expect(window.location.href).toContain('/fake/path.tar.gz'); + // restore + createSpy.mockRestore(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.location = originalLocation; }); }); From 33a7d754cde301caa2951ea90911c336c7dab939 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 10:29:07 -0600 Subject: [PATCH 08/24] chore: more test for coverage --- .../backup-restore/LibraryBackupPage.test.tsx | 25 +++++++++++++- .../backup-restore/data/api.test.ts | 34 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index ec16297632..2d308fc7eb 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -4,6 +4,7 @@ import { render, screen, } from '@src/testUtils'; +import { act } from '@testing-library/react'; import { LibraryBackupStatus } from './data/constants'; import { LibraryBackupPage } from './LibraryBackupPage'; import messages from './messages'; @@ -29,10 +30,11 @@ jest.mock('@src/library-authoring/data/apiHooks', () => ({ // Mutable mocks varied per test const mockMutate = jest.fn(); let mockStatusData: any = {}; +let mockMutationError: any = null; // allows testing mutation error branch jest.mock('@src/library-authoring/backup-restore/data/hooks', () => ({ useCreateLibraryBackup: () => ({ mutate: mockMutate, - error: null, + error: mockMutationError, }), useGetLibraryBackupStatus: () => ({ data: mockStatusData, @@ -49,6 +51,7 @@ describe('', () => { slug: 'test-lib', org: 'TestOrg', }; + mockMutationError = null; }); it('returns NotFoundAlert if no libraryData', () => { @@ -156,4 +159,24 @@ describe('', () => { // @ts-ignore window.location = originalLocation; }); + + it('executes timeout callback clearing task and re-enabling button after 5 minutes', () => { + jest.useFakeTimers(); + mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + mockStatusData = { state: LibraryBackupStatus.Pending }; + }); + render(); + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + fireEvent.click(button); + // Now in progress + expect(button).toBeDisabled(); + act(() => { + jest.advanceTimersByTime(5 * 60 * 1000); // advance 5 minutes + }); + // After timeout callback, should be enabled again + expect(button).toBeEnabled(); + jest.useRealTimers(); + }); }); diff --git a/src/library-authoring/backup-restore/data/api.test.ts b/src/library-authoring/backup-restore/data/api.test.ts index 87617ad534..72aacdb748 100644 --- a/src/library-authoring/backup-restore/data/api.test.ts +++ b/src/library-authoring/backup-restore/data/api.test.ts @@ -1,5 +1,23 @@ +// We will load the actual platform module to mutate its exported config object without mocking getConfig itself. +// eslint-disable-next-line import/no-extraneous-dependencies import { createLibraryBackup, getLibraryBackupStatus } from './api'; +// Provide a controlled mock for the authenticated HTTP client without touching getConfig +const mockPost = jest.fn(); +const mockGet = jest.fn(); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: () => ({ + post: (...args) => mockPost(...args), + get: (...args) => mockGet(...args), + }), +})); + +afterEach(() => { + mockPost.mockReset(); + mockGet.mockReset(); +}); + // Internal helper for URL construction const getApiUrl = (path) => `http://studio.test/${path || ''}`; @@ -8,10 +26,26 @@ describe('backup-restore api', () => { await expect(createLibraryBackup('test-library-id')).rejects.toBeDefined(); }); + it('should build correct URL and call post for createLibraryBackup', async () => { + mockPost.mockResolvedValue({ data: { success: true } }); + const result = await createLibraryBackup('abc'); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPost.mock.calls[0][0]).toMatch(/\/api\/libraries\/v2\/abc\/backup\/$/); + expect(result).toEqual({ success: true }); + }); + it('should call getLibraryBackupStatus and return a promise', async () => { await expect(getLibraryBackupStatus('test-library-id', 'test-task-id')).rejects.toBeDefined(); }); + it('should build correct URL and call get for getLibraryBackupStatus', async () => { + mockGet.mockResolvedValue({ data: { status: 'ok' } }); + const result = await getLibraryBackupStatus('abc', 'task123'); + expect(mockGet).toHaveBeenCalledTimes(1); + expect(mockGet.mock.calls[0][0]).toMatch(/\/api\/libraries\/v2\/abc\/backup\/\?task_id=task123$/); + expect(result).toEqual({ status: 'ok' }); + }); + it('should throw if libraryId is missing for createLibraryBackup', async () => { // @ts-expect-error await expect(createLibraryBackup()).rejects.toBeDefined(); From bfd3a5dd8e7b20e5d4680a95c33b1473b6aef76b Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 10:33:33 -0600 Subject: [PATCH 09/24] chore: fixed lint issues --- src/library-authoring/backup-restore/data/hooks.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/library-authoring/backup-restore/data/hooks.test.tsx b/src/library-authoring/backup-restore/data/hooks.test.tsx index 92a475db83..713e354536 100644 --- a/src/library-authoring/backup-restore/data/hooks.test.tsx +++ b/src/library-authoring/backup-restore/data/hooks.test.tsx @@ -8,7 +8,7 @@ import { useGetLibraryBackupStatus, useCreateLibraryBackup } from './hooks'; // Mock API functions jest.mock('@src/library-authoring/backup-restore/data/api', () => ({ createLibraryBackup: jest.fn(async () => ({ task_id: 'task-abc' })), - getLibraryBackupStatus: jest.fn(async (_libraryId: string, _taskId: string) => ({ + getLibraryBackupStatus: jest.fn(async () => ({ state: LibraryBackupStatus.Pending, url: '', })), @@ -36,8 +36,6 @@ describe('backup-restore hooks', () => { it('useGetLibraryBackupStatus does not fetch when taskId is empty', async () => { const wrapper = createWrapper(); renderHook(() => useGetLibraryBackupStatus(libraryId, ''), { wrapper }); - // Allow microtasks to flush to prevent false positives - await new Promise(res => setTimeout(res, 0)); expect(getLibraryBackupStatus).not.toHaveBeenCalled(); }); From 541f2b1395a527b4dcaee79e39401d2d028aa5a1 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 11:16:36 -0600 Subject: [PATCH 10/24] chore: update naming for a more semantic one --- src/header/hooks.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/header/hooks.jsx b/src/header/hooks.jsx index 311375e578..f458412bd3 100644 --- a/src/header/hooks.jsx +++ b/src/header/hooks.jsx @@ -89,32 +89,32 @@ export const useSettingMenuItems = courseId => { return items; }; -export const useToolsMenuItems = (courseId, isLibrary = false) => { +export const useToolsMenuItems = (itemId, isLibrary = false) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const waffleFlags = useWaffleFlags(); const items = [ { - href: waffleFlags.useNewImportPage ? `/course/${courseId}/import` : `${studioBaseUrl}/import/${courseId}`, + href: waffleFlags.useNewImportPage ? `/course/${itemId}/import` : `${studioBaseUrl}/import/${itemId}`, title: intl.formatMessage(messages['header.links.import']), }, { - href: waffleFlags.useNewExportPage ? `/course/${courseId}/export` : `${studioBaseUrl}/export/${courseId}`, + href: waffleFlags.useNewExportPage ? `/course/${itemId}/export` : `${studioBaseUrl}/export/${itemId}`, title: intl.formatMessage(messages['header.links.exportCourse']), }, ...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' ? [{ - href: `${studioBaseUrl}/course/${courseId}#export-tags`, + href: `${studioBaseUrl}/course/${itemId}#export-tags`, title: intl.formatMessage(messages['header.links.exportTags']), }] : [] ), { - href: `/course/${courseId}/checklists`, + href: `/course/${itemId}/checklists`, title: intl.formatMessage(messages['header.links.checklists']), }, ...(waffleFlags.enableCourseOptimizer ? [{ - href: `/course/${courseId}/optimizer`, + href: `/course/${itemId}/optimizer`, title: ( <> {intl.formatMessage(messages['header.links.optimizer'])} @@ -126,7 +126,7 @@ export const useToolsMenuItems = (courseId, isLibrary = false) => { const libraryItems = [ { - href: `/library/${courseId}/backup`, + href: `/library/${itemId}/backup`, title: intl.formatMessage(messages['header.links.exportLibrary']), }, ]; From ec963f04c16dfefd34424fd4be1ac0ecc05a28ca Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 11:32:43 -0600 Subject: [PATCH 11/24] chore: changed fireEvent to userEvent --- .../backup-restore/LibraryBackupPage.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index 2d308fc7eb..0681e073e4 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -1,9 +1,9 @@ import { - fireEvent, initializeMocks, render, screen, } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; import { act } from '@testing-library/react'; import { LibraryBackupStatus } from './data/constants'; import { LibraryBackupPage } from './LibraryBackupPage'; @@ -78,7 +78,7 @@ describe('', () => { render(); const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); expect(initialButton).toBeEnabled(); - fireEvent.click(initialButton); + await userEvent.click(initialButton); const pendingText = await screen.findByText(messages.backupPending.defaultMessage); const pendingButton = pendingText.closest('button'); expect(pendingButton).toBeDisabled(); @@ -91,7 +91,7 @@ describe('', () => { }); render(); const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); - fireEvent.click(initialButton); + await userEvent.click(initialButton); const exportingText = await screen.findByText(messages.backupExporting.defaultMessage); const exportingButton = exportingText.closest('button'); expect(exportingButton).toBeDisabled(); @@ -103,7 +103,7 @@ describe('', () => { render(); const button = screen.getByRole('button'); expect(button).toHaveTextContent(/Download Library Backup/); - fireEvent.click(button); + userEvent.click(button); expect(downloadSpy).toHaveBeenCalledWith('a'); downloadSpy.mockRestore(); }); @@ -123,7 +123,7 @@ describe('', () => { }); const { unmount } = render(); const button = screen.getByRole('button'); - fireEvent.click(button); + userEvent.click(button); unmount(); // No assertion needed, just coverage for cleanup }); @@ -151,7 +151,7 @@ describe('', () => { window.location = { href: '' }; render(); const button = screen.getByRole('button'); - fireEvent.click(button); + userEvent.click(button); expect(window.location.href).toContain('/fake/path.tar.gz'); // restore createSpy.mockRestore(); @@ -169,7 +169,7 @@ describe('', () => { render(); const button = screen.getByRole('button'); expect(button).toBeEnabled(); - fireEvent.click(button); + userEvent.click(button); // Now in progress expect(button).toBeDisabled(); act(() => { From 1f51ebf2e313d616adfc8fdc25878cf313c5ffb7 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 12:15:01 -0600 Subject: [PATCH 12/24] chore: improved queryKeys --- .../backup-restore/LibraryBackupPage.test.tsx | 22 +------------------ .../backup-restore/data/constants.ts | 7 +++++- .../backup-restore/data/hooks.ts | 6 ++--- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index 0681e073e4..f9015075f0 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -102,7 +102,7 @@ describe('', () => { const downloadSpy = jest.spyOn(document, 'createElement'); render(); const button = screen.getByRole('button'); - expect(button).toHaveTextContent(/Download Library Backup/); + expect(button).toHaveTextContent(messages.downloadReadyButton.defaultMessage); userEvent.click(button); expect(downloadSpy).toHaveBeenCalledWith('a'); downloadSpy.mockRestore(); @@ -159,24 +159,4 @@ describe('', () => { // @ts-ignore window.location = originalLocation; }); - - it('executes timeout callback clearing task and re-enabling button after 5 minutes', () => { - jest.useFakeTimers(); - mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { - onSuccess({ task_id: 'task-123' }); - mockStatusData = { state: LibraryBackupStatus.Pending }; - }); - render(); - const button = screen.getByRole('button'); - expect(button).toBeEnabled(); - userEvent.click(button); - // Now in progress - expect(button).toBeDisabled(); - act(() => { - jest.advanceTimersByTime(5 * 60 * 1000); // advance 5 minutes - }); - // After timeout callback, should be enabled again - expect(button).toBeEnabled(); - jest.useRealTimers(); - }); }); diff --git a/src/library-authoring/backup-restore/data/constants.ts b/src/library-authoring/backup-restore/data/constants.ts index 9c6309c173..9824d28fc7 100644 --- a/src/library-authoring/backup-restore/data/constants.ts +++ b/src/library-authoring/backup-restore/data/constants.ts @@ -14,4 +14,9 @@ export enum LibraryBackupStatus { Failed = 'Failed', } -export const LIBRARY_BACKUP_MUTATION_KEY = 'create-library-backup'; +export const libraryBackupQueryKeys = { + // TODO: add appId to follow new agreements once definitions are ready for queryKeys + all: ['library-v2-backup'], + backupStatus: (libraryId: string, taskId: string) => [...libraryBackupQueryKeys.all, 'status', libraryId, taskId], + backupMutation: (libraryId: string) => [...libraryBackupQueryKeys.all, 'create-backup', libraryId], +}; diff --git a/src/library-authoring/backup-restore/data/hooks.ts b/src/library-authoring/backup-restore/data/hooks.ts index 7cbfd87896..9f3b3d2554 100644 --- a/src/library-authoring/backup-restore/data/hooks.ts +++ b/src/library-authoring/backup-restore/data/hooks.ts @@ -1,5 +1,5 @@ import { createLibraryBackup, getLibraryBackupStatus } from '@src/library-authoring/backup-restore/data/api'; -import { GetLibraryBackupStatusResponse, LIBRARY_BACKUP_MUTATION_KEY, LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants'; +import { GetLibraryBackupStatusResponse, libraryBackupQueryKeys, LibraryBackupStatus } from '@src/library-authoring/backup-restore/data/constants'; import { useMutation, useQuery } from '@tanstack/react-query'; /** @@ -16,7 +16,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; */ export const useGetLibraryBackupStatus = (libraryId: string, taskId: string) => useQuery({ - queryKey: ['library-backup-status', libraryId, taskId], + queryKey: libraryBackupQueryKeys.backupStatus(libraryId, taskId), queryFn: () => getLibraryBackupStatus(libraryId, taskId), enabled: !!taskId, // Only run the query if taskId is provided refetchInterval: (query) => (query.state.data?.state === LibraryBackupStatus.Pending @@ -24,7 +24,7 @@ Error>({ }); export const useCreateLibraryBackup = (libraryId: string) => useMutation({ - mutationKey: [LIBRARY_BACKUP_MUTATION_KEY, libraryId], + mutationKey: libraryBackupQueryKeys.backupMutation(libraryId), mutationFn: () => createLibraryBackup(libraryId), gcTime: 60, // Cache for 1 minute to prevent rapid re-creation of backups }); From 4b4b93a646a3ab558fb90181fcb66d154155ea9f Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 12:21:04 -0600 Subject: [PATCH 13/24] chore: lint cleanup --- src/library-authoring/backup-restore/LibraryBackupPage.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index f9015075f0..e926cf15f8 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -4,7 +4,6 @@ import { screen, } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; -import { act } from '@testing-library/react'; import { LibraryBackupStatus } from './data/constants'; import { LibraryBackupPage } from './LibraryBackupPage'; import messages from './messages'; From 26ea5009ef7d14fbe47f7c01b38641d6ec56224d Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 16 Oct 2025 13:12:15 -0600 Subject: [PATCH 14/24] chore: changed tests and time to 1min --- .../backup-restore/LibraryBackupPage.test.tsx | 23 +++++++++++++++++++ .../backup-restore/LibraryBackupPage.tsx | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index e926cf15f8..a382b476e6 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -1,4 +1,5 @@ import { + act, initializeMocks, render, screen, @@ -158,4 +159,26 @@ describe('', () => { // @ts-ignore window.location = originalLocation; }); + + it('executes timeout callback clearing task and re-enabling button after 5 minutes', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + mockStatusData = { state: LibraryBackupStatus.Pending }; + }); + render(); + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + await user.click(button); + + // Now in progress + expect(button).toBeDisabled(); + act(() => { + jest.advanceTimersByTime(1 * 60 * 1000); // advance 1 minutes + }); + // After timeout callback, should be enabled again + expect(button).toBeEnabled(); + jest.useRealTimers(); + }); }); diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.tsx index 4de8dc6a6f..61d4a334cd 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.tsx @@ -70,12 +70,12 @@ export const LibraryBackupPage = () => { mutation.mutate(undefined, { onSuccess: (data) => { setTaskId(data.task_id); - // Clear task id after 5 minutes to allow new backups + // Clear task id after 1 minutes to allow new backups timeoutRef.current = setTimeout(() => { setTaskId(''); setIsMutationInProgress(false); timeoutRef.current = null; - }, 5 * 60 * 1000); + }, 60 * 1000); }, onError: () => { setIsMutationInProgress(false); From 7c4b6f41b03132341af7b0c49b353216bac0df77 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 09:23:15 -0600 Subject: [PATCH 15/24] chore: even more tests --- .../backup-restore/LibraryBackupPage.test.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index a382b476e6..e34fdbd33a 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -181,4 +181,65 @@ describe('', () => { expect(button).toBeEnabled(); jest.useRealTimers(); }); + + it('shows pending message when mutation is in progress but no backup state yet', async () => { + // Mock mutation to trigger onSuccess but don't immediately set backup state + mockMutate.mockImplementation((_arg: any, { onSuccess }: any) => { + onSuccess({ task_id: 'task-123' }); + // Don't set mockStatusData.state immediately to simulate the state + // before the status API has returned any backup state + }); + + render(); + const button = screen.getByRole('button'); + + await userEvent.click(button); + + // This should trigger the specific line: return intl.formatMessage(messages.backupPending); + // when isMutationInProgress is true but !backupState + expect(screen.getByText(messages.backupPending.defaultMessage)).toBeVisible(); + expect(button).toBeDisabled(); + }); + + it('downloads backup immediately when clicking button with already succeeded backup', async () => { + // Set up a scenario where backup is already succeeded with a URL + mockStatusData = { + state: LibraryBackupStatus.Succeeded, + url: '/api/libraries/v2/backup/download/test-backup.tar.gz', + }; + + render(); + + // Spy on handleDownload function call + const createElementSpy = jest.spyOn(document, 'createElement'); + const mockAnchor = { + href: '', + download: '', + click: jest.fn(), + }; + createElementSpy.mockReturnValue(mockAnchor as any); + const appendChildSpy = jest.spyOn(document.body, 'appendChild').mockImplementation(); + const removeChildSpy = jest.spyOn(document.body, 'removeChild').mockImplementation(); + + const button = screen.getByRole('button'); + + // Click the button - this should trigger the early return in handleDownloadBackup + await userEvent.click(button); + + // Verify the download was triggered + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(mockAnchor.href).toContain('/api/libraries/v2/backup/download/test-backup.tar.gz'); + expect(mockAnchor.download).toBe('test-lib-backup.tar.gz'); + expect(mockAnchor.click).toHaveBeenCalled(); + expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor); + expect(removeChildSpy).toHaveBeenCalledWith(mockAnchor); + + // Verify mutate was NOT called since backup already exists + expect(mockMutate).not.toHaveBeenCalled(); + + // Clean up spies + createElementSpy.mockRestore(); + appendChildSpy.mockRestore(); + removeChildSpy.mockRestore(); + }); }); From 15c9c15db011b83700b185f5a0c1c6e630869477 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 11:58:22 -0600 Subject: [PATCH 16/24] chore: split hook for library menu items --- src/header/Header.tsx | 11 +++++++---- src/header/hooks.jsx | 25 ++++++++++++++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/header/Header.tsx b/src/header/Header.tsx index fde2159502..eab1fada80 100644 --- a/src/header/Header.tsx +++ b/src/header/Header.tsx @@ -1,11 +1,13 @@ +import { StudioHeader } from '@edx/frontend-component-header'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { StudioHeader } from '@edx/frontend-component-header'; import { type Container, useToggle } from '@openedx/paragon'; import { useWaffleFlags } from '../data/apiHooks'; import { SearchModal } from '../search-modal'; -import { useContentMenuItems, useSettingMenuItems, useToolsMenuItems } from './hooks'; +import { + useContentMenuItems, useLibraryToolsMenuItems, useSettingMenuItems, useToolsMenuItems, +} from './hooks'; import messages from './messages'; type ContainerPropsType = React.ComponentProps; @@ -39,7 +41,8 @@ const Header = ({ const contentMenuItems = useContentMenuItems(contextId); const settingMenuItems = useSettingMenuItems(contextId); - const toolsMenuItems = useToolsMenuItems(contextId, isLibrary); + const toolsMenuItems = useToolsMenuItems(contextId); + const libraryToolsMenuItems = useLibraryToolsMenuItems(contextId); const mainMenuDropdowns = !isLibrary ? [ { id: `${intl.formatMessage(messages['header.links.content'])}-dropdown-menu`, @@ -59,7 +62,7 @@ const Header = ({ ] : [{ id: `${intl.formatMessage(messages['header.links.tools'])}-dropdown-menu`, buttonTitle: intl.formatMessage(messages['header.links.tools']), - items: toolsMenuItems, + items: libraryToolsMenuItems, }]; const getOutlineLink = () => { diff --git a/src/header/hooks.jsx b/src/header/hooks.jsx index f458412bd3..236498e413 100644 --- a/src/header/hooks.jsx +++ b/src/header/hooks.jsx @@ -89,32 +89,32 @@ export const useSettingMenuItems = courseId => { return items; }; -export const useToolsMenuItems = (itemId, isLibrary = false) => { +export const useToolsMenuItems = (courseId) => { const intl = useIntl(); const studioBaseUrl = getConfig().STUDIO_BASE_URL; const waffleFlags = useWaffleFlags(); const items = [ { - href: waffleFlags.useNewImportPage ? `/course/${itemId}/import` : `${studioBaseUrl}/import/${itemId}`, + href: waffleFlags.useNewImportPage ? `/course/${courseId}/import` : `${studioBaseUrl}/import/${courseId}`, title: intl.formatMessage(messages['header.links.import']), }, { - href: waffleFlags.useNewExportPage ? `/course/${itemId}/export` : `${studioBaseUrl}/export/${itemId}`, + href: waffleFlags.useNewExportPage ? `/course/${courseId}/export` : `${studioBaseUrl}/export/${courseId}`, title: intl.formatMessage(messages['header.links.exportCourse']), }, ...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' ? [{ - href: `${studioBaseUrl}/course/${itemId}#export-tags`, + href: `${studioBaseUrl}/course/${courseId}#export-tags`, title: intl.formatMessage(messages['header.links.exportTags']), }] : [] ), { - href: `/course/${itemId}/checklists`, + href: `/course/${courseId}/checklists`, title: intl.formatMessage(messages['header.links.checklists']), }, ...(waffleFlags.enableCourseOptimizer ? [{ - href: `/course/${itemId}/optimizer`, + href: `/course/${courseId}/optimizer`, title: ( <> {intl.formatMessage(messages['header.links.optimizer'])} @@ -124,12 +124,19 @@ export const useToolsMenuItems = (itemId, isLibrary = false) => { }] : []), ]; - const libraryItems = [ + return items; +}; + +export const useLibraryToolsMenuItems = itemId => { + const intl = useIntl(); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + + const items = [ { - href: `/library/${itemId}/backup`, + href: `${studioBaseUrl}/library/${itemId}/backup`, title: intl.formatMessage(messages['header.links.exportLibrary']), }, ]; - return isLibrary ? libraryItems : items; + return items; }; From ded03af0af098347e09c3c985b9c712562518b5c Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 13:03:11 -0600 Subject: [PATCH 17/24] chore: fixed typo on refactor --- src/header/hooks.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/header/hooks.jsx b/src/header/hooks.jsx index 236498e413..80389e3a72 100644 --- a/src/header/hooks.jsx +++ b/src/header/hooks.jsx @@ -129,11 +129,10 @@ export const useToolsMenuItems = (courseId) => { export const useLibraryToolsMenuItems = itemId => { const intl = useIntl(); - const studioBaseUrl = getConfig().STUDIO_BASE_URL; const items = [ { - href: `${studioBaseUrl}/library/${itemId}/backup`, + href: `/library/${itemId}/backup`, title: intl.formatMessage(messages['header.links.exportLibrary']), }, ]; From dd2674c724d28bb898fe298965c5576587b43325 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 13:14:52 -0600 Subject: [PATCH 18/24] chore: improved test to use available mocks --- .../backup-restore/data/api.test.ts | 51 +++++++------------ 1 file changed, 19 insertions(+), 32 deletions(-) diff --git a/src/library-authoring/backup-restore/data/api.test.ts b/src/library-authoring/backup-restore/data/api.test.ts index 72aacdb748..18f895f2d8 100644 --- a/src/library-authoring/backup-restore/data/api.test.ts +++ b/src/library-authoring/backup-restore/data/api.test.ts @@ -1,37 +1,29 @@ -// We will load the actual platform module to mutate its exported config object without mocking getConfig itself. // eslint-disable-next-line import/no-extraneous-dependencies +import { getLibraryBackupApiUrl, getLibraryBackupStatusApiUrl } from '@src/library-authoring/data/api'; +import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; +import { initializeMocks } from '@src/testUtils'; import { createLibraryBackup, getLibraryBackupStatus } from './api'; -// Provide a controlled mock for the authenticated HTTP client without touching getConfig -const mockPost = jest.fn(); -const mockGet = jest.fn(); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: () => ({ - post: (...args) => mockPost(...args), - get: (...args) => mockGet(...args), - }), -})); +mockContentLibrary.applyMock(); +const { axiosMock } = initializeMocks(); afterEach(() => { - mockPost.mockReset(); - mockGet.mockReset(); + axiosMock.reset(); }); -// Internal helper for URL construction -const getApiUrl = (path) => `http://studio.test/${path || ''}`; - describe('backup-restore api', () => { it('should call createLibraryBackup and return a promise', async () => { - await expect(createLibraryBackup('test-library-id')).rejects.toBeDefined(); + await expect(createLibraryBackup(mockContentLibrary.libraryId)).rejects.toBeDefined(); }); it('should build correct URL and call post for createLibraryBackup', async () => { - mockPost.mockResolvedValue({ data: { success: true } }); - const result = await createLibraryBackup('abc'); - expect(mockPost).toHaveBeenCalledTimes(1); - expect(mockPost.mock.calls[0][0]).toMatch(/\/api\/libraries\/v2\/abc\/backup\/$/); - expect(result).toEqual({ success: true }); + const libraryUrl = getLibraryBackupApiUrl(mockContentLibrary.libraryId); + axiosMock.onPost(libraryUrl).reply(200, { success: true, taskId: 'task123' }); + const result = await createLibraryBackup(mockContentLibrary.libraryId); + + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].url).toMatch(libraryUrl); + expect(result).toEqual({ success: true, taskId: 'task123' }); }); it('should call getLibraryBackupStatus and return a promise', async () => { @@ -39,10 +31,10 @@ describe('backup-restore api', () => { }); it('should build correct URL and call get for getLibraryBackupStatus', async () => { - mockGet.mockResolvedValue({ data: { status: 'ok' } }); - const result = await getLibraryBackupStatus('abc', 'task123'); - expect(mockGet).toHaveBeenCalledTimes(1); - expect(mockGet.mock.calls[0][0]).toMatch(/\/api\/libraries\/v2\/abc\/backup\/\?task_id=task123$/); + axiosMock.onGet().reply(200, { status: 'ok' }); + const result = await getLibraryBackupStatus(mockContentLibrary.libraryId, 'task123'); + expect(axiosMock.history.get.length).toBe(1); + expect(axiosMock.history.get[0].url).toMatch(getLibraryBackupStatusApiUrl(mockContentLibrary.libraryId, 'task123')); expect(result).toEqual({ status: 'ok' }); }); @@ -55,11 +47,6 @@ describe('backup-restore api', () => { // @ts-expect-error await expect(getLibraryBackupStatus()).rejects.toBeDefined(); // @ts-expect-error - await expect(getLibraryBackupStatus('test-library-id')).rejects.toBeDefined(); - }); - - it('should build correct URL for backup', () => { - expect(getApiUrl('api/libraries/v2/abc/backup/')).toBe('http://studio.test/api/libraries/v2/abc/backup/'); - expect(getApiUrl('')).toBe('http://studio.test/'); + await expect(getLibraryBackupStatus(mockContentLibrary.libraryId)).rejects.toBeDefined(); }); }); From 269d0f71b02bf1fefeab4bc2fbf3c92ac646eee0 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 13:20:08 -0600 Subject: [PATCH 19/24] chore: change from jest.mocks to spyon --- .../backup-restore/data/hooks.test.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/library-authoring/backup-restore/data/hooks.test.tsx b/src/library-authoring/backup-restore/data/hooks.test.tsx index 713e354536..ddde9bf7cf 100644 --- a/src/library-authoring/backup-restore/data/hooks.test.tsx +++ b/src/library-authoring/backup-restore/data/hooks.test.tsx @@ -1,23 +1,14 @@ -import React from 'react'; +import { initializeMocks, renderHook, waitFor } from '@src/testUtils'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; -import { initializeMocks } from '@src/testUtils'; +import React from 'react'; +import * as api from '@src/library-authoring/backup-restore/data/api'; import { LibraryBackupStatus } from './constants'; -import { useGetLibraryBackupStatus, useCreateLibraryBackup } from './hooks'; - -// Mock API functions -jest.mock('@src/library-authoring/backup-restore/data/api', () => ({ - createLibraryBackup: jest.fn(async () => ({ task_id: 'task-abc' })), - getLibraryBackupStatus: jest.fn(async () => ({ - state: LibraryBackupStatus.Pending, - url: '', - })), -})); - -const { createLibraryBackup, getLibraryBackupStatus } = jest.requireMock('@src/library-authoring/backup-restore/data/api'); +import { useCreateLibraryBackup, useGetLibraryBackupStatus } from './hooks'; describe('backup-restore hooks', () => { const libraryId = 'lib:Org:example'; + let createLibraryBackupSpy: jest.SpyInstance; + let getLibraryBackupStatusSpy: jest.SpyInstance; const createWrapper = () => { const queryClient = new QueryClient({ @@ -30,13 +21,22 @@ describe('backup-restore hooks', () => { beforeEach(() => { initializeMocks(); - jest.clearAllMocks(); + createLibraryBackupSpy = jest.spyOn(api, 'createLibraryBackup').mockImplementation(async () => ({ task_id: 'task-abc' })); + getLibraryBackupStatusSpy = jest.spyOn(api, 'getLibraryBackupStatus').mockImplementation(async () => ({ + state: LibraryBackupStatus.Pending, + url: '', + })); + }); + + afterEach(() => { + createLibraryBackupSpy.mockRestore(); + getLibraryBackupStatusSpy.mockRestore(); }); it('useGetLibraryBackupStatus does not fetch when taskId is empty', async () => { const wrapper = createWrapper(); renderHook(() => useGetLibraryBackupStatus(libraryId, ''), { wrapper }); - expect(getLibraryBackupStatus).not.toHaveBeenCalled(); + expect(getLibraryBackupStatusSpy).not.toHaveBeenCalled(); }); it('useGetLibraryBackupStatus fetches when taskId provided and sets data to Pending', async () => { @@ -44,7 +44,7 @@ describe('backup-restore hooks', () => { const taskId = 'task-123'; const { result } = renderHook(() => useGetLibraryBackupStatus(libraryId, taskId), { wrapper }); await waitFor(() => { - expect(getLibraryBackupStatus).toHaveBeenCalledWith(libraryId, taskId); + expect(getLibraryBackupStatusSpy).toHaveBeenCalledWith(libraryId, taskId); expect(result.current.data).toBeDefined(); }); expect(result.current.data?.state).toBe(LibraryBackupStatus.Pending); @@ -54,6 +54,6 @@ describe('backup-restore hooks', () => { const wrapper = createWrapper(); const { result } = renderHook(() => useCreateLibraryBackup(libraryId), { wrapper }); await result.current.mutateAsync(); - expect(createLibraryBackup).toHaveBeenCalledWith(libraryId); + expect(createLibraryBackupSpy).toHaveBeenCalledWith(libraryId); }); }); From 76ae11b5b9270e5d5f19c0710e2566f0c9e5196a Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 13:21:23 -0600 Subject: [PATCH 20/24] chore: update test based on commets --- .../backup-restore/LibraryBackupPage.test.tsx | 50 ++++++++----------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index e34fdbd33a..f97181a5be 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -1,7 +1,9 @@ +import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext'; +import { mockContentLibrary } from '@src/library-authoring/data/api.mocks'; import { act, + render as baseRender, initializeMocks, - render, screen, } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; @@ -9,10 +11,13 @@ import { LibraryBackupStatus } from './data/constants'; import { LibraryBackupPage } from './LibraryBackupPage'; import messages from './messages'; -// Mock the hooks/context used by the page so we can render it in isolation. -jest.mock('@src/library-authoring/common/context/LibraryContext', () => ({ - useLibraryContext: () => ({ libraryId: 'lib:TestOrg:test-lib' }), -})); +mockContentLibrary.applyMock(); +const mockLibraryId = mockContentLibrary.libraryId; +const render = (libraryId: string = mockLibraryId) => baseRender(, { + extraWrapper: ({ children }) => ( + {children} + ), +}); jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), @@ -21,12 +26,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ }), })); -const mockLibraryData: { data: any } = { data: {} }; - -jest.mock('@src/library-authoring/data/apiHooks', () => ({ - useContentLibrary: () => (mockLibraryData), -})); - // Mutable mocks varied per test const mockMutate = jest.fn(); let mockStatusData: any = {}; @@ -46,25 +45,20 @@ describe('', () => { initializeMocks(); mockMutate.mockReset(); mockStatusData = {}; - mockLibraryData.data = { - title: 'My Test Library', - slug: 'test-lib', - org: 'TestOrg', - }; mockMutationError = null; }); it('returns NotFoundAlert if no libraryData', () => { - mockLibraryData.data = undefined; + mockContentLibrary.libraryData = null as any; - render(); + render(); expect(screen.getByText(/Not Found/i)).toBeVisible(); }); it('renders the backup page title and initial download button', () => { mockStatusData = {}; - render(); + render(); expect(screen.getByText(messages.backupPageTitle.defaultMessage)).toBeVisible(); const button = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); expect(button).toBeEnabled(); @@ -75,7 +69,7 @@ describe('', () => { onSuccess({ task_id: 'task-123' }); mockStatusData = { state: LibraryBackupStatus.Pending }; }); - render(); + render(); const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); expect(initialButton).toBeEnabled(); await userEvent.click(initialButton); @@ -89,7 +83,7 @@ describe('', () => { onSuccess({ task_id: 'task-123' }); mockStatusData = { state: LibraryBackupStatus.Exporting }; }); - render(); + render(); const initialButton = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); await userEvent.click(initialButton); const exportingText = await screen.findByText(messages.backupExporting.defaultMessage); @@ -100,7 +94,7 @@ describe('', () => { it('shows succeeded state uses ready text and triggers download', () => { mockStatusData = { state: 'Succeeded', url: '/fake/path.tar.gz' }; const downloadSpy = jest.spyOn(document, 'createElement'); - render(); + render(); const button = screen.getByRole('button'); expect(button).toHaveTextContent(messages.downloadReadyButton.defaultMessage); userEvent.click(button); @@ -110,7 +104,7 @@ describe('', () => { it('shows failed state and error alert', () => { mockStatusData = { state: LibraryBackupStatus.Failed }; - render(); + render(); expect(screen.getByText(messages.backupFailedError.defaultMessage)).toBeVisible(); const button = screen.getByRole('button'); expect(button).toBeEnabled(); @@ -121,7 +115,7 @@ describe('', () => { onSuccess({ task_id: 'task-123' }); mockStatusData = { state: LibraryBackupStatus.Pending }; }); - const { unmount } = render(); + const { unmount } = render(); const button = screen.getByRole('button'); userEvent.click(button); unmount(); @@ -149,7 +143,7 @@ describe('', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore window.location = { href: '' }; - render(); + render(); const button = screen.getByRole('button'); userEvent.click(button); expect(window.location.href).toContain('/fake/path.tar.gz'); @@ -167,7 +161,7 @@ describe('', () => { onSuccess({ task_id: 'task-123' }); mockStatusData = { state: LibraryBackupStatus.Pending }; }); - render(); + render(); const button = screen.getByRole('button'); expect(button).toBeEnabled(); await user.click(button); @@ -190,7 +184,7 @@ describe('', () => { // before the status API has returned any backup state }); - render(); + render(); const button = screen.getByRole('button'); await userEvent.click(button); @@ -208,7 +202,7 @@ describe('', () => { url: '/api/libraries/v2/backup/download/test-backup.tar.gz', }; - render(); + render(); // Spy on handleDownload function call const createElementSpy = jest.spyOn(document, 'createElement'); From 821af33c72d63c0aa55ad4d80ddf74f086db5589 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 13:21:50 -0600 Subject: [PATCH 21/24] chore: update test to get URL from a better place --- src/library-authoring/backup-restore/data/api.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/library-authoring/backup-restore/data/api.ts b/src/library-authoring/backup-restore/data/api.ts index af57e70d71..dd31db8173 100644 --- a/src/library-authoring/backup-restore/data/api.ts +++ b/src/library-authoring/backup-restore/data/api.ts @@ -1,16 +1,14 @@ -import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { CreateLibraryBackupResponse, GetLibraryBackupStatusResponse } from '@src/library-authoring/backup-restore/data/constants'; - -const getApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}/${path || ''}`; +import { getLibraryBackupApiUrl, getLibraryBackupStatusApiUrl } from '@src/library-authoring/data/api'; export const createLibraryBackup = async (libraryId: string): Promise => { - const { data } = await getAuthenticatedHttpClient().post(getApiUrl(`api/libraries/v2/${libraryId}/backup/`)); + const { data } = await getAuthenticatedHttpClient().post(getLibraryBackupApiUrl(libraryId), {}); return data; }; export const getLibraryBackupStatus = async (libraryId: string, taskId: string): Promise => { - const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`api/libraries/v2/${libraryId}/backup/?task_id=${taskId}`)); + const { data } = await getAuthenticatedHttpClient().get(getLibraryBackupStatusApiUrl(libraryId, taskId)); return data; }; From d7c627c838d9d64294a4870545ebf03bb9ae1831 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 14:13:01 -0600 Subject: [PATCH 22/24] chore: added extra getters for new endpoints --- src/library-authoring/data/api.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index f5a3aa8d26..8d7a3571b5 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -137,6 +137,14 @@ export const getLibraryContainerCollectionsUrl = (containerId: string) => `${get * Get the URL for the API endpoint to publish a single container (+ children). */ export const getLibraryContainerPublishApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}publish/`; +/** + * Get the URL for the API endpoint to create a backup of a v2 library. + */ +export const getLibraryBackupApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/backup/`; +/** + * Get the URL for the API endpoint to get the status of a library backup task. + */ +export const getLibraryBackupStatusApiUrl = (libraryId: string, taskId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/backup/?task_id=${taskId}`; export interface ContentLibrary { id: string; From 43016d7356e820d487cf60bdf690b6dc06d5d591 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 16:12:42 -0600 Subject: [PATCH 23/24] chore: update test to prevent issues with useContentLibrary --- .../backup-restore/LibraryBackupPage.test.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index f97181a5be..fc0084005a 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -11,9 +11,7 @@ import { LibraryBackupStatus } from './data/constants'; import { LibraryBackupPage } from './LibraryBackupPage'; import messages from './messages'; -mockContentLibrary.applyMock(); -const mockLibraryId = mockContentLibrary.libraryId; -const render = (libraryId: string = mockLibraryId) => baseRender(, { +const render = (libraryId: string = mockContentLibrary.libraryId) => baseRender(, { extraWrapper: ({ children }) => ( {children} ), @@ -26,6 +24,13 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ }), })); +const mockLibraryData: +{ data: typeof mockContentLibrary.libraryData | undefined } = { data: mockContentLibrary.libraryData }; + +jest.mock('@src/library-authoring/data/apiHooks', () => ({ + useContentLibrary: () => (mockLibraryData), +})); + // Mutable mocks varied per test const mockMutate = jest.fn(); let mockStatusData: any = {}; @@ -46,18 +51,17 @@ describe('', () => { mockMutate.mockReset(); mockStatusData = {}; mockMutationError = null; + mockLibraryData.data = mockContentLibrary.libraryData; }); it('returns NotFoundAlert if no libraryData', () => { - mockContentLibrary.libraryData = null as any; - - render(); + mockLibraryData.data = undefined as any; + render(mockContentLibrary.libraryIdThatNeverLoads); expect(screen.getByText(/Not Found/i)).toBeVisible(); }); it('renders the backup page title and initial download button', () => { - mockStatusData = {}; render(); expect(screen.getByText(messages.backupPageTitle.defaultMessage)).toBeVisible(); const button = screen.getByRole('button', { name: messages.downloadAriaLabel.defaultMessage }); @@ -223,7 +227,7 @@ describe('', () => { // Verify the download was triggered expect(createElementSpy).toHaveBeenCalledWith('a'); expect(mockAnchor.href).toContain('/api/libraries/v2/backup/download/test-backup.tar.gz'); - expect(mockAnchor.download).toBe('test-lib-backup.tar.gz'); + expect(mockAnchor.download).toContain('backup.tar.gz'); expect(mockAnchor.click).toHaveBeenCalled(); expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor); expect(removeChildSpy).toHaveBeenCalledWith(mockAnchor); From e02acc4b73a35b9066a1a6caed675be1881cceb3 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 17 Oct 2025 16:41:48 -0600 Subject: [PATCH 24/24] chore: added comments for clarity --- .../backup-restore/LibraryBackupPage.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx index fc0084005a..d00805e6e9 100644 --- a/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx +++ b/src/library-authoring/backup-restore/LibraryBackupPage.test.tsx @@ -17,6 +17,8 @@ const render = (libraryId: string = mockContentLibrary.libraryId) => baseRender( ), }); +// Mocking i18n to prevent having to generate all dynamic translations for this specific test file +// Other tests can still use the real implementation as needed jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), useIntl: () => ({ @@ -27,6 +29,8 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ const mockLibraryData: { data: typeof mockContentLibrary.libraryData | undefined } = { data: mockContentLibrary.libraryData }; +// TODO: consider using the usual mockContentLibrary.applyMocks pattern after figuring out +// why it doesn't work here as expected jest.mock('@src/library-authoring/data/apiHooks', () => ({ useContentLibrary: () => (mockLibraryData), }));