Compare commits
2
Commits
3710a7f4aa
...
b075006d0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b075006d0f | ||
|
|
c0d83359cc |
@@ -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 }}"
|
||||||
@@ -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 }}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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/
|
||||||
@@ -40,7 +40,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "NODE_ENV=development react-scripts start",
|
"start": "NODE_ENV=development react-scripts start",
|
||||||
"build": "NODE_ENV=production react-scripts build",
|
"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": "NODE_ENV=development react-scripts test",
|
||||||
|
"test:ci": "CI=true NODE_ENV=development react-scripts test --watchAll=false --coverage=false",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"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();
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
const Cookies = require("js-cookie");
|
const Cookies = require("js-cookie");
|
||||||
|
|
||||||
const baseURL = "http://localhost:8011/api/";
|
const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
|
||||||
//const baseURL = 'https://chatbackend.aimloperations.com/api/';
|
|
||||||
//const baseURL = process.env.REACT_APP_BACKEND_REST_API_BASE_URL;
|
|
||||||
|
|
||||||
export const axiosInstance = axios.create({
|
export const axiosInstance = axios.create({
|
||||||
baseURL: baseURL,
|
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(() => {
|
useEffect(() => {
|
||||||
/* WS initialization and cleanup */
|
/* WS initialization and cleanup */
|
||||||
if (account) {
|
if (account) {
|
||||||
ws.current = new WebSocket(`ws://localhost:8011/ws/chat_again/`);
|
ws.current = new WebSocket(process.env.REACT_APP_BACKEND_WS_API_BASE_URL);
|
||||||
//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.onopen = () => {
|
ws.current.onopen = () => {
|
||||||
setSocket(ws.current);
|
setSocket(ws.current);
|
||||||
|
|||||||
@@ -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