Add unit tests for models, components, auth, and sign-in (closes #11).
Replace the broken CRA App smoke test with focused Jest/RTL coverage and a CI-friendly test:ci script.
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user