Add unit tests (#11) (#14)

## Summary
- Add Jest/RTL unit tests for data models, Card, Footer, CustomTextField/PasswordField, CustomToastMessage, ConversationCard, AuthContext, and SignIn
- Remove the broken CRA `App.test.tsx` placeholder that expected "learn react"
- Add `npm run test:ci` for non-interactive CI runs

Closes #11

## Test plan
- [ ] `cd llm-fe && npm install`
- [ ] `npm run test:ci` — expect 9 suites / 32 tests passing

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-07-14 18:03:24 -07:00
parent 40db2495f2
commit c0d83359cc
11 changed files with 560 additions and 9 deletions
+1
View File
@@ -41,6 +41,7 @@
"start": "NODE_ENV=development react-scripts start",
"build": "NODE_ENV=production react-scripts build",
"test": "NODE_ENV=development react-scripts test",
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
"eject": "react-scripts eject"
},
"eslintConfig": {
-9
View File
@@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
@@ -0,0 +1,22 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import Card from './Card';
describe('Card', () => {
it('renders children inside card body', () => {
render(
<Card>
<span>Card content</span>
</Card>
);
expect(screen.getByText('Card content')).toBeInTheDocument();
expect(screen.getByText('Card content').closest('.card')).toHaveClass('card-body');
});
it('renders empty card when no children given', () => {
const { container } = render(<Card />);
expect(container.querySelector('.card.card-body')).toBeInTheDocument();
expect(container.querySelector('.card.card-body')?.childElementCount).toBe(0);
});
});
@@ -0,0 +1,56 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import ConversationCard from './ConversationCard';
jest.mock('../../ui-kit/components/MDTypography', () => ({
__esModule: true,
default: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
const renderCard = (props: Partial<React.ComponentProps<typeof ConversationCard>> = {}) => {
const setSelectConversation = props.setSelectConversation ?? jest.fn();
const deleteConversation = props.deleteConversation ?? jest.fn();
render(
<ThemeProvider theme={createTheme()}>
<ConversationCard
title={props.title ?? 'My chat'}
conversation_id={props.conversation_id ?? 5}
setSelectConversation={setSelectConversation}
deleteConversation={deleteConversation}
selectedConversation={props.selectedConversation}
/>
</ThemeProvider>
);
return { setSelectConversation, deleteConversation };
};
describe('ConversationCard', () => {
it('renders conversation title', () => {
renderCard({ title: 'Project brainstorm' });
expect(screen.getByText('Project brainstorm')).toBeInTheDocument();
});
it('selects conversation when title clicked', async () => {
const user = userEvent.setup();
const { setSelectConversation } = renderCard({ conversation_id: 12 });
await user.click(screen.getByText('My chat'));
expect(setSelectConversation).toHaveBeenCalledWith(12);
});
it('deletes conversation when delete button clicked', async () => {
const user = userEvent.setup();
const { deleteConversation } = renderCard({ conversation_id: 8 });
await user.click(screen.getByLabelText(/delete/i));
expect(deleteConversation).toHaveBeenCalledWith(8);
});
});
@@ -0,0 +1,33 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CustomPasswordField from './CustomPasswordField';
describe('CustomPasswordField', () => {
it('renders a password input with label', () => {
render(
<ThemeProvider theme={createTheme()}>
<CustomPasswordField label="Password" name="password" changeHandler={jest.fn()} />
</ThemeProvider>
);
const input = screen.getByLabelText('Password');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'password');
});
it('calls changeHandler when user types', async () => {
const user = userEvent.setup();
const changeHandler = jest.fn();
render(
<ThemeProvider theme={createTheme()}>
<CustomPasswordField label="Password" name="password" changeHandler={changeHandler} />
</ThemeProvider>
);
await user.type(screen.getByLabelText('Password'), 'secret');
expect(changeHandler).toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CustomTextField from './CustomTextField';
const renderField = (props: Partial<React.ComponentProps<typeof CustomTextField>> = {}) => {
const changeHandler = props.changeHandler ?? jest.fn();
render(
<ThemeProvider theme={createTheme()}>
<CustomTextField
label={props.label ?? 'Username'}
name={props.name ?? 'username'}
changeHandler={changeHandler}
isMultline={props.isMultline ?? false}
/>
</ThemeProvider>
);
return { changeHandler };
};
describe('CustomTextField', () => {
it('renders labeled text input', () => {
renderField({ label: 'Email', name: 'email' });
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
it('calls changeHandler when user types', async () => {
const user = userEvent.setup();
const { changeHandler } = renderField();
await user.type(screen.getByLabelText('Username'), 'abc');
expect(changeHandler).toHaveBeenCalled();
});
it('supports multiline mode', () => {
renderField({ label: 'Notes', name: 'notes', isMultline: true });
expect(screen.getByLabelText('Notes').tagName).toBe('TEXTAREA');
});
});
@@ -0,0 +1,10 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import CustomToastMessage from './CustomeToastMessage';
describe('CustomToastMessage', () => {
it('renders the provided message in toast body', () => {
render(<CustomToastMessage message="Saved successfully" />);
expect(screen.getByText('Saved successfully')).toBeInTheDocument();
});
});
@@ -0,0 +1,20 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import Footer from './Footer';
jest.mock('../../ui-kit/components/MDTypography', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('Footer', () => {
it('renders copyright and developer credit', () => {
render(<Footer />);
expect(screen.getByText(/© 2025 Chat/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /AI ML Operations, LLC/i })).toHaveAttribute(
'href',
'www.aimloperations.com'
);
});
});
@@ -0,0 +1,71 @@
import React, { useContext } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { AuthContext, AuthProvider } from './AuthContext';
const Probe = () => {
const { authenticated, loading, setAuthentication } = useContext(AuthContext);
if (loading) {
return <div>loading</div>;
}
return (
<div>
<span data-testid="auth-state">{authenticated ? 'yes' : 'no'}</span>
<button type="button" onClick={() => setAuthentication(true)}>
login
</button>
</div>
);
};
const renderWithProvider = () =>
render(
<MemoryRouter>
<AuthProvider>
<Probe />
</AuthProvider>
</MemoryRouter>
);
/** Minimal JWT payload with exp far in the future (year 2286). */
const FUTURE_TOKEN =
'eyJhbGciOiJub25lIn0.eyJleHAiOjE5OTk5OTk5OTk5fQ.';
describe('AuthProvider', () => {
beforeEach(() => {
localStorage.clear();
});
it('starts unauthenticated when no token stored', async () => {
renderWithProvider();
await waitFor(() => {
expect(screen.queryByText('loading')).not.toBeInTheDocument();
});
expect(screen.getByTestId('auth-state')).toHaveTextContent('no');
});
it('authenticates when valid access token present', async () => {
localStorage.setItem('access_token', FUTURE_TOKEN);
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('auth-state')).toHaveTextContent('yes');
});
});
it('updates authenticated state via setAuthentication', async () => {
const user = userEvent.setup();
renderWithProvider();
await waitFor(() => {
expect(screen.getByTestId('auth-state')).toHaveTextContent('no');
});
await user.click(screen.getByRole('button', { name: 'login' }));
expect(screen.getByTestId('auth-state')).toHaveTextContent('yes');
});
});
+195
View File
@@ -0,0 +1,195 @@
import {
Account,
Announcement,
Company,
Conversation,
ConversationPrompt,
Document,
Feedback,
} from './data';
describe('ConversationPrompt', () => {
it('uses defaults when constructed with no args', () => {
const prompt = new ConversationPrompt();
expect(prompt.id).toBeUndefined();
expect(prompt.message).toBe('');
expect(prompt.user_created).toBe(false);
expect(prompt.created_timestamp).toBeInstanceOf(Date);
});
it('hydrates from initializer', () => {
const created = new Date('2024-01-01');
const prompt = new ConversationPrompt({
id: 7,
message: 'hello',
user_created: true,
created_timestamp: created,
});
expect(prompt.id).toBe(7);
expect(prompt.message).toBe('hello');
expect(prompt.user_created).toBe(true);
expect(prompt.created_timestamp).toEqual(created);
});
});
describe('Company', () => {
it('uses defaults when constructed with no args', () => {
const company = new Company();
expect(company.id).toBeUndefined();
expect(company.name).toBe('');
expect(company.state).toBe('');
expect(company.zipcode).toBeUndefined();
expect(company.address).toBe('');
});
it('hydrates from initializer', () => {
const company = new Company({
id: 1,
name: 'Acme',
state: 'CA',
zipcode: 90210,
address: '1 Main St',
});
expect(company.id).toBe(1);
expect(company.name).toBe('Acme');
expect(company.state).toBe('CA');
expect(company.zipcode).toBe(90210);
expect(company.address).toBe('1 Main St');
});
});
describe('Conversation', () => {
it('uses defaults when constructed with no args', () => {
const conversation = new Conversation();
expect(conversation.id).toBeUndefined();
expect(conversation.title).toBe('');
expect(conversation.conversationDetail).toEqual([]);
expect(conversation.account).toBeUndefined();
});
it('hydrates from initializer', () => {
const detail = [new ConversationPrompt({ id: 1, message: 'hi' })];
const account = new Account({ email: 'a@b.com' });
const conversation = new Conversation({
id: 42,
title: 'Chat',
conversationDetail: detail,
account,
});
expect(conversation.id).toBe(42);
expect(conversation.title).toBe('Chat');
expect(conversation.conversationDetail).toEqual(detail);
expect(conversation.account).toEqual(account);
});
});
describe('Announcement', () => {
it('uses defaults when constructed with no args', () => {
const announcement = new Announcement();
expect(announcement.status).toBe('default');
expect(announcement.message).toBe('');
});
it('hydrates from initializer', () => {
const announcement = new Announcement({
status: 'info',
message: 'Welcome',
});
expect(announcement.status).toBe('info');
expect(announcement.message).toBe('Welcome');
});
});
describe('Document', () => {
it('uses defaults when constructed with no args', () => {
const doc = new Document();
expect(doc.id).toBe(0);
expect(doc.name).toBe('');
expect(doc.file).toBe('');
expect(doc.date_uploaded).toBe('');
expect(doc.active).toBe(false);
expect(doc.processed).toBe(false);
});
it('hydrates from initializer', () => {
const doc = new Document({
id: 9,
name: 'report.pdf',
file: '/files/report.pdf',
date_uploaded: '2024-06-01',
active: true,
processed: true,
});
expect(doc.id).toBe(9);
expect(doc.name).toBe('report.pdf');
expect(doc.file).toBe('/files/report.pdf');
expect(doc.date_uploaded).toBe('2024-06-01');
expect(doc.active).toBe(true);
expect(doc.processed).toBe(true);
});
});
describe('Feedback', () => {
it('uses defaults when constructed with no args', () => {
const feedback = new Feedback();
expect(feedback.id).toBe(0);
expect(feedback.title).toBe('');
expect(feedback.status).toBe('');
expect(feedback.text).toBe('');
expect(feedback.category).toBe('');
});
it('hydrates from initializer', () => {
const feedback = new Feedback({
id: 3,
title: 'Bug',
status: 'open',
text: 'Broken button',
category: 'ui',
});
expect(feedback.id).toBe(3);
expect(feedback.title).toBe('Bug');
expect(feedback.status).toBe('open');
expect(feedback.text).toBe('Broken button');
expect(feedback.category).toBe('ui');
});
});
describe('Account', () => {
it('uses defaults when constructed with no args', () => {
const account = new Account();
expect(account.email).toBe('');
expect(account.first_name).toBe('');
expect(account.last_name).toBe('');
expect(account.role).toBeUndefined();
expect(account.company).toBeUndefined();
expect(account.is_company_manager).toBe(false);
expect(account.has_password).toBe(false);
expect(account.is_active).toBe(false);
expect(account.has_signed_tos).toBe(false);
});
it('hydrates from initializer', () => {
const company = new Company({ id: 1, name: 'Acme' });
const account = new Account({
email: 'user@example.com',
first_name: 'Jane',
last_name: 'Doe',
role: 'admin',
company,
is_company_manager: true,
has_password: true,
is_active: true,
has_signed_tos: true,
});
expect(account.email).toBe('user@example.com');
expect(account.first_name).toBe('Jane');
expect(account.last_name).toBe('Doe');
expect(account.role).toBe('admin');
expect(account.company).toEqual(company);
expect(account.is_company_manager).toBe(true);
expect(account.has_password).toBe(true);
expect(account.is_active).toBe(true);
expect(account.has_signed_tos).toBe(true);
});
});
@@ -0,0 +1,112 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import SignIn from './SignIn';
import { AuthContext } from '../../contexts/AuthContext';
import { AccountContext } from '../../contexts/AccountContext';
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
const mockPost = jest.fn();
const mockGet = jest.fn();
jest.mock('../../../axiosApi', () => ({
axiosInstance: {
post: (...args: unknown[]) => mockPost(...args),
get: (...args: unknown[]) => mockGet(...args),
defaults: { headers: {} as Record<string, string | null> },
},
}));
const renderSignIn = () => {
const setAuthentication = jest.fn();
const setNeedsNewPassword = jest.fn();
const setAccount = jest.fn();
render(
<MemoryRouter>
<AuthContext.Provider
value={{
authenticated: false,
setAuthentication,
needsNewPassword: false,
setNeedsNewPassword,
loading: false,
}}
>
<AccountContext.Provider value={{ account: undefined, setAccount }}>
<SignIn />
</AccountContext.Provider>
</AuthContext.Provider>
</MemoryRouter>
);
return { setAuthentication, setNeedsNewPassword, setAccount };
};
describe('SignIn', () => {
beforeEach(() => {
localStorage.clear();
mockPost.mockReset();
mockGet.mockReset();
});
it('renders sign-in form fields', () => {
renderSignIn();
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.getByPlaceholderText('Email Address')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Forgot Password?' })).toBeInTheDocument();
});
it('does not submit when required fields empty', async () => {
const user = userEvent.setup();
renderSignIn();
await user.click(screen.getByRole('button', { name: 'Sign In' }));
await waitFor(() => {
expect(mockPost).not.toHaveBeenCalled();
});
});
it('authenticates on successful submit', async () => {
const user = userEvent.setup();
mockPost.mockResolvedValue({
data: { access: 'access-token', refresh: 'refresh-token' },
});
mockGet.mockResolvedValue({
data: {
email: 'user@example.com',
first_name: 'Jane',
last_name: 'Doe',
is_company_manager: false,
has_signed_tos: true,
has_usable_password: true,
company: { id: 1, name: 'Acme', state: 'CA', zipcode: '90001', address: '1 Main' },
},
});
const { setAuthentication, setAccount } = renderSignIn();
await user.type(screen.getByPlaceholderText('Email Address'), 'user@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'secret123');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith('/token/obtain/', {
username: 'user@example.com',
password: 'secret123',
});
});
await waitFor(() => {
expect(setAuthentication).toHaveBeenCalledWith(true);
expect(setAccount).toHaveBeenCalled();
expect(localStorage.getItem('access_token')).toBe('access-token');
expect(localStorage.getItem('refresh_token')).toBe('refresh-token');
});
});
});