From d74058991c2e2742f27bb8051a24ec65dcf068c2 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 12:56:20 -0400 Subject: [PATCH 1/7] feat: certificates all learners list --- src/certificates/CertificatesPage.tsx | 59 ++++ .../CertificateGenerationHistory.tsx | 62 ++++ .../components/IssuedCertificates.tsx | 279 ++++++++++++++++++ src/certificates/data/api.ts | 63 ++++ src/certificates/data/apiHook.ts | 47 +++ src/certificates/data/queryKeys.ts | 13 + src/certificates/messages.ts | 211 +++++++++++++ src/certificates/types.ts | 32 ++ src/routes.tsx | 9 +- 9 files changed, 771 insertions(+), 4 deletions(-) create mode 100644 src/certificates/CertificatesPage.tsx create mode 100644 src/certificates/components/CertificateGenerationHistory.tsx create mode 100644 src/certificates/components/IssuedCertificates.tsx create mode 100644 src/certificates/data/api.ts create mode 100644 src/certificates/data/apiHook.ts create mode 100644 src/certificates/data/queryKeys.ts create mode 100644 src/certificates/messages.ts create mode 100644 src/certificates/types.ts diff --git a/src/certificates/CertificatesPage.tsx b/src/certificates/CertificatesPage.tsx new file mode 100644 index 00000000..d8eaa95a --- /dev/null +++ b/src/certificates/CertificatesPage.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { Tabs, Tab, Spinner } from '@openedx/paragon'; +import messages from './messages'; +import IssuedCertificates from './components/IssuedCertificates'; +import CertificateGenerationHistory from './components/CertificateGenerationHistory'; +import { useCertificateConfig } from './data/apiHook'; +import PageNotFound from '@src/components/PageNotFound'; + +const CertificatesPage = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [activeTab, setActiveTab] = useState('issued'); + const { data: config, isLoading, error } = useCertificateConfig(courseId); + + // Check if we got a 404 or if certificates are not enabled + const is404 = (error as any)?.response?.status === 404; + const certificatesNotEnabled = !isLoading && config && !config.enabled; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (is404 || certificatesNotEnabled) { + return ; + } + + return ( +
+

{intl.formatMessage(messages.certificatesTitle)}

+ + setActiveTab(key as string)} + className="mb-3" + > + + + + + + + +
+ ); +}; + +export default CertificatesPage; diff --git a/src/certificates/components/CertificateGenerationHistory.tsx b/src/certificates/components/CertificateGenerationHistory.tsx new file mode 100644 index 00000000..aa6f3aeb --- /dev/null +++ b/src/certificates/components/CertificateGenerationHistory.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { DataTable, Spinner } from '@openedx/paragon'; +import { useCertificateGenerationHistory } from '../data/apiHook'; +import messages from '../messages'; + +const CertificateGenerationHistory = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [pageIndex, setPageIndex] = useState(0); + const pageSize = 20; + + const { data, isLoading, error } = useCertificateGenerationHistory( + courseId, + { page: pageIndex, pageSize } + ); + + const columns = [ + { + Header: intl.formatMessage(messages.taskName), + accessor: 'taskName', + }, + { + Header: intl.formatMessage(messages.date), + accessor: 'date', + }, + { + Header: intl.formatMessage(messages.details), + accessor: 'details', + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return
Error loading generation history
; + } + + return ( +
+ + + + + +
+ ); +}; + +export default CertificateGenerationHistory; diff --git a/src/certificates/components/IssuedCertificates.tsx b/src/certificates/components/IssuedCertificates.tsx new file mode 100644 index 00000000..b196df38 --- /dev/null +++ b/src/certificates/components/IssuedCertificates.tsx @@ -0,0 +1,279 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { + Button, + DataTable, + Form, + SearchField, + Dropdown, + ModalDialog, +} from '@openedx/paragon'; +import { useIssuedCertificates, useRegenerateCertificatesMutation } from '../data/apiHook'; +import { useAlert } from '@src/providers/AlertProvider'; +import messages from '../messages'; +import { CertificateFilter } from '../types'; +import { APIError } from '@src/types'; +import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter'; + +const CertificateSearchField = ({ filterValue, setFilter }: { filterValue: string, setFilter: (value: string) => void }) => { + const intl = useIntl(); + const { inputValue, handleChange } = useDebouncedFilter({ + filterValue, + setFilter, + delay: 400, + }); + + return ( + + ); +}; + +const IssuedCertificates = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); + const [pageIndex, setPageIndex] = useState(0); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); + const pageSize = 20; + const { showToast, showModal, removeAlert } = useAlert(); + const { mutate: regenerateMutation, isPending: isRegenerating } = useRegenerateCertificatesMutation(); + + const { data, isLoading, error } = useIssuedCertificates( + courseId, + { page: pageIndex, pageSize }, + search, + filter + ); + + const filterOptions = [ + { value: 'all', label: intl.formatMessage(messages.allLearners) }, + { value: 'received', label: intl.formatMessage(messages.received) }, + { value: 'not_received', label: intl.formatMessage(messages.notReceived) }, + { value: 'audit_passing', label: intl.formatMessage(messages.auditPassing) }, + { value: 'audit_not_passing', label: intl.formatMessage(messages.auditNotPassing) }, + { value: 'error', label: intl.formatMessage(messages.errorState) }, + { value: 'granted_exceptions', label: intl.formatMessage(messages.grantedExceptions) }, + { value: 'invalidated', label: intl.formatMessage(messages.invalidated) }, + ]; + + const columns = [ + { + Header: intl.formatMessage(messages.username), + accessor: 'username', + }, + { + Header: intl.formatMessage(messages.email), + accessor: 'email', + }, + { + Header: intl.formatMessage(messages.enrollmentTrack), + accessor: 'enrollmentTrack', + }, + { + Header: intl.formatMessage(messages.certificateStatus), + accessor: 'certificateStatus', + }, + { + Header: intl.formatMessage(messages.specialCase), + accessor: 'specialCase', + Cell: ({ value }) => value || '—', + }, + { + Header: intl.formatMessage(messages.exceptionGranted), + accessor: 'exceptionGranted', + Cell: ({ value }) => value || '—', + }, + { + Header: intl.formatMessage(messages.exceptionNotes), + accessor: 'exceptionNotes', + Cell: ({ value }) => value || '—', + }, + { + Header: intl.formatMessage(messages.invalidatedBy), + accessor: 'invalidatedBy', + Cell: ({ value }) => value || '—', + }, + { + Header: intl.formatMessage(messages.invalidationDate), + accessor: 'invalidationDate', + Cell: ({ value }) => value || '—', + }, + ]; + + // Determine if regenerate button should be disabled + const isRegenerateDisabled = filter === 'all' || filter === 'invalidated' || (data?.count || 0) === 0; + + // Get modal content based on filter + const getModalContent = () => { + const filterMessages = { + received: intl.formatMessage(messages.regenerateAllLearnersMessage), + not_received: intl.formatMessage(messages.regenerateNotReceivedMessage), + audit_passing: intl.formatMessage(messages.regenerateAuditPassingMessage), + audit_not_passing: intl.formatMessage(messages.regenerateAuditNotPassingMessage), + error: intl.formatMessage(messages.regenerateErrorMessage), + granted_exceptions: intl.formatMessage(messages.generateExceptionsMessage), + }; + return filterMessages[filter] || intl.formatMessage(messages.regenerateAllLearnersMessage); + }; + + const handleOpenRegenerateModal = () => { + if (isRegenerateDisabled) return; + setIsRegenerateModalOpen(true); + }; + + const handleConfirmRegenerate = () => { + if (!courseId) return; + + // Map filter to API parameters + const params: any = {}; + + if (filter === 'granted_exceptions') { + params.studentSet = 'allowlisted'; + } else if (filter === 'received') { + params.statuses = ['downloadable']; + } else if (filter === 'not_received') { + params.statuses = ['notpassing', 'unavailable']; + } else if (filter === 'audit_passing') { + params.statuses = ['audit_passing']; + } else if (filter === 'audit_not_passing') { + params.statuses = ['audit_notpassing']; + } else if (filter === 'error') { + params.statuses = ['error']; + } + + regenerateMutation( + { courseId, params }, + { + onSuccess: () => { + setIsRegenerateModalOpen(false); + showToast(intl.formatMessage(messages.regenerateSuccess)); + }, + onError: (err: APIError | Error) => { + setIsRegenerateModalOpen(false); + const errorMessage = 'response' in err + ? err.response?.data?.error || intl.formatMessage(messages.regenerateError) + : err.message; + showModal({ + confirmText: intl.formatMessage(messages.close), + message: errorMessage, + variant: 'danger', + onConfirm: (id) => removeAlert(id), + }); + }, + } + ); + }; + + return ( +
+
+
+ + + + + {filterOptions.find(opt => opt.value === filter)?.label} + + + {filterOptions.map(option => ( + setFilter(option.value as CertificateFilter)} + > + {option.label} + + ))} + + + +
+ +
+ + setIsRegenerateModalOpen(false)} + isOverflowVisible={false} + > + + + {intl.formatMessage( + filter === 'granted_exceptions' + ? messages.generateCertificatesTitle + : messages.regenerateCertificatesTitle + )} + + + +

