Compare commits

...
2 Commits
Author SHA1 Message Date
westfarn b075006d0f Wire node-static deploy for chat_web_app (closes #13).
Unit Tests / test (pull_request) Successful in 11s
Add build:prod/beta webroot scripts, Gitea CI/deploy workflows, and bake API
URLs from REACT_APP_* so hosts can deploy via server-infra like dta_webapp.
2026-07-25 04:32:07 -07:00
westfarn c0d83359cc 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
2026-07-14 18:03:24 -07:00
18 changed files with 716 additions and 17 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Deploy Beta
on:
workflow_dispatch: {}
jobs:
unit-tests:
runs-on: self-hosted
defaults:
run:
working-directory: llm-fe
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:ci
deploy-beta:
needs: unit-tests
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps:
- name: Deploy chat_web_app beta
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app chat_web_app \
--env beta \
--ref "${{ gitea.sha }}"
+22
View File
@@ -0,0 +1,22 @@
name: Deploy Prod
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
on:
workflow_run:
workflows: [Unit Tests]
types: [completed]
branches: [master]
jobs:
deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps:
- name: Deploy chat_web_app prod
run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \
--app chat_web_app \
--env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
+28
View File
@@ -0,0 +1,28 @@
name: Unit Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
defaults:
run:
working-directory: llm-fe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:ci
+63
View File
@@ -0,0 +1,63 @@
# Chat Bot Frontend (`chat_web_app`)
CRA/React app under `llm-fe/`. Production serve is **node-static** (not Docker):
hosts build with npm and write into `/var/www/...`; the shared **web-static** nginx
container serves those roots. Deploy is driven by `server-infra`.
## Local development
```bash
cd llm-fe
npm ci
npm start
```
Uses `.env.development` (API → local `chat_backend`, usually `http://127.0.0.1:8001`).
## Environment files (CRA `REACT_APP_*`)
| File | When used | Backend |
|------|-----------|---------|
| `.env.development` | `npm start` | local backend |
| `.env.production` | `npm run build` / `build:prod` | `https://chatbackend.aimloperations.com` |
| `.env.beta` | `npm run build:beta` | `https://beta.chatbackend.aimloperations.com` |
Baked into the JS bundle at build time. No host secret file needed for this
frontend (unlike Django apps under `~/Documents/secrets/`).
Required vars:
- `REACT_APP_BACKEND_REST_API_BASE_URL` — REST base (trailing `/api/`)
- `REACT_APP_BACKEND_WS_API_BASE_URL` — WebSocket URL (full `ws://` / `wss://` path)
## Production / beta build (host deploy)
`server-infra` runs `npm ci` then `npm run build:<env>` in `llm-fe/`. Those
scripts write static assets into:
| Script | Document root |
|--------|----------------|
| `npm run build:prod` | `/var/www/prod.chat.aimloperations/html` |
| `npm run build:beta` | `/var/www/beta.chat.aimloperations/html` |
Paths must match `server-infra` `app_catalog.chat_web_app.webroot_pattern`.
Manual deploy from the control node:
```bash
~/Documents/repos/server-infra/scripts/deploy.sh \
--app chat_web_app --env prod --ref <sha>
```
## CI / CD (Gitea)
- `.gitea/workflows/unit-tests.yml` — tests on push/PR to `master`
- `.gitea/workflows/deploy-prod.yml` — after green Unit Tests on `master` push
- `.gitea/workflows/deploy-beta.yml` — manual (`workflow_dispatch`)
## Tests
```bash
cd llm-fe
npm run test:ci
```
+2
View File
@@ -0,0 +1,2 @@
REACT_APP_BACKEND_REST_API_BASE_URL=https://beta.chatbackend.aimloperations.com/api/
REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
+3
View File
@@ -40,7 +40,10 @@
"scripts": {
"start": "NODE_ENV=development react-scripts start",
"build": "NODE_ENV=production react-scripts build",
"build:prod": "NODE_ENV=production react-scripts build && mkdir -p /var/www/prod.chat.aimloperations/html && cp -r ./build/* /var/www/prod.chat.aimloperations/html/",
"build:beta": "bash -c 'set -a; source .env.beta; set +a; NODE_ENV=production react-scripts build' && mkdir -p /var/www/beta.chat.aimloperations/html && cp -r ./build/* /var/www/beta.chat.aimloperations/html/",
"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();
});
+1 -3
View File
@@ -1,9 +1,7 @@
import axios from "axios";
const Cookies = require("js-cookie");
const baseURL = "http://localhost:8011/api/";
//const baseURL = 'https://chatbackend.aimloperations.com/api/';
//const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
export const axiosInstance = axios.create({
baseURL: baseURL,
@@ -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');
});
});
@@ -70,11 +70,7 @@ function WebSocketProvider({ children }) {
useEffect(() => {
/* WS initialization and cleanup */
if (account) {
ws.current = new WebSocket(`ws://localhost:8011/ws/chat_again/`);
//ws.current = new WebSocket(`ws://localhost:8011/ws/conditional_chat/`);
//ws.current = new WebSocket('wss://chatbackend.aimloperations.com/ws/chat_again/')
//ws.current = new WebSocket('wss://chatbackend.aimloperations.com/ws/conditional_chat/')
//ws.current = process.env.REACT_APP_BACKEND_WS_API_BASE_URL;
ws.current = new WebSocket(process.env.REACT_APP_BACKEND_WS_API_BASE_URL);
ws.current.onopen = () => {
setSocket(ws.current);
+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');
});
});
});