From 43886aef7c88cc553a5a40c66d96920af407d1ed Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 12:07:33 +0000
Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20add=20compo?=
=?UTF-8?q?nent=20tests=20for=20all=208=20shared=20frontend=20components?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Agent-Logs-Url: https://github.com/AvaCodeSolutions/django-email-learning/sessions/441cafc3-06cd-4339-aade-0b3dd794af20
Co-authored-by: payamnj <11951509+payamnj@users.noreply.github.com>
---
frontend/src/__mocks__/render.jsx | 5 +
frontend/src/test/Base.test.jsx | 103 +++++++++++
frontend/src/test/BottomDrawer.test.jsx | 59 +++++++
frontend/src/test/ContentEditor.test.jsx | 86 +++++++++
frontend/src/test/FileUpload.test.jsx | 135 ++++++++++++++
frontend/src/test/ImageUpload.test.jsx | 145 +++++++++++++++
frontend/src/test/MenuBar.test.jsx | 176 +++++++++++++++++++
frontend/src/test/RequiredTextField.test.jsx | 61 +++++++
frontend/src/test/ThemeSwitcher.test.jsx | 44 +++++
frontend/src/test/setup.js | 12 ++
10 files changed, 826 insertions(+)
create mode 100644 frontend/src/__mocks__/render.jsx
create mode 100644 frontend/src/test/Base.test.jsx
create mode 100644 frontend/src/test/BottomDrawer.test.jsx
create mode 100644 frontend/src/test/ContentEditor.test.jsx
create mode 100644 frontend/src/test/FileUpload.test.jsx
create mode 100644 frontend/src/test/ImageUpload.test.jsx
create mode 100644 frontend/src/test/MenuBar.test.jsx
create mode 100644 frontend/src/test/RequiredTextField.test.jsx
create mode 100644 frontend/src/test/ThemeSwitcher.test.jsx
diff --git a/frontend/src/__mocks__/render.jsx b/frontend/src/__mocks__/render.jsx
new file mode 100644
index 0000000..c5cf31e
--- /dev/null
+++ b/frontend/src/__mocks__/render.jsx
@@ -0,0 +1,5 @@
+// Manual mock for src/render.jsx
+// Re-exports useAppContext backed by the test AppContext so components
+// rendered via renderWithProviders(…) receive the correct context values.
+export { useAppContext } from '../test/test-utils.jsx';
+export default vi.fn();
diff --git a/frontend/src/test/Base.test.jsx b/frontend/src/test/Base.test.jsx
new file mode 100644
index 0000000..316765d
--- /dev/null
+++ b/frontend/src/test/Base.test.jsx
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import Base from '../components/Base';
+
+vi.mock('../render.jsx');
+
+// MenuBar fetches job status and organizations on mount.
+// Provide benign responses so state updates don't throw.
+function setupFetch() {
+ global.fetch.mockImplementation((url) => {
+ if (url.includes('/status/jobs/')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ jobs: { deliver_contents: null } }),
+ });
+ }
+ if (url.includes('/organizations/')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ organizations: [] }),
+ });
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+ });
+}
+
+describe('Base', () => {
+ beforeEach(() => {
+ setupFetch();
+ });
+
+ it('renders children', () => {
+ renderWithProviders(
+
+
Page content
+
+ );
+ expect(screen.getByText('Page content')).toBeInTheDocument();
+ });
+
+ it('renders a single breadcrumb as the active (non-linked) crumb', () => {
+ renderWithProviders(
+
+
+
+ );
+ // The last crumb is rendered as Typography, not a link
+ expect(screen.getByText('Dashboard')).toBeInTheDocument();
+ });
+
+ it('renders intermediate breadcrumbs as links', () => {
+ renderWithProviders(
+
+
+
+ );
+ expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
+ expect(screen.getByRole('link', { name: 'Courses' })).toHaveAttribute('href', '/courses');
+ // Last crumb is plain text, not a link
+ expect(screen.getByText('Module 1')).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'Module 1' })).not.toBeInTheDocument();
+ });
+
+ it('renders the AvaCode Solutions footer link', () => {
+ renderWithProviders(
+
+
+
+ );
+ expect(screen.getByRole('link', { name: /avacode solutions/i })).toBeInTheDocument();
+ });
+
+ it('calls organizationIdRefreshCallback when organization changes', async () => {
+ const onOrgRefresh = vi.fn();
+ renderWithProviders(
+
+
+
+ );
+ // Called immediately on mount with null (initial state)
+ await waitFor(() => expect(onOrgRefresh).toHaveBeenCalledWith(null));
+ });
+
+ it('renders without BottomDrawer when bottomDrawerParams is omitted', () => {
+ renderWithProviders(
+
+
+
+ );
+ // FAB for BottomDrawer must not be present
+ expect(screen.queryByRole('button', { name: /filter list/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/BottomDrawer.test.jsx b/frontend/src/test/BottomDrawer.test.jsx
new file mode 100644
index 0000000..dd36b93
--- /dev/null
+++ b/frontend/src/test/BottomDrawer.test.jsx
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import BottomDrawer from '../components/BottomDrawer';
+
+// vite/modulepreload-polyfill is mocked globally in setup.js
+
+describe('BottomDrawer', () => {
+ it('renders children inside the drawer when it is open', () => {
+ renderWithProviders(
+ icon}>
+ Filter options
+
+ );
+ // Drawer children are only mounted after the FAB is clicked
+ fireEvent.click(screen.getByRole('button', { name: /filter list/i }));
+ expect(screen.getByText('Filter options')).toBeInTheDocument();
+ });
+
+ it('renders the FAB trigger button', () => {
+ renderWithProviders(
+ icon}>
+ Content
+
+ );
+ expect(screen.getByRole('button', { name: /filter list/i })).toBeInTheDocument();
+ });
+
+ it('clicking the FAB opens the drawer (content accessible)', () => {
+ renderWithProviders(
+ icon}>
+ Drawer content
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /filter list/i }));
+ expect(screen.getByText('Drawer content')).toBeInTheDocument();
+ });
+
+ it('accepts any icon element', () => {
+ const { container } = renderWithProviders(
+ ★}>
+ Content
+
+ );
+ expect(container.querySelector('[data-testid="custom-icon"]')).toBeInTheDocument();
+ });
+
+ it('renders multiple children inside the drawer when open', () => {
+ renderWithProviders(
+ icon}>
+ Child One
+ Child Two
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /filter list/i }));
+ expect(screen.getByText('Child One')).toBeInTheDocument();
+ expect(screen.getByText('Child Two')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/ContentEditor.test.jsx b/frontend/src/test/ContentEditor.test.jsx
new file mode 100644
index 0000000..96074ff
--- /dev/null
+++ b/frontend/src/test/ContentEditor.test.jsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import ContentEditor from '../components/ContentEditor';
+
+vi.mock('../render.jsx');
+
+// ContentEditor returns null until Tiptap's useEditor initialises the
+// ProseMirror instance. All assertions are wrapped in waitFor so they
+// only run once the toolbar has mounted.
+
+describe('ContentEditor', () => {
+ it('renders the formatting toolbar', async () => {
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => expect(screen.getByText('H1')).toBeInTheDocument());
+ expect(screen.getByText('H2')).toBeInTheDocument();
+ expect(screen.getByText('H3')).toBeInTheDocument();
+ });
+
+ it('renders Undo and Redo buttons in the toolbar', async () => {
+ renderWithProviders(
+
+ );
+
+ // Wait for the editor to initialise (returns null until ready)
+ await waitFor(() => expect(screen.getByText('H1')).toBeInTheDocument());
+
+ // The toolbar contains many icon-only buttons: H1, H2, H3, Undo, Redo,
+ // Bold, Italic, Code-block, link, alignment, image, etc.
+ const toolbarButtons = screen.getAllByRole('button');
+ expect(toolbarButtons.length).toBeGreaterThan(8);
+ });
+
+ it('hides the toolbar when disabled=true', async () => {
+ renderWithProviders(
+
+ );
+
+ // The editor itself is still mounted (content is readable), but the
+ // toolbar must not be rendered.
+ await waitFor(() =>
+ expect(screen.queryByText('H1')).not.toBeInTheDocument()
+ );
+ });
+
+ it('renders initial HTML content inside the editor area', async () => {
+ renderWithProviders(
+
+ );
+
+ await waitFor(() =>
+ expect(screen.getByText('Welcome to the editor')).toBeInTheDocument()
+ );
+ });
+
+ it('calls editorInstanceCallback with the editor instance', async () => {
+ const onEditor = vi.fn();
+ renderWithProviders(
+
+ );
+
+ await waitFor(() => expect(onEditor).toHaveBeenCalledWith(expect.objectContaining({
+ chain: expect.any(Function),
+ })));
+ });
+});
diff --git a/frontend/src/test/FileUpload.test.jsx b/frontend/src/test/FileUpload.test.jsx
new file mode 100644
index 0000000..d0e4b6a
--- /dev/null
+++ b/frontend/src/test/FileUpload.test.jsx
@@ -0,0 +1,135 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import FileUpload from '../components/FileUpload';
+
+const defaultProps = {
+ uploadApiEndpoint: '/api/upload/',
+ token: 'test-token',
+ csrfToken: 'csrf-token',
+ onUploadSuccess: vi.fn(),
+ onUploadError: vi.fn(),
+};
+
+describe('FileUpload', () => {
+ beforeEach(() => {
+ defaultProps.onUploadSuccess.mockClear();
+ defaultProps.onUploadError.mockClear();
+ });
+
+ it('renders the upload button with the default label', () => {
+ renderWithProviders();
+ expect(screen.getByText('Upload File')).toBeInTheDocument();
+ });
+
+ it('renders a custom upload label', () => {
+ renderWithProviders();
+ expect(screen.getByText('Attach Document')).toBeInTheDocument();
+ });
+
+ it('renders helper text when provided', () => {
+ renderWithProviders();
+ expect(screen.getByText('Max size 5 MB')).toBeInTheDocument();
+ });
+
+ it('shows "Uploading…" while the request is in-flight', async () => {
+ let resolveUpload;
+ global.fetch.mockReturnValue(
+ new Promise((resolve) => {
+ resolveUpload = resolve;
+ })
+ );
+ const { container } = renderWithProviders();
+ const input = container.querySelector('input[type="file"]');
+ fireEvent.change(input, { target: { files: [new File(['x'], 'doc.pdf', { type: 'application/pdf' })] } });
+
+ await waitFor(() => expect(screen.getByText('Uploading...')).toBeInTheDocument());
+
+ // Settle the promise so act() can clean up
+ resolveUpload({ ok: true, json: () => Promise.resolve({ file_name: 'doc.pdf' }) });
+ });
+
+ it('shows the filename and remove button after a successful upload', async () => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ file_name: 'report.pdf' }),
+ });
+ const onUploadSuccess = vi.fn();
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['x'], 'report.pdf', { type: 'application/pdf' })] },
+ });
+
+ await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
+ expect(screen.getByText('Remove File')).toBeInTheDocument();
+ expect(onUploadSuccess).toHaveBeenCalledWith({ file_name: 'report.pdf' });
+ });
+
+ it('uses a custom remove label', async () => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ file_name: 'doc.pdf' }),
+ });
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['x'], 'doc.pdf', { type: 'application/pdf' })] },
+ });
+
+ await waitFor(() => expect(screen.getByText('Delete attachment')).toBeInTheDocument());
+ });
+
+ it('shows an error alert when the upload fails', async () => {
+ global.fetch.mockResolvedValue({
+ ok: false,
+ json: () => Promise.resolve({ error: 'File too large' }),
+ });
+ const { container } = renderWithProviders();
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['x'], 'big.pdf', { type: 'application/pdf' })] },
+ });
+
+ await waitFor(() => expect(screen.getByText('File too large')).toBeInTheDocument());
+ });
+
+ it('clears the uploaded file when remove is clicked', async () => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ file_name: 'doc.pdf' }),
+ });
+ const onUploadSuccess = vi.fn();
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['x'], 'doc.pdf', { type: 'application/pdf' })] },
+ });
+
+ await waitFor(() => expect(screen.getByText('doc.pdf')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByText('Remove File'));
+
+ expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
+ expect(onUploadSuccess).toHaveBeenLastCalledWith({ file_path: null, file_name: null });
+ });
+
+ it('sends the CSRF token and bearer token in the request', async () => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ file_name: 'f.pdf' }),
+ });
+ const { container } = renderWithProviders();
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['x'], 'f.pdf', { type: 'application/pdf' })] },
+ });
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
+ const [url, options] = global.fetch.mock.calls[0];
+ expect(url).toBe('/api/upload/');
+ expect(options.headers['X-CSRFToken']).toBe('csrf-token');
+ expect(options.method).toBe('POST');
+ });
+});
diff --git a/frontend/src/test/ImageUpload.test.jsx b/frontend/src/test/ImageUpload.test.jsx
new file mode 100644
index 0000000..f2feaba
--- /dev/null
+++ b/frontend/src/test/ImageUpload.test.jsx
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import ImageUpload from '../components/ImageUpload';
+
+vi.mock('../render.jsx');
+
+describe('ImageUpload', () => {
+ beforeEach(() => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ file_url: '/media/img.png', file_path: 'img.png' }),
+ });
+ });
+
+ it('shows the upload button when no initialUrl is given', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByText('Upload')).toBeInTheDocument();
+ });
+
+ it('shows the uploaded image when initialUrl is provided', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByAltText('Uploaded image')).toHaveAttribute(
+ 'src',
+ 'https://example.com/photo.png'
+ );
+ });
+
+ it('shows the remove button when an image is displayed', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByText('Remove')).toBeInTheDocument();
+ });
+
+ it('removes the image when the remove button is clicked', () => {
+ const onUploadSuccess = vi.fn();
+ renderWithProviders(
+
+ );
+ fireEvent.click(screen.getByText('Remove'));
+ expect(screen.queryByAltText('Uploaded image')).not.toBeInTheDocument();
+ expect(onUploadSuccess).toHaveBeenCalledWith({ file_url: null, file_path: null });
+ });
+
+ it('rejects WebP files and calls onUploadError', () => {
+ const onUploadError = vi.fn();
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['data'], 'photo.webp', { type: 'image/webp' })] },
+ });
+ expect(onUploadError).toHaveBeenCalledWith(expect.any(Error));
+ expect(onUploadError.mock.calls[0][0].message).toMatch(/webp/i);
+ });
+
+ it('rejects non-image files and calls onUploadError', () => {
+ const onUploadError = vi.fn();
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['data'], 'document.pdf', { type: 'application/pdf' })] },
+ });
+ expect(onUploadError).toHaveBeenCalledWith(expect.any(Error));
+ });
+
+ it('uploads a valid image and shows the result', async () => {
+ global.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ file_url: '/media/img.png', file_path: 'img.png' }),
+ });
+ const onUploadSuccess = vi.fn();
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['data'], 'photo.png', { type: 'image/png' })] },
+ });
+
+ await waitFor(() =>
+ expect(onUploadSuccess).toHaveBeenCalledWith({
+ file_url: '/media/img.png',
+ file_path: 'img.png',
+ })
+ );
+ expect(screen.getByAltText('Uploaded image')).toHaveAttribute('src', '/media/img.png');
+ });
+
+ it('calls onUploadError when the server returns an error response', async () => {
+ global.fetch.mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const onUploadError = vi.fn();
+ const { container } = renderWithProviders(
+
+ );
+ fireEvent.change(container.querySelector('input[type="file"]'), {
+ target: { files: [new File(['data'], 'photo.png', { type: 'image/png' })] },
+ });
+
+ await waitFor(() => expect(onUploadError).toHaveBeenCalledWith(expect.any(Error)));
+ });
+
+ it('updates the preview when initialUrl prop changes', async () => {
+ const { rerender } = renderWithProviders(
+
+ );
+ expect(screen.getByAltText('Uploaded image')).toHaveAttribute('src', 'https://example.com/old.png');
+
+ rerender(
+
+ );
+ // Rerender without providers — need to re-wrap; verify new URL is reflected
+ await waitFor(() =>
+ expect(screen.getByAltText('Uploaded image')).toHaveAttribute(
+ 'src',
+ 'https://example.com/new.png'
+ )
+ );
+ });
+});
diff --git a/frontend/src/test/MenuBar.test.jsx b/frontend/src/test/MenuBar.test.jsx
new file mode 100644
index 0000000..c3f6cc6
--- /dev/null
+++ b/frontend/src/test/MenuBar.test.jsx
@@ -0,0 +1,176 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithProviders } from './test-utils';
+import MenuBar from '../components/MenuBar';
+
+vi.mock('../render.jsx');
+
+// In jsdom, window.matchMedia always reports no matches, which makes MUI's
+// useMediaQuery return false. That causes the nav Drawer to use the
+// "temporary" variant (closed by default), so nav links are unmounted.
+// Mocking useMediaQuery to return true simulates an md+ screen, so the
+// Drawer is rendered as "permanent" and links are always in the DOM.
+vi.mock('@mui/material', async () => ({
+ ...(await vi.importActual('@mui/material')),
+ useMediaQuery: vi.fn(() => true),
+}));
+
+// ---------------------------------------------------------------------------
+// Default fetch mock — returns empty organizations and healthy job status.
+// Individual tests override this with vi.fn().mockImplementation when needed.
+// ---------------------------------------------------------------------------
+function setupDefaultFetch() {
+ global.fetch.mockImplementation((url) => {
+ if (url.includes('/status/jobs/')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ jobs: { deliver_contents: null } }),
+ });
+ }
+ if (url.includes('/organizations/')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ organizations: [] }),
+ });
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+ });
+}
+
+const defaultProps = {
+ activeOrganizationId: null,
+ changeOrganizationCallback: vi.fn(),
+ showOrganizationSwitcher: true,
+ drawerWidth: 250,
+};
+
+describe('MenuBar', () => {
+ beforeEach(() => {
+ setupDefaultFetch();
+ defaultProps.changeOrganizationCallback.mockClear();
+ });
+
+ it('renders the logo image', () => {
+ renderWithProviders();
+ const logos = screen.getAllByAltText('Logo');
+ expect(logos.length).toBeGreaterThan(0);
+ });
+
+ it('always shows the Courses navigation link', () => {
+ renderWithProviders();
+ expect(screen.getByText('Courses')).toBeInTheDocument();
+ });
+
+ it('does not show Organizations link for a regular user', () => {
+ renderWithProviders();
+ expect(screen.queryByText('Organizations')).not.toBeInTheDocument();
+ });
+
+ it('does not show Learners link for a regular user', () => {
+ renderWithProviders();
+ expect(screen.queryByText('Learners')).not.toBeInTheDocument();
+ });
+
+ it('shows Organizations link for organization admin', () => {
+ renderWithProviders(, {
+ appContext: { isOrganizationAdmin: true },
+ });
+ expect(screen.getByText('Organizations')).toBeInTheDocument();
+ });
+
+ it('shows Learners link for organization admin', () => {
+ renderWithProviders(, {
+ appContext: { isOrganizationAdmin: true },
+ });
+ expect(screen.getByText('Learners')).toBeInTheDocument();
+ });
+
+ it('shows Learners link for instructor', () => {
+ renderWithProviders(, {
+ appContext: { isInstructor: true },
+ });
+ expect(screen.getByText('Learners')).toBeInTheDocument();
+ });
+
+ it('shows Learners link for platform admin', () => {
+ renderWithProviders(, {
+ appContext: { isPlatformAdmin: true },
+ });
+ expect(screen.getByText('Learners')).toBeInTheDocument();
+ });
+
+ it('shows Settings menu item with API Keys for platform admin', () => {
+ renderWithProviders(, {
+ appContext: { isPlatformAdmin: true },
+ });
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+
+ it('populates the organization selector after fetch', async () => {
+ global.fetch.mockImplementation((url) => {
+ if (url.includes('/organizations/')) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({ organizations: [{ id: '1', name: 'Acme Corp' }] }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ jobs: { deliver_contents: null } }),
+ });
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText('Acme Corp')).toBeInTheDocument());
+ });
+
+ it('hides the organization selector when showOrganizationSwitcher is false', () => {
+ renderWithProviders();
+ // No combobox / select for org switching
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ });
+
+ it('shows content delivery chip for platform admin when job status is present', async () => {
+ global.fetch.mockImplementation((url) => {
+ if (url.includes('/status/jobs/')) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ jobs: {
+ deliver_contents: {
+ job_health_status: 'healthy',
+ last_execution_started_at: null,
+ },
+ },
+ }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ organizations: [] }),
+ });
+ });
+
+ renderWithProviders(, {
+ appContext: { isPlatformAdmin: true },
+ });
+
+ await waitFor(() =>
+ expect(screen.getByText('Content delivery')).toBeInTheDocument()
+ );
+ });
+
+ it('expands Settings sub-menu on click to reveal API Keys', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, {
+ appContext: { isPlatformAdmin: true },
+ });
+
+ await user.click(screen.getByText('Settings'));
+ expect(screen.getByText('API Keys')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/RequiredTextField.test.jsx b/frontend/src/test/RequiredTextField.test.jsx
new file mode 100644
index 0000000..dd89f00
--- /dev/null
+++ b/frontend/src/test/RequiredTextField.test.jsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import RequiredTextField from '../components/RequiredTextField';
+
+vi.mock('../render.jsx');
+
+describe('RequiredTextField', () => {
+ it('renders the label text', () => {
+ renderWithProviders();
+ // getByLabelText finds the input whose label matches — confirms the label is rendered
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
+ });
+
+ it('marks the input as required', () => {
+ renderWithProviders();
+ expect(screen.getByRole('textbox')).toBeRequired();
+ });
+
+ it('renders helper text', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByText('Enter full name')).toBeInTheDocument();
+ });
+
+ it('renders in error state with aria-invalid and helper text', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
+ expect(screen.getByText('This field is required')).toBeInTheDocument();
+ });
+
+ it('fires onChange when user types', () => {
+ const onChange = vi.fn();
+ renderWithProviders();
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Jane' } });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('applies dir="rtl" when context direction is rtl', () => {
+ const { container } = renderWithProviders(
+ ,
+ { appContext: { direction: 'rtl' } }
+ );
+ expect(container.querySelector('[dir="rtl"]')).toBeInTheDocument();
+ });
+
+ it('passes extra sx and props through to TextField', () => {
+ renderWithProviders(
+
+ );
+ expect(screen.getByTestId('user-input')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/ThemeSwitcher.test.jsx b/frontend/src/test/ThemeSwitcher.test.jsx
new file mode 100644
index 0000000..86c539b
--- /dev/null
+++ b/frontend/src/test/ThemeSwitcher.test.jsx
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { renderWithProviders } from './test-utils';
+import { lightTheme, darkTheme } from '../theme/themes';
+import ThemeSwitcher from '../components/ThemeSwitcher';
+
+vi.mock('../render.jsx');
+
+describe('ThemeSwitcher', () => {
+ it('shows "Dark" button when rendered with the light theme', () => {
+ renderWithProviders(, { theme: lightTheme });
+ expect(screen.getByText('Dark')).toBeInTheDocument();
+ });
+
+ it('shows "Light" button when rendered with the dark theme', () => {
+ renderWithProviders(, { theme: darkTheme });
+ expect(screen.getByText('Light')).toBeInTheDocument();
+ });
+
+ it('stores "dark" in localStorage when switching from light theme', () => {
+ renderWithProviders(, { theme: lightTheme });
+ fireEvent.click(screen.getByRole('button'));
+ expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
+ });
+
+ it('stores "light" in localStorage when switching from dark theme', () => {
+ renderWithProviders(, { theme: darkTheme });
+ fireEvent.click(screen.getByRole('button'));
+ expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'light');
+ });
+
+ it('toggles the button label after click', () => {
+ renderWithProviders(, { theme: lightTheme });
+ expect(screen.getByText('Dark')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByText('Light')).toBeInTheDocument();
+ });
+
+ it('shows the Dark mode icon in light theme', () => {
+ const { container } = renderWithProviders(, { theme: lightTheme });
+ // DarkModeRoundedIcon is rendered as an SVG inside the button
+ expect(container.querySelector('button svg')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js
index 25633c7..c89caa6 100644
--- a/frontend/src/test/setup.js
+++ b/frontend/src/test/setup.js
@@ -47,9 +47,21 @@ vi.mock('../assets/logo-h-dark.png', () => ({ default: 'logo-h-dark.png' }));
vi.mock('../assets/logo-v-light.png', () => ({ default: 'logo-v-light.png' }));
vi.mock('../assets/logo-v-dark.png', () => ({ default: 'logo-v-dark.png' }));
+// Mock Vite-specific virtual module (not available in jsdom environment)
+vi.mock('vite/modulepreload-polyfill', () => ({}));
+
+// Mock ldrs animation library (uses custom elements / browser APIs unavailable in jsdom)
+vi.mock('ldrs/react', () => ({ ChaoticOrbit: () => null }));
+vi.mock('ldrs/react/ChaoticOrbit.css', () => ({}));
+
// Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
});
+
+// ProseMirror calls Element.getClientRects() when scrolling to selection.
+// jsdom does not implement this method, which causes unhandled errors during
+// ContentEditor tests. Provide a minimal stub so ProseMirror exits cleanly.
+Element.prototype.getClientRects = vi.fn(() => []);
From 92f6723f41c37b33b03dda64987df5e30a19299b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 12:10:42 +0000
Subject: [PATCH 2/3] fix: explicit vi import in render mock; patch
Text/Range.getClientRects for ProseMirror
Agent-Logs-Url: https://github.com/AvaCodeSolutions/django-email-learning/sessions/441cafc3-06cd-4339-aade-0b3dd794af20
Co-authored-by: payamnj <11951509+payamnj@users.noreply.github.com>
---
frontend/src/__mocks__/render.jsx | 2 ++
frontend/src/test/setup.js | 17 +++++++++++++----
2 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/frontend/src/__mocks__/render.jsx b/frontend/src/__mocks__/render.jsx
index c5cf31e..e3d6b0b 100644
--- a/frontend/src/__mocks__/render.jsx
+++ b/frontend/src/__mocks__/render.jsx
@@ -1,5 +1,7 @@
// Manual mock for src/render.jsx
// Re-exports useAppContext backed by the test AppContext so components
// rendered via renderWithProviders(…) receive the correct context values.
+import { vi } from 'vitest';
+
export { useAppContext } from '../test/test-utils.jsx';
export default vi.fn();
diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js
index c89caa6..52de21b 100644
--- a/frontend/src/test/setup.js
+++ b/frontend/src/test/setup.js
@@ -61,7 +61,16 @@ beforeEach(() => {
global.fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
});
-// ProseMirror calls Element.getClientRects() when scrolling to selection.
-// jsdom does not implement this method, which causes unhandled errors during
-// ContentEditor tests. Provide a minimal stub so ProseMirror exits cleanly.
-Element.prototype.getClientRects = vi.fn(() => []);
+// ProseMirror's scroll-to-selection requires getClientRects / getBoundingClientRect
+// on Text nodes and Range objects — neither is implemented by jsdom.
+// Provide minimal stubs so the EditorView exits cleanly instead of throwing.
+const _emptyRects = [];
+if (typeof Text !== 'undefined') {
+ Text.prototype.getClientRects = vi.fn(() => _emptyRects);
+ Text.prototype.getBoundingClientRect = vi.fn(() => new DOMRect(0, 0, 0, 0));
+}
+if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) {
+ Range.prototype.getClientRects = vi.fn(() => _emptyRects);
+}
+// Also patch Element in case jsdom's own stub is absent in some test envs
+Element.prototype.getClientRects = vi.fn(() => _emptyRects);
From 3bdb6a1b9361ccc65361d98f36e223d2ac790d74 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 5 May 2026 12:26:59 +0000
Subject: [PATCH 3/3] fix: use plain functions for getClientRects stubs to
survive vi.clearAllMocks()
Agent-Logs-Url: https://github.com/AvaCodeSolutions/django-email-learning/sessions/e825f40b-1d95-4f6f-9757-87fbd54110f9
Co-authored-by: payamnj <11951509+payamnj@users.noreply.github.com>
---
frontend/src/test/setup.js | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js
index 52de21b..2a8e2fd 100644
--- a/frontend/src/test/setup.js
+++ b/frontend/src/test/setup.js
@@ -63,14 +63,15 @@ beforeEach(() => {
// ProseMirror's scroll-to-selection requires getClientRects / getBoundingClientRect
// on Text nodes and Range objects — neither is implemented by jsdom.
-// Provide minimal stubs so the EditorView exits cleanly instead of throwing.
-const _emptyRects = [];
+// Use plain functions (not vi.fn) so vi.clearAllMocks() in beforeEach does not
+// reset the implementation and leave them returning undefined.
+const _emptyRects = Object.assign([], { item: () => null });
if (typeof Text !== 'undefined') {
- Text.prototype.getClientRects = vi.fn(() => _emptyRects);
- Text.prototype.getBoundingClientRect = vi.fn(() => new DOMRect(0, 0, 0, 0));
+ Text.prototype.getClientRects = () => _emptyRects;
+ Text.prototype.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0);
}
if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) {
- Range.prototype.getClientRects = vi.fn(() => _emptyRects);
+ Range.prototype.getClientRects = () => _emptyRects;
}
// Also patch Element in case jsdom's own stub is absent in some test envs
-Element.prototype.getClientRects = vi.fn(() => _emptyRects);
+Element.prototype.getClientRects = () => _emptyRects;