Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/__mocks__/render.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +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();
103 changes: 103 additions & 0 deletions frontend/src/test/Base.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<Base breadCrumbList={[{ label: 'Home', href: '/' }]}>
<p>Page content</p>
</Base>
);
expect(screen.getByText('Page content')).toBeInTheDocument();
});

it('renders a single breadcrumb as the active (non-linked) crumb', () => {
renderWithProviders(
<Base breadCrumbList={[{ label: 'Dashboard', href: '/dashboard' }]}>
<div />
</Base>
);
// The last crumb is rendered as Typography, not a link
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});

it('renders intermediate breadcrumbs as links', () => {
renderWithProviders(
<Base
breadCrumbList={[
{ label: 'Home', href: '/' },
{ label: 'Courses', href: '/courses' },
{ label: 'Module 1', href: '/courses/1' },
]}
>
<div />
</Base>
);
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(
<Base breadCrumbList={[{ label: 'Home', href: '/' }]}>
<div />
</Base>
);
expect(screen.getByRole('link', { name: /avacode solutions/i })).toBeInTheDocument();
});

it('calls organizationIdRefreshCallback when organization changes', async () => {
const onOrgRefresh = vi.fn();
renderWithProviders(
<Base
breadCrumbList={[{ label: 'Home', href: '/' }]}
organizationIdRefreshCallback={onOrgRefresh}
>
<div />
</Base>
);
// Called immediately on mount with null (initial state)
await waitFor(() => expect(onOrgRefresh).toHaveBeenCalledWith(null));
});

it('renders without BottomDrawer when bottomDrawerParams is omitted', () => {
renderWithProviders(
<Base breadCrumbList={[{ label: 'Home', href: '/' }]}>
<div />
</Base>
);
// FAB for BottomDrawer must not be present
expect(screen.queryByRole('button', { name: /filter list/i })).not.toBeInTheDocument();
});
});
59 changes: 59 additions & 0 deletions frontend/src/test/BottomDrawer.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<BottomDrawer icon={<span>icon</span>}>
<p>Filter options</p>
</BottomDrawer>
);
// 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(
<BottomDrawer icon={<span>icon</span>}>
<p>Content</p>
</BottomDrawer>
);
expect(screen.getByRole('button', { name: /filter list/i })).toBeInTheDocument();
});

it('clicking the FAB opens the drawer (content accessible)', () => {
renderWithProviders(
<BottomDrawer icon={<span>icon</span>}>
<p>Drawer content</p>
</BottomDrawer>
);
fireEvent.click(screen.getByRole('button', { name: /filter list/i }));
expect(screen.getByText('Drawer content')).toBeInTheDocument();
});

it('accepts any icon element', () => {
const { container } = renderWithProviders(
<BottomDrawer icon={<span data-testid="custom-icon">★</span>}>
<p>Content</p>
</BottomDrawer>
);
expect(container.querySelector('[data-testid="custom-icon"]')).toBeInTheDocument();
});

it('renders multiple children inside the drawer when open', () => {
renderWithProviders(
<BottomDrawer icon={<span>icon</span>}>
<p>Child One</p>
<p>Child Two</p>
</BottomDrawer>
);
fireEvent.click(screen.getByRole('button', { name: /filter list/i }));
expect(screen.getByText('Child One')).toBeInTheDocument();
expect(screen.getByText('Child Two')).toBeInTheDocument();
});
});
86 changes: 86 additions & 0 deletions frontend/src/test/ContentEditor.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<ContentEditor
initialContent=""
contentUpdateCallback={vi.fn()}
/>
);

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(
<ContentEditor
initialContent=""
contentUpdateCallback={vi.fn()}
/>
);

// 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(
<ContentEditor
initialContent="<p>Hello</p>"
contentUpdateCallback={vi.fn()}
disabled
/>
);

// 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(
<ContentEditor
initialContent="<p>Welcome to the editor</p>"
contentUpdateCallback={vi.fn()}
/>
);

await waitFor(() =>
expect(screen.getByText('Welcome to the editor')).toBeInTheDocument()
);
});

it('calls editorInstanceCallback with the editor instance', async () => {
const onEditor = vi.fn();
renderWithProviders(
<ContentEditor
initialContent=""
contentUpdateCallback={vi.fn()}
editorInstanceCallback={onEditor}
/>
);

await waitFor(() => expect(onEditor).toHaveBeenCalledWith(expect.objectContaining({
chain: expect.any(Function),
})));
});
});
135 changes: 135 additions & 0 deletions frontend/src/test/FileUpload.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<FileUpload {...defaultProps} />);
expect(screen.getByText('Upload File')).toBeInTheDocument();
});

it('renders a custom upload label', () => {
renderWithProviders(<FileUpload {...defaultProps} uploadLabel="Attach Document" />);
expect(screen.getByText('Attach Document')).toBeInTheDocument();
});

it('renders helper text when provided', () => {
renderWithProviders(<FileUpload {...defaultProps} helperText="Max size 5 MB" />);
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(<FileUpload {...defaultProps} />);
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(
<FileUpload {...defaultProps} onUploadSuccess={onUploadSuccess} />
);
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(
<FileUpload {...defaultProps} removeLabel="Delete attachment" />
);
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(<FileUpload {...defaultProps} />);
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(
<FileUpload {...defaultProps} onUploadSuccess={onUploadSuccess} />
);
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(<FileUpload {...defaultProps} />);
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');
});
});
Loading
Loading