{getModalContent()}

+

+ {intl.formatMessage(messages.regenerateConfirmation, { + action: filter === 'granted_exceptions' ? 'Generate' : 'Regenerate', + number: data?.count || 0, + })} +

+
+ + + + +
+ + {error ? ( +
Error loading certificates
+ ) : ( + setPageIndex(newPageIndex)} + > + + + {Math.ceil((data?.count || 0) / pageSize) > 1 && ( + + + + + + )} + + )} +
+ ); +}; + +export default IssuedCertificates; diff --git a/src/certificates/data/api.ts b/src/certificates/data/api.ts new file mode 100644 index 00000000..d87dc0da --- /dev/null +++ b/src/certificates/data/api.ts @@ -0,0 +1,63 @@ +import { camelCaseObject, getAuthenticatedHttpClient, snakeCaseObject } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { + IssuedCertificate, + CertificateGenerationHistory, + RegenerateCertificatesParams, + CertificateFilter +} from '../types'; +import { DataList, PaginationQueryKeys } from '@src/types'; + +export const getIssuedCertificates = async ( + courseId: string, + pagination: PaginationQueryKeys, + search?: string, + filter?: CertificateFilter +): Promise> => { + const params = new URLSearchParams({ + page: String(pagination.page + 1), + page_size: String(pagination.pageSize), + }); + + if (search) { + params.append('search', search); + } + + if (filter && filter !== 'all') { + params.append('filter', filter); + } + + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/issued?${params.toString()}` + ); + return camelCaseObject(data); +}; + +export const getCertificateGenerationHistory = async ( + courseId: string, + pagination: PaginationQueryKeys +): Promise> => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/generation_history?page=${pagination.page + 1}&page_size=${pagination.pageSize}` + ); + return camelCaseObject(data); +}; + +export const regenerateCertificates = async ( + courseId: string, + params: RegenerateCertificatesParams +) => { + const snakeCaseData = snakeCaseObject(params); + const { data } = await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/regenerate`, + snakeCaseData + ); + return camelCaseObject(data); +}; + +export const getCertificateConfig = async (courseId: string): Promise<{ enabled: boolean }> => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/certificates/config` + ); + return camelCaseObject(data); +}; diff --git a/src/certificates/data/apiHook.ts b/src/certificates/data/apiHook.ts new file mode 100644 index 00000000..4a472caa --- /dev/null +++ b/src/certificates/data/apiHook.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { PaginationQueryKeys } from '@src/types'; +import { getIssuedCertificates, getCertificateGenerationHistory, regenerateCertificates, getCertificateConfig } from './api'; +import { certificatesQueryKeys } from './queryKeys'; +import { CertificateFilter, RegenerateCertificatesParams } from '../types'; + +export const useIssuedCertificates = ( + courseId: string, + pagination: PaginationQueryKeys, + search?: string, + filter?: CertificateFilter +) => { + return useQuery({ + queryKey: certificatesQueryKeys.list(courseId, pagination, search, filter), + queryFn: () => getIssuedCertificates(courseId, pagination, search, filter), + }); +}; + +export const useCertificateGenerationHistory = ( + courseId: string, + pagination: PaginationQueryKeys +) => { + return useQuery({ + queryKey: certificatesQueryKeys.history(courseId, pagination), + queryFn: () => getCertificateGenerationHistory(courseId, pagination), + }); +}; + +export const useRegenerateCertificatesMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ courseId, params }: { courseId: string, params: RegenerateCertificatesParams }) => + regenerateCertificates(courseId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: certificatesQueryKeys.lists() }); + queryClient.invalidateQueries({ queryKey: certificatesQueryKeys.all }); + }, + }); +}; + +export const useCertificateConfig = (courseId: string) => { + return useQuery({ + queryKey: certificatesQueryKeys.config(courseId), + queryFn: () => getCertificateConfig(courseId), + }); +}; diff --git a/src/certificates/data/queryKeys.ts b/src/certificates/data/queryKeys.ts new file mode 100644 index 00000000..49d8f0a2 --- /dev/null +++ b/src/certificates/data/queryKeys.ts @@ -0,0 +1,13 @@ +import { PaginationQueryKeys } from '@src/types'; +import { CertificateFilter } from '../types'; + +export const certificatesQueryKeys = { + all: ['certificates'] as const, + lists: () => [...certificatesQueryKeys.all, 'list'] as const, + list: (courseId: string, pagination: PaginationQueryKeys, search?: string, filter?: CertificateFilter) => + [...certificatesQueryKeys.lists(), courseId, pagination, search, filter] as const, + history: (courseId: string, pagination: PaginationQueryKeys) => + [...certificatesQueryKeys.all, 'history', courseId, pagination] as const, + config: (courseId: string) => + [...certificatesQueryKeys.all, 'config', courseId] as const, +}; diff --git a/src/certificates/messages.ts b/src/certificates/messages.ts new file mode 100644 index 00000000..95cb537d --- /dev/null +++ b/src/certificates/messages.ts @@ -0,0 +1,211 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + certificatesTitle: { + id: 'certificates.title', + defaultMessage: 'Certificates', + description: 'Title for certificates page', + }, + issuedCertificatesTab: { + id: 'certificates.issuedCertificates.tab', + defaultMessage: 'Issued Certificates', + description: 'Tab label for issued certificates', + }, + generationHistoryTab: { + id: 'certificates.generationHistory.tab', + defaultMessage: 'Certificate Generation History', + description: 'Tab label for certificate generation history', + }, + searchPlaceholder: { + id: 'certificates.search.placeholder', + defaultMessage: 'Search for a Learner', + description: 'Placeholder for search input', + }, + allLearners: { + id: 'certificates.filter.allLearners', + defaultMessage: 'All Learners', + description: 'Filter option for all learners', + }, + received: { + id: 'certificates.filter.received', + defaultMessage: 'Received', + description: 'Filter option for received certificates', + }, + notReceived: { + id: 'certificates.filter.notReceived', + defaultMessage: 'Not Received', + description: 'Filter option for not received certificates', + }, + auditPassing: { + id: 'certificates.filter.auditPassing', + defaultMessage: 'Audit - Passing', + description: 'Filter option for audit passing', + }, + auditNotPassing: { + id: 'certificates.filter.auditNotPassing', + defaultMessage: 'Audit - Not Passing', + description: 'Filter option for audit not passing', + }, + errorState: { + id: 'certificates.filter.errorState', + defaultMessage: 'Error State', + description: 'Filter option for error state', + }, + grantedExceptions: { + id: 'certificates.filter.grantedExceptions', + defaultMessage: 'Granted Exceptions', + description: 'Filter option for granted exceptions', + }, + invalidated: { + id: 'certificates.filter.invalidated', + defaultMessage: 'Invalidated', + description: 'Filter option for invalidated certificates', + }, + regenerateCertificates: { + id: 'certificates.regenerate.button', + defaultMessage: 'Regenerate Certificates', + description: 'Button text for regenerating certificates', + }, + regenerating: { + id: 'certificates.regenerate.inProgress', + defaultMessage: 'Regenerating...', + description: 'Button text while regenerating certificates', + }, + username: { + id: 'certificates.table.username', + defaultMessage: 'Username', + description: 'Table column header for username', + }, + email: { + id: 'certificates.table.email', + defaultMessage: 'Email', + description: 'Table column header for email', + }, + enrollmentTrack: { + id: 'certificates.table.enrollmentTrack', + defaultMessage: 'Enrollment Track', + description: 'Table column header for enrollment track', + }, + certificateStatus: { + id: 'certificates.table.certificateStatus', + defaultMessage: 'Certificate Status', + description: 'Table column header for certificate status', + }, + specialCase: { + id: 'certificates.table.specialCase', + defaultMessage: 'Special Case', + description: 'Table column header for special case', + }, + exceptionGranted: { + id: 'certificates.table.exceptionGranted', + defaultMessage: 'Exception Granted', + description: 'Table column header for exception granted', + }, + exceptionNotes: { + id: 'certificates.table.exceptionNotes', + defaultMessage: 'Exception Notes', + description: 'Table column header for exception notes', + }, + invalidatedBy: { + id: 'certificates.table.invalidatedBy', + defaultMessage: 'Invalidated By', + description: 'Table column header for invalidated by', + }, + invalidationDate: { + id: 'certificates.table.invalidationDate', + defaultMessage: 'Invalidation Date', + description: 'Table column header for invalidation date', + }, + taskName: { + id: 'certificates.history.taskName', + defaultMessage: 'Task Name', + description: 'Table column header for task name', + }, + date: { + id: 'certificates.history.date', + defaultMessage: 'Date', + description: 'Table column header for date', + }, + details: { + id: 'certificates.history.details', + defaultMessage: 'Details', + description: 'Table column header for details', + }, + regenerateSuccess: { + id: 'certificates.regenerate.success', + defaultMessage: 'Certificate regeneration has been started', + description: 'Success message for certificate regeneration', + }, + regenerateError: { + id: 'certificates.regenerate.error', + defaultMessage: 'Error regenerating certificates', + description: 'Error message for certificate regeneration', + }, + close: { + id: 'certificates.close', + defaultMessage: 'Close', + description: 'Close button text', + }, + regenerateAllLearnersMessage: { + id: 'certificates.regenerate.modal.allLearners', + defaultMessage: 'Regenerate certificates for all learners who have received certificates.', + description: 'Modal message for regenerating certificates for received filter', + }, + regenerateNotReceivedMessage: { + id: 'certificates.regenerate.modal.notReceived', + defaultMessage: 'Generate certificates for learners who have not yet received certificates.', + description: 'Modal message for regenerating certificates for not received filter', + }, + regenerateAuditPassingMessage: { + id: 'certificates.regenerate.modal.auditPassing', + defaultMessage: 'Generate certificates for audit learners who are passing.', + description: 'Modal message for regenerating certificates for audit passing filter', + }, + regenerateAuditNotPassingMessage: { + id: 'certificates.regenerate.modal.auditNotPassing', + defaultMessage: 'Generate certificates for audit learners who are not passing.', + description: 'Modal message for regenerating certificates for audit not passing filter', + }, + regenerateErrorMessage: { + id: 'certificates.regenerate.modal.error', + defaultMessage: 'Regenerate certificates for learners whose certificate generation resulted in an error.', + description: 'Modal message for regenerating certificates for error filter', + }, + generateExceptionsMessage: { + id: 'certificates.regenerate.modal.exceptions', + defaultMessage: 'Generate certificates for learners who have been granted exceptions.', + description: 'Modal message for generating certificates for granted exceptions filter', + }, + regenerateCertificatesTitle: { + id: 'certificates.regenerate.modal.title', + defaultMessage: 'Regenerate Certificates', + description: 'Modal title for regenerating certificates', + }, + generateCertificatesTitle: { + id: 'certificates.generate.modal.title', + defaultMessage: 'Generate Certificates', + description: 'Modal title for generating certificates', + }, + regenerateConfirmation: { + id: 'certificates.regenerate.modal.confirmation', + defaultMessage: '{action} certificates for {number} learners?', + description: 'Confirmation message for regenerating/generating certificates', + }, + cancel: { + id: 'certificates.cancel', + defaultMessage: 'Cancel', + description: 'Cancel button text', + }, + regenerate: { + id: 'certificates.regenerate', + defaultMessage: 'Regenerate', + description: 'Regenerate button text', + }, + generate: { + id: 'certificates.generate', + defaultMessage: 'Generate', + description: 'Generate button text', + }, +}); + +export default messages; diff --git a/src/certificates/types.ts b/src/certificates/types.ts new file mode 100644 index 00000000..7284231f --- /dev/null +++ b/src/certificates/types.ts @@ -0,0 +1,32 @@ +export interface IssuedCertificate { + username: string, + email: string, + enrollmentTrack: string, + certificateStatus: string, + specialCase: string | null, + exceptionGranted: string | null, + exceptionNotes: string | null, + invalidatedBy: string | null, + invalidationDate: string | null, +} + +export interface CertificateGenerationHistory { + taskName: string, + date: string, + details: string, +} + +export interface RegenerateCertificatesParams { + statuses?: string[], + studentSet?: 'all' | 'allowlisted', +} + +export type CertificateFilter = + | 'all' + | 'received' + | 'not_received' + | 'audit_passing' + | 'audit_not_passing' + | 'error' + | 'granted_exceptions' + | 'invalidated'; diff --git a/src/routes.tsx b/src/routes.tsx index 84a9afde..3f09b533 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -3,6 +3,7 @@ import CourseInfoPage from '@src/courseInfo/CourseInfoPage'; import DateExtensionsPage from '@src/dateExtensions/DateExtensionsPage'; import DataDownloadsPage from '@src/dataDownloads/DataDownloadsPage'; import OpenResponsesPage from '@src/openResponses/OpenResponsesPage'; +import CertificatesPage from '@src/certificates/CertificatesPage'; const routes = [ { @@ -44,10 +45,10 @@ const routes = [ // path: 'special_exams', // element: // }, - // { - // path: 'certificates', - // element: - // }, + { + path: 'certificates', + element: + }, { path: 'open_responses', element: From fc8ac67d58d78c40c3b6dcc0864516528c75eda4 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 14:19:04 -0400 Subject: [PATCH 2/7] fix: search and pagination --- .../components/IssuedCertificates.tsx | 200 +++++++++++------- 1 file changed, 123 insertions(+), 77 deletions(-) diff --git a/src/certificates/components/IssuedCertificates.tsx b/src/certificates/components/IssuedCertificates.tsx index b196df38..10e2a53b 100644 --- a/src/certificates/components/IssuedCertificates.tsx +++ b/src/certificates/components/IssuedCertificates.tsx @@ -5,7 +5,6 @@ import { Button, DataTable, Form, - SearchField, Dropdown, ModalDialog, } from '@openedx/paragon'; @@ -15,8 +14,10 @@ import messages from '../messages'; import { CertificateFilter } from '../types'; import { APIError } from '@src/types'; import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter'; +import { FormControl, Icon } from '@openedx/paragon'; +import { Search } from '@openedx/paragon/icons'; -const CertificateSearchField = ({ filterValue, setFilter }: { filterValue: string, setFilter: (value: string) => void }) => { +const SearchFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => { const intl = useIntl(); const { inputValue, handleChange } = useDebouncedFilter({ filterValue, @@ -25,31 +26,18 @@ const CertificateSearchField = ({ filterValue, setFilter }: { filterValue: strin }); return ( - ) => handleChange(e.target.value)} placeholder={intl.formatMessage(messages.searchPlaceholder)} - onChange={handleChange} + trailingElement={} value={inputValue} /> ); }; -const IssuedCertificates = () => { +const FilterDropdownFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => { const intl = useIntl(); - const { courseId = '' } = useParams<{ courseId: string }>(); - const [search, setSearch] = useState(''); - const [filter, setFilter] = useState('all'); - const [pageIndex, setPageIndex] = useState(0); - const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); - const pageSize = 20; - const { showToast, showModal, removeAlert } = useAlert(); - const { mutate: regenerateMutation, isPending: isRegenerating } = useRegenerateCertificatesMutation(); - - const { data, isLoading, error } = useIssuedCertificates( - courseId, - { page: pageIndex, pageSize }, - search, - filter - ); const filterOptions = [ { value: 'all', label: intl.formatMessage(messages.allLearners) }, @@ -62,52 +50,120 @@ const IssuedCertificates = () => { { value: 'invalidated', label: intl.formatMessage(messages.invalidated) }, ]; + return ( + + + + {filterOptions.find(opt => opt.value === (filterValue || 'all'))?.label} + + + {filterOptions.map(option => ( + setFilter(option.value)} + > + {option.label} + + ))} + + + + ); +}; + +const IssuedCertificates = () => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [filters, setFilters] = useState<{ page: number, search: string, filter: CertificateFilter }>({ + page: 0, + search: '', + filter: 'all', + }); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); + const pageSize = 10; + const { showToast, showModal, removeAlert } = useAlert(); + const { mutate: regenerateMutation, isPending: isRegenerating } = useRegenerateCertificatesMutation(); + + const { data, isLoading, error } = useIssuedCertificates( + courseId, + { page: filters.page, pageSize }, + filters.search, + filters.filter + ); + const columns = [ { Header: intl.formatMessage(messages.username), accessor: 'username', + Filter: SearchFilter, }, { Header: intl.formatMessage(messages.email), accessor: 'email', + Filter: FilterDropdownFilter, }, { Header: intl.formatMessage(messages.enrollmentTrack), accessor: 'enrollmentTrack', + disableFilters: true, }, { Header: intl.formatMessage(messages.certificateStatus), accessor: 'certificateStatus', + disableFilters: true, }, { Header: intl.formatMessage(messages.specialCase), accessor: 'specialCase', Cell: ({ value }) => value || '—', + disableFilters: true, }, { Header: intl.formatMessage(messages.exceptionGranted), accessor: 'exceptionGranted', Cell: ({ value }) => value || '—', + disableFilters: true, }, { Header: intl.formatMessage(messages.exceptionNotes), accessor: 'exceptionNotes', Cell: ({ value }) => value || '—', + disableFilters: true, }, { Header: intl.formatMessage(messages.invalidatedBy), accessor: 'invalidatedBy', Cell: ({ value }) => value || '—', + disableFilters: true, }, { Header: intl.formatMessage(messages.invalidationDate), accessor: 'invalidationDate', Cell: ({ value }) => value || '—', + disableFilters: true, }, ]; + const handleFetchData = (data: { pageIndex: number, filters: { id: string, value: string }[] }) => { + const searchFilter = data.filters.find((filter) => filter.id === 'username'); + const newSearch = searchFilter ? searchFilter.value : ''; + const filterFilter = data.filters.find((filter) => filter.id === 'email'); + const newFilter = filterFilter ? filterFilter.value as CertificateFilter : 'all'; + + const filterChanged = newSearch !== filters.search || newFilter !== filters.filter; + const pageChanged = data.pageIndex !== filters.page; + + // If filters changed, reset to page 0 + if (filterChanged) { + setFilters({ page: 0, search: newSearch, filter: newFilter }); + } else if (pageChanged) { + // If only page changed (filters didn't change), update page + setFilters({ page: data.pageIndex, search: newSearch, filter: newFilter }); + } + }; + // Determine if regenerate button should be disabled - const isRegenerateDisabled = filter === 'all' || filter === 'invalidated' || (data?.count || 0) === 0; + const isRegenerateDisabled = filters.filter === 'all' || filters.filter === 'invalidated' || (data?.count || 0) === 0; // Get modal content based on filter const getModalContent = () => { @@ -119,7 +175,7 @@ const IssuedCertificates = () => { error: intl.formatMessage(messages.regenerateErrorMessage), granted_exceptions: intl.formatMessage(messages.generateExceptionsMessage), }; - return filterMessages[filter] || intl.formatMessage(messages.regenerateAllLearnersMessage); + return filterMessages[filters.filter] || intl.formatMessage(messages.regenerateAllLearnersMessage); }; const handleOpenRegenerateModal = () => { @@ -133,17 +189,17 @@ const IssuedCertificates = () => { // Map filter to API parameters const params: any = {}; - if (filter === 'granted_exceptions') { + if (filters.filter === 'granted_exceptions') { params.studentSet = 'allowlisted'; - } else if (filter === 'received') { + } else if (filters.filter === 'received') { params.statuses = ['downloadable']; - } else if (filter === 'not_received') { + } else if (filters.filter === 'not_received') { params.statuses = ['notpassing', 'unavailable']; - } else if (filter === 'audit_passing') { + } else if (filters.filter === 'audit_passing') { params.statuses = ['audit_passing']; - } else if (filter === 'audit_not_passing') { + } else if (filters.filter === 'audit_not_passing') { params.statuses = ['audit_notpassing']; - } else if (filter === 'error') { + } else if (filters.filter === 'error') { params.statuses = ['error']; } @@ -172,39 +228,9 @@ const IssuedCertificates = () => { return (
-
-
- - - - - {filterOptions.find(opt => opt.value === filter)?.label} - - - {filterOptions.map(option => ( - setFilter(option.value as CertificateFilter)} - > - {option.label} - - ))} - - - -
- -
- { {intl.formatMessage( - filter === 'granted_exceptions' + filters.filter === 'granted_exceptions' ? messages.generateCertificatesTitle : messages.regenerateCertificatesTitle )} @@ -225,7 +251,7 @@ const IssuedCertificates = () => {

{getModalContent()}

{intl.formatMessage(messages.regenerateConfirmation, { - action: filter === 'granted_exceptions' ? 'Generate' : 'Regenerate', + action: filters.filter === 'granted_exceptions' ? 'Generate' : 'Regenerate', number: data?.count || 0, })}

@@ -238,7 +264,7 @@ const IssuedCertificates = () => { {isRegenerating ? intl.formatMessage(messages.regenerating) : intl.formatMessage( - filter === 'granted_exceptions' ? messages.generate : messages.regenerate + filters.filter === 'granted_exceptions' ? messages.generate : messages.regenerate )} @@ -248,28 +274,48 @@ const IssuedCertificates = () => {
Error loading certificates
) : ( setPageIndex(newPageIndex)} + isFilterable + numBreakoutFilters={2} + isLoading={isLoading} + isPaginated + itemCount={data?.count || 0} + manualFilters + manualPagination + pageSize={pageSize} + pageCount={Math.ceil((data?.count || 0) / pageSize)} + FilterStatusComponent={() => null} > +
+ + +
- {Math.ceil((data?.count || 0) / pageSize) > 1 && ( - - - - - - )} +
)}
From fe16033f7f6610d1c116a44479bb4748d1eaec29 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 14:53:22 -0400 Subject: [PATCH 3/7] fix: linting --- src/certificates/components/CertificateGenerationHistory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/certificates/components/CertificateGenerationHistory.tsx b/src/certificates/components/CertificateGenerationHistory.tsx index aa6f3aeb..6253dac8 100644 --- a/src/certificates/components/CertificateGenerationHistory.tsx +++ b/src/certificates/components/CertificateGenerationHistory.tsx @@ -8,7 +8,7 @@ import messages from '../messages'; const CertificateGenerationHistory = () => { const intl = useIntl(); const { courseId = '' } = useParams<{ courseId: string }>(); - const [pageIndex, setPageIndex] = useState(0); + const [pageIndex] = useState(0); const pageSize = 20; const { data, isLoading, error } = useCertificateGenerationHistory( From 22dfd758225331b6215b8874ddd0088bf909ab85 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 15:29:20 -0400 Subject: [PATCH 4/7] fix: linting --- src/routes.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes.tsx b/src/routes.tsx index 6b310081..c3c311d0 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -8,7 +8,6 @@ import DateExtensionsPage from '@src/dateExtensions/DateExtensionsPage'; import EnrollmentsPage from '@src/enrollments/EnrollmentsPage'; import GradingPage from '@src/grading/GradingPage'; import OpenResponsesPage from '@src/openResponses/OpenResponsesPage'; -import CertificatesPage from '@src/certificates/CertificatesPage'; import SpecialExamsPage from '@src/specialExams/SpecialExamsPage'; import PageNotFound from '@src/components/PageNotFound'; import { useWidgetProps } from './slots/SlotUtils'; From 769a9c68b80dd28be78580114690e2af67e0bb1e Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 18:08:23 -0400 Subject: [PATCH 5/7] feat: test coverage --- src/certificates/CertificatesPage.test.tsx | 204 ++++++++ .../CertificateGenerationHistory.test.tsx | 146 ++++++ .../components/IssuedCertificates.test.tsx | 469 ++++++++++++++++++ src/certificates/data/api.test.ts | 194 ++++++++ src/certificates/data/apiHook.test.tsx | 301 +++++++++++ src/certificates/data/queryKeys.test.ts | 128 +++++ 6 files changed, 1442 insertions(+) create mode 100644 src/certificates/CertificatesPage.test.tsx create mode 100644 src/certificates/components/CertificateGenerationHistory.test.tsx create mode 100644 src/certificates/components/IssuedCertificates.test.tsx create mode 100644 src/certificates/data/api.test.ts create mode 100644 src/certificates/data/apiHook.test.tsx create mode 100644 src/certificates/data/queryKeys.test.ts diff --git a/src/certificates/CertificatesPage.test.tsx b/src/certificates/CertificatesPage.test.tsx new file mode 100644 index 00000000..e6ff906c --- /dev/null +++ b/src/certificates/CertificatesPage.test.tsx @@ -0,0 +1,204 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import CertificatesPage from './CertificatesPage'; +import { useCertificateConfig } from './data/apiHook'; +import { renderWithQueryClient } from '@src/testUtils'; +import messages from './messages'; + +jest.mock('./data/apiHook'); +jest.mock('./components/IssuedCertificates', () => ({ + __esModule: true, + default: () =>
Issued Certificates Component
, +})); +jest.mock('./components/CertificateGenerationHistory', () => ({ + __esModule: true, + default: () =>
Certificate Generation History Component
, +})); +jest.mock('@src/components/PageNotFound', () => ({ + __esModule: true, + default: () =>
Page Not Found
, +})); + +const mockUseCertificateConfig = useCertificateConfig as jest.MockedFunction; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return renderWithQueryClient( + + + + + + ); +}; + +describe('CertificatesPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseCertificateConfig.mockReturnValue({ + data: { enabled: true }, + isLoading: false, + error: null, + } as any); + }); + + it('should render page with title and tabs', () => { + renderWithProviders(); + + expect(screen.getByText(messages.certificatesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage })).toBeInTheDocument(); + }); + + it('should render IssuedCertificates component by default', () => { + renderWithProviders(); + + expect(screen.getByText('Issued Certificates Component')).toBeInTheDocument(); + }); + + it('should switch to generation history tab', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + await user.click(historyTab); + + expect(screen.getByText('Certificate Generation History Component')).toBeInTheDocument(); + }); + + it('should switch back to issued certificates tab', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + await user.click(historyTab); + + const issuedTab = screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage }); + await user.click(issuedTab); + + expect(screen.getByText('Issued Certificates Component')).toBeInTheDocument(); + }); + + it('should render loading state', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + const { container } = renderWithProviders(); + + // Check for spinner element + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('should render PageNotFound when certificates are not enabled', () => { + mockUseCertificateConfig.mockReturnValue({ + data: { enabled: false }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('Page Not Found')).toBeInTheDocument(); + }); + + it('should render PageNotFound when 404 error occurs', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: false, + error: { response: { status: 404 } }, + } as any); + + renderWithProviders(); + + expect(screen.getByText('Page Not Found')).toBeInTheDocument(); + }); + + it('should not render PageNotFound for non-404 errors', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: false, + error: { response: { status: 500 } }, + } as any); + + renderWithProviders(); + + expect(screen.queryByText('Page Not Found')).not.toBeInTheDocument(); + }); + + it('should call useCertificateConfig with correct courseId', () => { + renderWithProviders(, 'course-456'); + + expect(mockUseCertificateConfig).toHaveBeenCalledWith('course-456'); + }); + + it('should maintain active tab state when switching tabs', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const issuedTab = screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage }); + expect(issuedTab).toHaveAttribute('aria-selected', 'true'); + + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + expect(historyTab).toHaveAttribute('aria-selected', 'false'); + + await user.click(historyTab); + + await waitFor(() => { + expect(historyTab).toHaveAttribute('aria-selected', 'true'); + expect(issuedTab).toHaveAttribute('aria-selected', 'false'); + }); + }); + + + it('should render certificates page when config is enabled', () => { + mockUseCertificateConfig.mockReturnValue({ + data: { enabled: true }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText(messages.certificatesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText('Page Not Found')).not.toBeInTheDocument(); + }); + + it('should not render page content while loading', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.queryByText(messages.certificatesTitle.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByText('Issued Certificates Component')).not.toBeInTheDocument(); + }); + + it('should handle error without response object', () => { + mockUseCertificateConfig.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Network error'), + } as any); + + renderWithProviders(); + + expect(screen.queryByText('Page Not Found')).not.toBeInTheDocument(); + }); + + it('should render both tabs simultaneously', () => { + renderWithProviders(); + + const issuedTab = screen.getByRole('tab', { name: messages.issuedCertificatesTab.defaultMessage }); + const historyTab = screen.getByRole('tab', { name: messages.generationHistoryTab.defaultMessage }); + + expect(issuedTab).toBeInTheDocument(); + expect(historyTab).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/components/CertificateGenerationHistory.test.tsx b/src/certificates/components/CertificateGenerationHistory.test.tsx new file mode 100644 index 00000000..ad7978b3 --- /dev/null +++ b/src/certificates/components/CertificateGenerationHistory.test.tsx @@ -0,0 +1,146 @@ +import { screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import CertificateGenerationHistory from './CertificateGenerationHistory'; +import { useCertificateGenerationHistory } from '../data/apiHook'; +import { renderWithQueryClient } from '@src/testUtils'; +import messages from '../messages'; + +jest.mock('../data/apiHook'); + +const mockUseCertificateGenerationHistory = useCertificateGenerationHistory as jest.MockedFunction; + +const mockHistoryData = { + results: [ + { + taskName: 'Generate Certificates', + date: '2025-01-15T10:30:00Z', + details: 'Generated 50 certificates', + }, + { + taskName: 'Regenerate Certificates', + date: '2025-01-14T14:20:00Z', + details: 'Regenerated 10 certificates', + }, + ], + count: 2, +}; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return renderWithQueryClient( + + + + + + ); +}; + +describe('CertificateGenerationHistory', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseCertificateGenerationHistory.mockReturnValue({ + data: mockHistoryData, + isLoading: false, + error: null, + } as any); + }); + + it('should render table with history data', () => { + renderWithProviders(); + + expect(screen.getByText('Generate Certificates')).toBeInTheDocument(); + expect(screen.getByText('2025-01-15T10:30:00Z')).toBeInTheDocument(); + expect(screen.getByText('Generated 50 certificates')).toBeInTheDocument(); + expect(screen.getByText('Regenerate Certificates')).toBeInTheDocument(); + expect(screen.getByText('2025-01-14T14:20:00Z')).toBeInTheDocument(); + expect(screen.getByText('Regenerated 10 certificates')).toBeInTheDocument(); + }); + + it('should render loading state', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + const { container } = renderWithProviders(); + + // Check for spinner element + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('should render error state', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to load history'), + } as any); + + renderWithProviders(); + + expect(screen.getByText('Error loading generation history')).toBeInTheDocument(); + }); + + it('should render empty state when no history', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: { results: [], count: 0 }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('No generation history found')).toBeInTheDocument(); + }); + + it('should call hook with correct parameters', () => { + renderWithProviders(, 'course-456'); + + expect(mockUseCertificateGenerationHistory).toHaveBeenCalledWith('course-456', { + page: 0, + pageSize: 20, + }); + }); + + it('should render table headers', () => { + renderWithProviders(); + + expect(screen.getByText(messages.taskName.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.date.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.details.defaultMessage)).toBeInTheDocument(); + }); + + it('should use pageSize of 20', () => { + renderWithProviders(); + + expect(mockUseCertificateGenerationHistory).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ pageSize: 20 }) + ); + }); + + it('should handle data with null values gracefully', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('No generation history found')).toBeInTheDocument(); + }); + + it('should calculate page count correctly', () => { + mockUseCertificateGenerationHistory.mockReturnValue({ + data: { results: mockHistoryData.results, count: 45 }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByText('Generate Certificates')).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/components/IssuedCertificates.test.tsx b/src/certificates/components/IssuedCertificates.test.tsx new file mode 100644 index 00000000..98fadc2c --- /dev/null +++ b/src/certificates/components/IssuedCertificates.test.tsx @@ -0,0 +1,469 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import IssuedCertificates from './IssuedCertificates'; +import { useIssuedCertificates, useRegenerateCertificatesMutation } from '../data/apiHook'; +import { AlertProvider } from '@src/providers/AlertProvider'; +import { renderWithQueryClient } from '@src/testUtils'; +import messages from '../messages'; + +jest.mock('../data/apiHook'); + +const mockUseIssuedCertificates = useIssuedCertificates as jest.MockedFunction; +const mockUseRegenerateCertificatesMutation = useRegenerateCertificatesMutation as jest.MockedFunction; + +const mockCertificatesData = { + results: [ + { + username: 'john_doe', + email: 'john@example.com', + enrollmentTrack: 'verified', + certificateStatus: 'downloadable', + specialCase: null, + exceptionGranted: null, + exceptionNotes: null, + invalidatedBy: null, + invalidationDate: null, + }, + { + username: 'jane_smith', + email: 'jane@example.com', + enrollmentTrack: 'audit', + certificateStatus: 'notpassing', + specialCase: 'honor_code', + exceptionGranted: 'admin', + exceptionNotes: 'Special permission', + invalidatedBy: 'instructor', + invalidationDate: '2025-01-15', + }, + ], + count: 2, +}; + +const renderWithProviders = (component: React.ReactElement, courseId = 'course-123') => { + return renderWithQueryClient( + + + + + + + + ); +}; + +describe('IssuedCertificates', () => { + const mockMutate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseIssuedCertificates.mockReturnValue({ + data: mockCertificatesData, + isLoading: false, + error: null, + } as any); + + mockUseRegenerateCertificatesMutation.mockReturnValue({ + mutate: mockMutate, + isPending: false, + } as any); + }); + + it('should render table with certificate data', () => { + renderWithProviders(); + + expect(screen.getByText('john_doe')).toBeInTheDocument(); + expect(screen.getByText('john@example.com')).toBeInTheDocument(); + expect(screen.getByText('jane_smith')).toBeInTheDocument(); + expect(screen.getByText('jane@example.com')).toBeInTheDocument(); + }); + + it('should render regenerate button', () => { + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeInTheDocument(); + }); + + it('should disable regenerate button when filter is "all"', () => { + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should disable regenerate button when filter is "invalidated"', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const invalidatedOption = screen.getByText(messages.invalidated.defaultMessage); + await user.click(invalidatedOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should enable regenerate button when filter is "received"', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).not.toBeDisabled(); + }); + + it('should disable regenerate button when count is 0', () => { + mockUseIssuedCertificates.mockReturnValue({ + data: { results: [], count: 0 }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should open modal when regenerate button clicked', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const buttons = screen.getAllByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(buttons[0]); + + expect(screen.getByText(messages.regenerateAllLearnersMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should display correct modal content for "received" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(button); + + expect(screen.getByText(messages.regenerateAllLearnersMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should display correct modal content for "granted_exceptions" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const exceptionsOption = screen.getByText(messages.grantedExceptions.defaultMessage); + await user.click(exceptionsOption); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(button); + + expect(screen.getByText(messages.generateCertificatesTitle.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.generateExceptionsMessage.defaultMessage)).toBeInTheDocument(); + }); + + it('should close modal when cancel is clicked', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButtons = screen.getAllByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButtons[0]); + + const cancelButton = screen.getByRole('button', { name: messages.cancel.defaultMessage }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByText(messages.regenerateAllLearnersMessage.defaultMessage)).not.toBeInTheDocument(); + }); + }); + + it('should call regenerate mutation with correct params for "received" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + { courseId: 'course-123', params: { statuses: ['downloadable'] } }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + it('should call regenerate mutation with correct params for "not_received" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const notReceivedOption = screen.getByText(messages.notReceived.defaultMessage); + await user.click(notReceivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + { courseId: 'course-123', params: { statuses: ['notpassing', 'unavailable'] } }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + it('should call regenerate mutation with correct params for "granted_exceptions" filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const exceptionsOption = screen.getByText(messages.grantedExceptions.defaultMessage); + await user.click(exceptionsOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.generate.defaultMessage }); + await user.click(confirmButton); + + expect(mockMutate).toHaveBeenCalledWith( + { courseId: 'course-123', params: { studentSet: 'allowlisted' } }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + + it('should show success toast on successful regeneration', async () => { + const user = userEvent.setup(); + let capturedCallbacks: any; + + mockMutate.mockImplementation((_, callbacks) => { + capturedCallbacks = callbacks; + }); + + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + capturedCallbacks.onSuccess(); + + await waitFor(() => { + expect(screen.getByText(messages.regenerateSuccess.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('should show error modal on regeneration failure', async () => { + const user = userEvent.setup(); + let capturedCallbacks: any; + + mockMutate.mockImplementation((_, callbacks) => { + capturedCallbacks = callbacks; + }); + + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + const error = { response: { data: { error: 'Custom error message' } } }; + capturedCallbacks.onError(error); + + await waitFor(() => { + expect(screen.getByText('Custom error message')).toBeInTheDocument(); + }); + }); + + it('should handle search filter', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await user.type(searchInput, 'john'); + + await waitFor(() => { + const calls = mockUseIssuedCertificates.mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[2]).toBe('john'); + }, { timeout: 500 }); + }); + + it('should render error state', () => { + mockUseIssuedCertificates.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Failed to load'), + } as any); + + renderWithProviders(); + + expect(screen.getByText('Error loading certificates')).toBeInTheDocument(); + }); + + it('should render loading state', () => { + mockUseIssuedCertificates.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); + + renderWithProviders(); + + expect(screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage })).toBeInTheDocument(); + }); + + it('should disable regenerate button while regenerating', () => { + mockUseRegenerateCertificatesMutation.mockReturnValue({ + mutate: mockMutate, + isPending: true, + } as any); + + renderWithProviders(); + + const button = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + expect(button).toBeDisabled(); + }); + + it('should render special case cells with dash when value is null', () => { + renderWithProviders(); + + const dashCells = screen.getAllByText('—'); + expect(dashCells.length).toBeGreaterThan(0); + }); + + it('should reset to page 0 when search filter changes', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const searchInput = screen.getByPlaceholderText(messages.searchPlaceholder.defaultMessage); + await user.type(searchInput, 'test'); + + await waitFor(() => { + const calls = mockUseIssuedCertificates.mock.calls; + const hasExpectedCall = calls.some(call => + call[0] === 'course-123' && + call[1].page === 0 && + call[1].pageSize === 10 && + call[2] === 'test' && + call[3] === 'all' + ); + expect(hasExpectedCall).toBe(true); + }, { timeout: 1000 }); + }); + + it('should handle error without response data', async () => { + const user = userEvent.setup(); + let capturedCallbacks: any; + + mockMutate.mockImplementation((_, callbacks) => { + capturedCallbacks = callbacks; + return undefined; + }); + + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + const confirmButton = screen.getByRole('button', { name: messages.regenerate.defaultMessage }); + await user.click(confirmButton); + + // Wait for mutation to be called + await waitFor(() => { + expect(capturedCallbacks).toBeDefined(); + }); + + const error = new Error('Network error'); + capturedCallbacks.onError(error); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + }); + + it('should display correct confirmation count', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const dropdown = screen.getByText(messages.allLearners.defaultMessage); + await user.click(dropdown); + + const receivedOption = screen.getByText(messages.received.defaultMessage); + await user.click(receivedOption); + + const regenerateButton = screen.getByRole('button', { name: messages.regenerateCertificates.defaultMessage }); + await user.click(regenerateButton); + + expect(screen.getByText(/Regenerate certificates for 2 learners?/)).toBeInTheDocument(); + }); +}); diff --git a/src/certificates/data/api.test.ts b/src/certificates/data/api.test.ts new file mode 100644 index 00000000..868017a9 --- /dev/null +++ b/src/certificates/data/api.test.ts @@ -0,0 +1,194 @@ +import { getIssuedCertificates, getCertificateGenerationHistory, regenerateCertificates, getCertificateConfig } from './api'; +import { camelCaseObject, getAuthenticatedHttpClient, snakeCaseObject } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '@src/data/api'; + +jest.mock('@openedx/frontend-base'); +jest.mock('@src/data/api'); + +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.MockedFunction; +const mockGetApiBaseUrl = getApiBaseUrl as jest.MockedFunction; +const mockCamelCaseObject = camelCaseObject as jest.MockedFunction; +const mockSnakeCaseObject = snakeCaseObject as jest.MockedFunction; + +describe('certificates api', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAuthenticatedHttpClient.mockReturnValue({ + get: mockGet, + post: mockPost, + } as any); + mockGetApiBaseUrl.mockReturnValue('http://localhost:8000'); + mockCamelCaseObject.mockImplementation((data) => data); + mockSnakeCaseObject.mockImplementation((data) => data); + }); + + describe('getIssuedCertificates', () => { + it('should fetch issued certificates with pagination', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should include search parameter when provided', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, 'john'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10&search=john' + ); + }); + + it('should include filter parameter when provided and not "all"', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, undefined, 'received'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10&filter=received' + ); + }); + + it('should not include filter parameter when filter is "all"', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, undefined, 'all'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10' + ); + }); + + it('should include both search and filter parameters', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 0, pageSize: 10 }, 'john', 'received'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=1&page_size=10&search=john&filter=received' + ); + }); + + it('should convert page index to 1-based for API', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getIssuedCertificates('course-123', { page: 2, pageSize: 20 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-123/certificates/issued?page=3&page_size=20' + ); + }); + }); + + describe('getCertificateGenerationHistory', () => { + it('should fetch certificate generation history with pagination', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getCertificateGenerationHistory('course-456', { page: 0, pageSize: 20 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-456/certificates/generation_history?page=1&page_size=20' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should convert page index to 1-based for API', async () => { + const mockData = { results: [], count: 0 }; + mockGet.mockResolvedValue({ data: mockData }); + + await getCertificateGenerationHistory('course-456', { page: 3, pageSize: 20 }); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-456/certificates/generation_history?page=4&page_size=20' + ); + }); + }); + + describe('regenerateCertificates', () => { + it('should post regenerate request with snake_case params', async () => { + const mockData = { success: true }; + mockPost.mockResolvedValue({ data: mockData }); + mockSnakeCaseObject.mockReturnValue({ statuses: ['downloadable'] }); + + const params = { statuses: ['downloadable'] }; + const result = await regenerateCertificates('course-789', params); + + expect(mockSnakeCaseObject).toHaveBeenCalledWith(params); + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-789/certificates/regenerate', + { statuses: ['downloadable'] } + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should handle studentSet parameter', async () => { + const mockData = { success: true }; + mockPost.mockResolvedValue({ data: mockData }); + mockSnakeCaseObject.mockReturnValue({ student_set: 'allowlisted' }); + + const params = { studentSet: 'allowlisted' as const }; + await regenerateCertificates('course-789', params); + + expect(mockSnakeCaseObject).toHaveBeenCalledWith(params); + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-789/certificates/regenerate', + { student_set: 'allowlisted' } + ); + }); + + it('should handle empty params', async () => { + const mockData = { success: true }; + mockPost.mockResolvedValue({ data: mockData }); + mockSnakeCaseObject.mockReturnValue({}); + + await regenerateCertificates('course-789', {}); + + expect(mockSnakeCaseObject).toHaveBeenCalledWith({}); + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-789/certificates/regenerate', + {} + ); + }); + }); + + describe('getCertificateConfig', () => { + it('should fetch certificate config', async () => { + const mockData = { enabled: true }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getCertificateConfig('course-999'); + + expect(mockGet).toHaveBeenCalledWith( + 'http://localhost:8000/api/instructor/v2/courses/course-999/certificates/config' + ); + expect(mockCamelCaseObject).toHaveBeenCalledWith(mockData); + expect(result).toEqual(mockData); + }); + + it('should handle disabled certificates', async () => { + const mockData = { enabled: false }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getCertificateConfig('course-999'); + + expect(result).toEqual({ enabled: false }); + }); + }); +}); diff --git a/src/certificates/data/apiHook.test.tsx b/src/certificates/data/apiHook.test.tsx new file mode 100644 index 00000000..5591c93c --- /dev/null +++ b/src/certificates/data/apiHook.test.tsx @@ -0,0 +1,301 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useIssuedCertificates, useCertificateGenerationHistory, useRegenerateCertificatesMutation, useCertificateConfig } from './apiHook'; +import { certificatesQueryKeys } from './queryKeys'; +import { getIssuedCertificates, getCertificateGenerationHistory, regenerateCertificates, getCertificateConfig } from './api'; +import { ReactNode } from 'react'; + +jest.mock('./api'); + +const mockGetIssuedCertificates = getIssuedCertificates as jest.MockedFunction; +const mockGetCertificateGenerationHistory = getCertificateGenerationHistory as jest.MockedFunction; +const mockRegenerateCertificates = regenerateCertificates as jest.MockedFunction; +const mockGetCertificateConfig = getCertificateConfig as jest.MockedFunction; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const WrappedComponent = ({ children }: { children: ReactNode }) => ( + {children} + ); + return WrappedComponent; +}; + +describe('certificates apiHook', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useIssuedCertificates', () => { + it('should fetch issued certificates successfully', async () => { + const mockData = { results: [], count: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 0, pageSize: 10 }, undefined, undefined); + expect(result.current.data).toEqual(mockData); + }); + + it('should fetch with search parameter', async () => { + const mockData = { results: [], count: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }, 'john'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 0, pageSize: 10 }, 'john', undefined); + }); + + it('should fetch with filter parameter', async () => { + const mockData = { results: [], count: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }, undefined, 'received'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 0, pageSize: 10 }, undefined, 'received'); + }); + + it('should handle fetch error', async () => { + const apiError = new Error('API Error'); + mockGetIssuedCertificates.mockRejectedValue(apiError); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 0, pageSize: 10 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(apiError); + }); + + it('should use correct query key', async () => { + const mockData = { results: [], count: 0 }; + mockGetIssuedCertificates.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useIssuedCertificates('course-123', { page: 1, pageSize: 20 }, 'test', 'received'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetIssuedCertificates).toHaveBeenCalledWith('course-123', { page: 1, pageSize: 20 }, 'test', 'received'); + }); + }); + + describe('useCertificateGenerationHistory', () => { + it('should fetch generation history successfully', async () => { + const mockData = { results: [], count: 0 }; + mockGetCertificateGenerationHistory.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useCertificateGenerationHistory('course-456', { page: 0, pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetCertificateGenerationHistory).toHaveBeenCalledWith('course-456', { page: 0, pageSize: 20 }); + expect(result.current.data).toEqual(mockData); + }); + + it('should handle fetch error', async () => { + const apiError = new Error('History fetch failed'); + mockGetCertificateGenerationHistory.mockRejectedValue(apiError); + + const { result } = renderHook( + () => useCertificateGenerationHistory('course-456', { page: 0, pageSize: 20 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(apiError); + }); + }); + + describe('useRegenerateCertificatesMutation', () => { + it('should regenerate certificates successfully', async () => { + const mockResponse = { success: true }; + mockRegenerateCertificates.mockResolvedValue(mockResponse); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: createWrapper() } + ); + + result.current.mutate({ + courseId: 'course-789', + params: { statuses: ['downloadable'] }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRegenerateCertificates).toHaveBeenCalledWith('course-789', { statuses: ['downloadable'] }); + expect(result.current.data).toEqual(mockResponse); + }); + + it('should regenerate with studentSet parameter', async () => { + const mockResponse = { success: true }; + mockRegenerateCertificates.mockResolvedValue(mockResponse); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: createWrapper() } + ); + + result.current.mutate({ + courseId: 'course-789', + params: { studentSet: 'allowlisted' }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockRegenerateCertificates).toHaveBeenCalledWith('course-789', { studentSet: 'allowlisted' }); + }); + + it('should handle mutation error', async () => { + mockRegenerateCertificates.mockRejectedValue(new Error('Regeneration failed')); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: createWrapper() } + ); + + result.current.mutate({ + courseId: 'course-789', + params: {}, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(new Error('Regeneration failed')); + }); + + it('should invalidate queries on success', async () => { + const mockResponse = { success: true }; + mockRegenerateCertificates.mockResolvedValue(mockResponse); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => useRegenerateCertificatesMutation(), + { wrapper: Wrapper } + ); + + result.current.mutate({ + courseId: 'course-789', + params: {}, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: certificatesQueryKeys.lists() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: certificatesQueryKeys.all }); + }); + }); + + describe('useCertificateConfig', () => { + it('should fetch certificate config successfully', async () => { + const mockData = { enabled: true }; + mockGetCertificateConfig.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useCertificateConfig('course-999'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockGetCertificateConfig).toHaveBeenCalledWith('course-999'); + expect(result.current.data).toEqual(mockData); + }); + + it('should handle disabled certificates', async () => { + const mockData = { enabled: false }; + mockGetCertificateConfig.mockResolvedValue(mockData); + + const { result } = renderHook( + () => useCertificateConfig('course-999'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual({ enabled: false }); + }); + + it('should handle fetch error', async () => { + const apiError = { response: { status: 404 } }; + mockGetCertificateConfig.mockRejectedValue(apiError); + + const { result } = renderHook( + () => useCertificateConfig('course-999'), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(apiError); + }); + }); +}); diff --git a/src/certificates/data/queryKeys.test.ts b/src/certificates/data/queryKeys.test.ts new file mode 100644 index 00000000..999b01dc --- /dev/null +++ b/src/certificates/data/queryKeys.test.ts @@ -0,0 +1,128 @@ +import { certificatesQueryKeys } from './queryKeys'; + +describe('certificatesQueryKeys', () => { + it('should generate all key', () => { + expect(certificatesQueryKeys.all).toEqual(['certificates']); + }); + + it('should generate lists key', () => { + expect(certificatesQueryKeys.lists()).toEqual(['certificates', 'list']); + }); + + it('should generate list key with all parameters', () => { + const result = certificatesQueryKeys.list( + 'course-123', + { page: 0, pageSize: 10 }, + 'john', + 'received' + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-123', + { page: 0, pageSize: 10 }, + 'john', + 'received', + ]); + }); + + it('should generate list key without search and filter', () => { + const result = certificatesQueryKeys.list( + 'course-456', + { page: 1, pageSize: 20 } + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-456', + { page: 1, pageSize: 20 }, + undefined, + undefined, + ]); + }); + + it('should generate list key with search only', () => { + const result = certificatesQueryKeys.list( + 'course-789', + { page: 2, pageSize: 15 }, + 'jane' + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-789', + { page: 2, pageSize: 15 }, + 'jane', + undefined, + ]); + }); + + it('should generate list key with filter only', () => { + const result = certificatesQueryKeys.list( + 'course-999', + { page: 0, pageSize: 10 }, + undefined, + 'error' + ); + + expect(result).toEqual([ + 'certificates', + 'list', + 'course-999', + { page: 0, pageSize: 10 }, + undefined, + 'error', + ]); + }); + + it('should generate history key', () => { + const result = certificatesQueryKeys.history( + 'course-111', + { page: 0, pageSize: 20 } + ); + + expect(result).toEqual([ + 'certificates', + 'history', + 'course-111', + { page: 0, pageSize: 20 }, + ]); + }); + + it('should generate config key', () => { + const result = certificatesQueryKeys.config('course-222'); + + expect(result).toEqual(['certificates', 'config', 'course-222']); + }); + + it('should generate different keys for different courses', () => { + const key1 = certificatesQueryKeys.list('course-a', { page: 0, pageSize: 10 }); + const key2 = certificatesQueryKeys.list('course-b', { page: 0, pageSize: 10 }); + + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys for different pagination', () => { + const key1 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }); + const key2 = certificatesQueryKeys.list('course-123', { page: 1, pageSize: 10 }); + + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys for different search terms', () => { + const key1 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, 'john'); + const key2 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, 'jane'); + + expect(key1).not.toEqual(key2); + }); + + it('should generate different keys for different filters', () => { + const key1 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, undefined, 'received'); + const key2 = certificatesQueryKeys.list('course-123', { page: 0, pageSize: 10 }, undefined, 'error'); + + expect(key1).not.toEqual(key2); + }); +}); From efbe00fea393a7e157e6fb7b167b8112b055c125 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 18:12:16 -0400 Subject: [PATCH 6/7] fix: lint --- src/certificates/CertificatesPage.test.tsx | 1 - .../components/IssuedCertificates.test.tsx | 10 +++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/certificates/CertificatesPage.test.tsx b/src/certificates/CertificatesPage.test.tsx index e6ff906c..1e53f358 100644 --- a/src/certificates/CertificatesPage.test.tsx +++ b/src/certificates/CertificatesPage.test.tsx @@ -153,7 +153,6 @@ describe('CertificatesPage', () => { }); }); - it('should render certificates page when config is enabled', () => { mockUseCertificateConfig.mockReturnValue({ data: { enabled: true }, diff --git a/src/certificates/components/IssuedCertificates.test.tsx b/src/certificates/components/IssuedCertificates.test.tsx index 98fadc2c..4d9c07ea 100644 --- a/src/certificates/components/IssuedCertificates.test.tsx +++ b/src/certificates/components/IssuedCertificates.test.tsx @@ -405,11 +405,11 @@ describe('IssuedCertificates', () => { await waitFor(() => { const calls = mockUseIssuedCertificates.mock.calls; const hasExpectedCall = calls.some(call => - call[0] === 'course-123' && - call[1].page === 0 && - call[1].pageSize === 10 && - call[2] === 'test' && - call[3] === 'all' + call[0] === 'course-123' + && call[1].page === 0 + && call[1].pageSize === 10 + && call[2] === 'test' + && call[3] === 'all' ); expect(hasExpectedCall).toBe(true); }, { timeout: 1000 }); From 02a86fbf54b6a9e78946e7619935a3e9c6613901 Mon Sep 17 00:00:00 2001 From: Jesse Stewart Date: Fri, 13 Mar 2026 18:25:09 -0400 Subject: [PATCH 7/7] fix: tests --- src/certificates/data/apiHook.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/certificates/data/apiHook.test.tsx b/src/certificates/data/apiHook.test.tsx index 5591c93c..de3f125c 100644 --- a/src/certificates/data/apiHook.test.tsx +++ b/src/certificates/data/apiHook.test.tsx @@ -32,7 +32,7 @@ describe('certificates apiHook', () => { describe('useIssuedCertificates', () => { it('should fetch issued certificates successfully', async () => { - const mockData = { results: [], count: 0 }; + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; mockGetIssuedCertificates.mockResolvedValue(mockData); const { result } = renderHook( @@ -49,7 +49,7 @@ describe('certificates apiHook', () => { }); it('should fetch with search parameter', async () => { - const mockData = { results: [], count: 0 }; + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; mockGetIssuedCertificates.mockResolvedValue(mockData); const { result } = renderHook( @@ -65,7 +65,7 @@ describe('certificates apiHook', () => { }); it('should fetch with filter parameter', async () => { - const mockData = { results: [], count: 0 }; + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; mockGetIssuedCertificates.mockResolvedValue(mockData); const { result } = renderHook( @@ -97,7 +97,7 @@ describe('certificates apiHook', () => { }); it('should use correct query key', async () => { - const mockData = { results: [], count: 0 }; + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; mockGetIssuedCertificates.mockResolvedValue(mockData); const { result } = renderHook( @@ -115,7 +115,7 @@ describe('certificates apiHook', () => { describe('useCertificateGenerationHistory', () => { it('should fetch generation history successfully', async () => { - const mockData = { results: [], count: 0 }; + const mockData = { results: [], count: 0, next: null, previous: null, numPages: 0 }; mockGetCertificateGenerationHistory.mockResolvedValue(mockData); const { result } = renderHook(