diff --git a/django_email_learning/personalised/views.py b/django_email_learning/personalised/views.py index 309747dd..972eafb0 100644 --- a/django_email_learning/personalised/views.py +++ b/django_email_learning/personalised/views.py @@ -335,6 +335,7 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty "django_email_learning:api_personalised:submit_certificate_form" ), "localeMessages": { + "form_title": _("Certificate of Completion"), "form_intro": _( "Congratulations on completing the course! To issue your certificate, please enter the name you would like displayed on it." ), diff --git a/frontend/personalised/assignment_public/Assignment.jsx b/frontend/personalised/assignment_public/Assignment.jsx index 77973a7a..f6d1c329 100644 --- a/frontend/personalised/assignment_public/Assignment.jsx +++ b/frontend/personalised/assignment_public/Assignment.jsx @@ -211,4 +211,6 @@ const Assignment = () => { }; +export { Assignment }; + render({ children: }); diff --git a/frontend/personalised/certificate/Certificate.jsx b/frontend/personalised/certificate/Certificate.jsx index 71e9e646..67ad8d67 100644 --- a/frontend/personalised/certificate/Certificate.jsx +++ b/frontend/personalised/certificate/Certificate.jsx @@ -167,4 +167,6 @@ const CertificateContent = () => { } +export { Certificate }; + render({children: }); diff --git a/frontend/personalised/certificate_form/CertificateForm.jsx b/frontend/personalised/certificate_form/CertificateForm.jsx index 2905bd2b..02275293 100644 --- a/frontend/personalised/certificate_form/CertificateForm.jsx +++ b/frontend/personalised/certificate_form/CertificateForm.jsx @@ -105,4 +105,6 @@ const CertificateForm = () => { ); }; +export { CertificateForm }; + render({children: }); diff --git a/frontend/personalised/quiz_public/Quiz.jsx b/frontend/personalised/quiz_public/Quiz.jsx index 6783aac1..27970c51 100644 --- a/frontend/personalised/quiz_public/Quiz.jsx +++ b/frontend/personalised/quiz_public/Quiz.jsx @@ -196,4 +196,5 @@ const Quiz = () => { } +export { Quiz }; render({children: }); diff --git a/frontend/src/test/Assignment.test.jsx b/frontend/src/test/Assignment.test.jsx new file mode 100644 index 00000000..e0629f65 --- /dev/null +++ b/frontend/src/test/Assignment.test.jsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import { Assignment } from '../../personalised/assignment_public/Assignment.jsx'; + +vi.mock('../render.jsx'); + +const sampleAssignment = { + id: 1, + title: 'Write a Report', + description: 'Write a short report about React.', + requires_text_submission: true, + requires_file_submission: false, +}; + +const sampleLocaleMessages = { + text_submission_label: 'Your Answer', + file_submission_label: 'Upload Your File', + submission_success: 'Your assignment has been submitted successfully!', + submission_error: 'An error occurred while submitting your assignment.', + submit: 'Submit', + close_window_message: 'You can now close this window!', + text_submission_required: 'Text submission is required.', + file_submission_required: 'File submission is required.', + error: 'Error', +}; + +const defaultAppContext = { + assignment: sampleAssignment, + token: 'test-token', + csrfToken: 'csrf-token', + apiEndpoint: '/api/assignment/submit/', + fileUploadApiEndpoint: '/api/file/upload/', + localeMessages: sampleLocaleMessages, + direction: 'ltr', +}; + +describe('Assignment', () => { + beforeEach(() => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ message: 'Submitted!' }), + }); + }); + + it('renders the assignment title', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Write a Report')).toBeInTheDocument(); + }); + + it('renders the assignment description', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Write a short report about React.')).toBeInTheDocument(); + }); + + it('renders the text submission field when requires_text_submission is true', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByLabelText('Your Answer')).toBeInTheDocument(); + }); + + it('does not render the text submission field when requires_text_submission is false', () => { + const ctx = { + ...defaultAppContext, + assignment: { ...sampleAssignment, requires_text_submission: false }, + }; + renderWithProviders(, { appContext: ctx }); + expect(screen.queryByLabelText('Your Answer')).not.toBeInTheDocument(); + }); + + it('renders the file upload section when requires_file_submission is true', () => { + const ctx = { + ...defaultAppContext, + assignment: { + ...sampleAssignment, + requires_text_submission: false, + requires_file_submission: true, + }, + }; + renderWithProviders(, { appContext: ctx }); + const fileLabels = screen.getAllByText('Upload Your File'); + expect(fileLabels.length).toBeGreaterThan(0); + }); + + it('renders the submit button', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + it('shows a validation error when text is required but empty', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Text submission is required.')).toBeInTheDocument() + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('shows success message after successful submission', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText('Your Answer'), { + target: { value: 'My answer text' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByText('Submitted!')).toBeInTheDocument() + ); + expect(screen.getByText('You can now close this window!')).toBeInTheDocument(); + }); + + it('posts the text submission to the api endpoint', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText('Your Answer'), { + target: { value: 'My answer' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe('/api/assignment/submit/'); + expect(options.method).toBe('POST'); + expect(options.headers['X-CSRFToken']).toBe('csrf-token'); + const body = JSON.parse(options.body); + expect(body.text_submission).toBe('My answer'); + expect(body.token).toBe('test-token'); + }); + + it('shows an error alert when submission fails', async () => { + global.fetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: 'Server error' }), + }); + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText('Your Answer'), { + target: { value: 'My answer' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument()); + }); + + it('shows error alert when errorMessage is present', () => { + renderWithProviders(, { + appContext: { ...defaultAppContext, errorMessage: 'Link expired', ref: 'ref-xyz' }, + }); + expect(screen.getByText(/Link expired/)).toBeInTheDocument(); + expect(screen.getByText(/ref-xyz/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/Certificate.test.jsx b/frontend/src/test/Certificate.test.jsx new file mode 100644 index 00000000..a77fd0f9 --- /dev/null +++ b/frontend/src/test/Certificate.test.jsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import { Certificate } from '../../personalised/certificate/Certificate.jsx'; + +vi.mock('../render.jsx'); + +const defaultAppContext = { + name: 'Jane Doe', + issueDate: 'January 01, 2025', + certificateNumber: 'ORG-COURSE-42-abc123', + qrcodeUrl: 'https://example.com/qr.png', + logoUrl: 'https://example.com/logo.png', + localeMessages: { + title: 'Certificate of Completion', + description: 'This certifies that Jane Doe has successfully completed the React Fundamentals course', + issue_date: 'Issued on', + certificate_number: 'Certificate Number', + organization_team: 'Acme Team', + }, +}; + +describe('Certificate', () => { + it('renders the certificate title', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Certificate of Completion')).toBeInTheDocument(); + }); + + it('renders the recipient name in the description', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText(/Jane Doe/)).toBeInTheDocument(); + }); + + it('renders the issue date', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText(/Issued on/)).toBeInTheDocument(); + expect(screen.getByText(/January 01, 2025/)).toBeInTheDocument(); + }); + + it('renders the certificate number', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText(/Certificate Number/)).toBeInTheDocument(); + expect(screen.getByText(/ORG-COURSE-42-abc123/)).toBeInTheDocument(); + }); + + it('renders the organization team name', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Acme Team')).toBeInTheDocument(); + }); + + it('renders the QR code image', () => { + renderWithProviders(, { appContext: defaultAppContext }); + const qrImg = screen.getByAltText('QR Code'); + expect(qrImg).toBeInTheDocument(); + expect(qrImg).toHaveAttribute('src', 'https://example.com/qr.png'); + }); + + it('renders the organization logo when provided', () => { + renderWithProviders(, { appContext: defaultAppContext }); + const logoImg = screen.getByAltText('Organization Logo'); + expect(logoImg).toBeInTheDocument(); + expect(logoImg).toHaveAttribute('src', 'https://example.com/logo.png'); + }); + + it('does not render the organization logo when logoUrl is empty', () => { + renderWithProviders(, { + appContext: { ...defaultAppContext, logoUrl: '' }, + }); + expect(screen.queryByAltText('Organization Logo')).not.toBeInTheDocument(); + }); + + it('shows an error alert when errorMessage is present', () => { + renderWithProviders(, { + appContext: { ...defaultAppContext, errorMessage: 'Certificate not found' }, + }); + expect(screen.getByText('Certificate not found')).toBeInTheDocument(); + expect(screen.queryByText('Certificate of Completion')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/CertificateForm.test.jsx b/frontend/src/test/CertificateForm.test.jsx new file mode 100644 index 00000000..0fad0cce --- /dev/null +++ b/frontend/src/test/CertificateForm.test.jsx @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import { CertificateForm } from '../../personalised/certificate_form/CertificateForm.jsx'; + +vi.mock('../render.jsx'); + +const sampleLocaleMessages = { + form_title: 'Certificate of Completion', + form_intro: 'Congratulations! Enter the name you would like on your certificate.', + full_name: 'Full Name', + full_name_required: 'Full Name is required', + error_sending_data: 'An error occurred while sending data. Please try again later.', + form_submission_success: 'Your certificate name has been submitted successfully!', + submit: 'Submit', + view_certificate: 'View Certificate', +}; + +const defaultAppContext = { + localeMessages: sampleLocaleMessages, + apiEndpoint: '/api/certificate/submit/', + token: 'test-token', + csrfToken: 'csrf-token', +}; + +describe('CertificateForm', () => { + beforeEach(() => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ certificate_url: '/certificates/ORG-COURSE-42-abc123/' }), + }); + }); + + it('renders the form title', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Certificate of Completion')).toBeInTheDocument(); + }); + + it('renders the intro text', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect( + screen.getByText('Congratulations! Enter the name you would like on your certificate.') + ).toBeInTheDocument(); + }); + + it('renders the full name input field', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByLabelText(/Full Name/)).toBeInTheDocument(); + }); + + it('renders the submit button', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + it('shows a validation error when name is empty', async () => { + const { container } = renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.submit(container.querySelector('form')); + await waitFor(() => + expect(screen.getByText('Full Name is required')).toBeInTheDocument() + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('submits the form with the entered name', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText(/Full Name/), { + target: { value: 'Jane Doe' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const [, options] = global.fetch.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body.name).toBe('Jane Doe'); + expect(body.token).toBe('test-token'); + expect(options.headers['X-CSRFToken']).toBe('csrf-token'); + }); + + it('shows success alert after successful submission', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText(/Full Name/), { + target: { value: 'Jane Doe' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect( + screen.getByText('Your certificate name has been submitted successfully!') + ).toBeInTheDocument() + ); + }); + + it('shows the View Certificate button after successful submission', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText(/Full Name/), { + target: { value: 'Jane Doe' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect(screen.getByRole('link', { name: 'View Certificate' })).toBeInTheDocument() + ); + expect(screen.getByRole('link', { name: 'View Certificate' })).toHaveAttribute( + 'href', + '/certificates/ORG-COURSE-42-abc123/' + ); + }); + + it('shows an error alert when the API call fails', async () => { + global.fetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({}), + }); + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.change(screen.getByLabelText(/Full Name/), { + target: { value: 'Jane Doe' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + await waitFor(() => + expect( + screen.getByText('An error occurred while sending data. Please try again later.') + ).toBeInTheDocument() + ); + }); +}); diff --git a/frontend/src/test/Quiz.test.jsx b/frontend/src/test/Quiz.test.jsx new file mode 100644 index 00000000..b6a5ac31 --- /dev/null +++ b/frontend/src/test/Quiz.test.jsx @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from './test-utils'; +import { Quiz } from '../../personalised/quiz_public/Quiz.jsx'; + +vi.mock('../render.jsx'); + +const sampleQuiz = { + id: 1, + title: 'Sample Quiz', + is_blocking: true, + questions: [ + { + id: 10, + text: 'What is 2 + 2?', + answers: [ + { id: 100, text: 'Three' }, + { id: 101, text: 'Four' }, + ], + }, + { + id: 11, + text: 'What color is the sky?', + answers: [ + { id: 200, text: 'Blue' }, + { id: 201, text: 'Green' }, + ], + }, + ], +}; + +const sampleLocaleMessages = { + quiz_intro: 'Select all correct answers.', + no_answer_warning: 'You have not selected any answers.', + your_score: 'Your score', + error_loading_quiz: 'Error loading quiz', + ready_to_submit: 'Ready to submit?', + submit_quiz_note: 'Note about negative marking.', + cancel: 'Cancel', + submit: 'Submit', + try_again: 'Try Again', + close_window_message: 'You can now close this window!', + non_blocking_quiz_caption: 'This quiz is for practice.', + correct_answer: 'Correct answer', + error: 'Error', +}; + +const defaultAppContext = { + quiz: sampleQuiz, + token: 'test-token', + csrfToken: 'csrf-token', + apiEndpoint: '/api/quiz/submit/', + localeMessages: sampleLocaleMessages, + direction: 'ltr', +}; + +describe('Quiz', () => { + beforeEach(() => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ passed: true, score: 80, message: 'Well done!', is_invalidated: true }), + }); + }); + + it('renders the quiz title', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Sample Quiz')).toBeInTheDocument(); + }); + + it('renders all quiz questions', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('What is 2 + 2?')).toBeInTheDocument(); + expect(screen.getByText('What color is the sky?')).toBeInTheDocument(); + }); + + it('renders quiz intro text', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Select all correct answers.')).toBeInTheDocument(); + }); + + it('renders answer checkboxes for each question', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByText('Three')).toBeInTheDocument(); + expect(screen.getByText('Four')).toBeInTheDocument(); + expect(screen.getByText('Blue')).toBeInTheDocument(); + expect(screen.getByText('Green')).toBeInTheDocument(); + }); + + it('renders the submit button', () => { + renderWithProviders(, { appContext: defaultAppContext }); + expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); + }); + + it('opens the confirmation dialog when submit is clicked', () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByText('Ready to submit?')).toBeInTheDocument(); + }); + + it('shows a warning when submitting with no answers selected', () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByText('You have not selected any answers.')).toBeInTheDocument(); + }); + + it('does not show warning when at least one answer is selected', () => { + renderWithProviders(, { appContext: defaultAppContext }); + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[0]); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.queryByText('You have not selected any answers.')).not.toBeInTheDocument(); + }); + + it('closes the dialog when Cancel is clicked', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByText('Ready to submit?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => + expect(screen.queryByText('Ready to submit?')).not.toBeInTheDocument() + ); + }); + + it('shows score and passed message after successful submission', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Submit' }).at(-1)); + await waitFor(() => expect(screen.getByText(/Well done!/)).toBeInTheDocument()); + expect(screen.getByText(/80%/)).toBeInTheDocument(); + }); + + it('posts to the api endpoint on submission', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Submit' }).at(-1)); + await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe('/api/quiz/submit/'); + expect(options.method).toBe('POST'); + expect(options.headers['X-CSRFToken']).toBe('csrf-token'); + }); + + it('shows error alert when errorMessage is present', () => { + renderWithProviders(, { + appContext: { ...defaultAppContext, errorMessage: 'Quiz not found', ref: 'abc123' }, + }); + expect(screen.getByText(/Quiz not found/)).toBeInTheDocument(); + expect(screen.getByText(/abc123/)).toBeInTheDocument(); + }); + + it('shows non-blocking caption after submission for non-blocking quiz', async () => { + const nonBlockingContext = { + ...defaultAppContext, + quiz: { ...sampleQuiz, is_blocking: false }, + }; + renderWithProviders(, { appContext: nonBlockingContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Submit' }).at(-1)); + await waitFor(() => expect(screen.getByText(/Well done!/)).toBeInTheDocument()); + expect(screen.getByText('This quiz is for practice.')).toBeInTheDocument(); + }); + + it('shows close window message after invalidated submission', async () => { + renderWithProviders(, { appContext: defaultAppContext }); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Submit' }).at(-1)); + await waitFor(() => expect(screen.getByText('You can now close this window!')).toBeInTheDocument()); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 35feed9d..37ed23e5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -34,7 +34,7 @@ export default defineConfig({ '@emotion/styled', ], // Force pre-bundling for MPA entry pages. - entries: ['./platform/courses/Courses.jsx', './platform/course/Course.jsx', './platform/organizations/Organizations.jsx', './platform/learners/Learners.jsx', './platform/settings_api_keys/SettingsApiKeys.jsx', './public/organization/Organization.jsx', './personalised/quiz_public/QuizPublic.jsx', './personalised/assignment_public/Assignment.jsx', './personalised/command_result/CommandResult.jsx'], + entries: ['./platform/courses/Courses.jsx', './platform/course/Course.jsx', './platform/organizations/Organizations.jsx', './platform/learners/Learners.jsx', './platform/settings_api_keys/SettingsApiKeys.jsx', './public/organization/Organization.jsx', './personalised/quiz_public/Quiz.jsx', './personalised/assignment_public/Assignment.jsx', './personalised/command_result/CommandResult.jsx'], }, build: { minify: 'terser',