Skip to content
Draft
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
10 changes: 10 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export interface Animal {
restrictions?: string[];
}

export interface Volunteer {
volunteerId: number;
firstName: string;
lastName: string;
}

export interface CreateMatchBody {
volunteerId: number;
chameleonAnimalId: number;
Expand Down Expand Up @@ -79,6 +85,10 @@ export class ApiClient {
return this.get('/api/animals') as Promise<Animal[]>;
}

public async getVolunteer(volunteerId: number): Promise<Volunteer> {
return this.get(`/api/volunteers/${volunteerId}`) as Promise<Volunteer>;
}

public async createMatch(body: CreateMatchBody): Promise<Match> {
return this.post('/api/matches', body) as Promise<Match>;
}
Expand Down
53 changes: 53 additions & 0 deletions apps/frontend/src/app.error.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render } from '@testing-library/react';
import { ChakraProvider } from '@chakra-ui/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';

import { routes } from './app';
import { system } from './theme';

vi.mock('@api/apiClient', () => ({
default: {
getHello: vi.fn().mockResolvedValue('hello'),
getVolunteer: vi.fn().mockResolvedValue({
volunteerId: 1,
firstName: 'Matt',
lastName: 'Wong',
}),
},
}));

// A page that crashes while rendering.
vi.mock('@pages/AnimalAvailability', () => ({
default: () => {
throw new Error('page exploded');
},
}));

describe('App routes when a page crashes', () => {
let consoleError: ReturnType<typeof vi.spyOn>;

afterEach(() => {
consoleError.mockRestore();
});

it('keeps the navbar above the error message', () => {
// React logs the thrown render error; keep the test output clean.
consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const view = render(
<ChakraProvider value={system}>
<RouterProvider
router={createMemoryRouter(routes, {
initialEntries: ['/animal-availability'],
})}
/>
</ChakraProvider>,
);

expect(view.getByText('page exploded')).toBeTruthy();
expect(
view.getByAltText('MSPCA - Kindness and Care for Animals'),
).toBeTruthy();
});
});
49 changes: 41 additions & 8 deletions apps/frontend/src/app.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,48 @@
import { render } from '@testing-library/react';
import { ChakraProvider } from '@chakra-ui/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';

import App from './app';
import { routes } from './app';
import { system } from './theme';

describe('App', () => {
it('should render successfully', () => {
const { baseElement } = render(<App />);
expect(baseElement).toBeTruthy();
vi.mock('@api/apiClient', () => ({
default: {
getHello: vi.fn().mockResolvedValue('hello'),
getAnimals: vi.fn().mockResolvedValue([]),
getVolunteer: vi.fn().mockResolvedValue({
volunteerId: 1,
firstName: 'Matt',
lastName: 'Wong',
}),
},
}));

const LOGO_ALT = 'MSPCA - Kindness and Care for Animals';

const renderAt = (path: string) =>
render(
<ChakraProvider value={system}>
<RouterProvider
router={createMemoryRouter(routes, { initialEntries: [path] })}
/>
</ChakraProvider>,
);

describe('App routes', () => {
it('renders the navbar on the home page', () => {
const view = renderAt('/');
expect(view.getByAltText(LOGO_ALT)).toBeTruthy();
expect(view.getByText('Welcome to scaffolding!')).toBeTruthy();
});

it('renders the navbar once on the animal availability page', () => {
const view = renderAt('/animal-availability');
expect(view.getAllByAltText(LOGO_ALT)).toHaveLength(1);
});

it('should have a greeting as the title', () => {
const { getByText } = render(<App />);
expect(getByText(/Welcome frontend/gi)).toBeTruthy();
it('shows the navbar and a not-found message on an unknown route', () => {
const view = renderAt('/does-not-exist');
expect(view.getByAltText(LOGO_ALT)).toBeTruthy();
expect(view.getByText('Page not found')).toBeTruthy();
});
});
41 changes: 28 additions & 13 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
import { useEffect } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import {
createBrowserRouter,
RouterProvider,
type RouteObject,
} from 'react-router-dom';

import apiClient from '@api/apiClient';
import Root from '@containers/root';
import Layout from '@containers/Layout';
import NotFound from '@containers/404';
import Test from '@containers/test';
import AnimalAvailability from '@pages/AnimalAvailability';
import { ROUTES } from './routes';

const router = createBrowserRouter([
// Every page is a child of Layout so the navbar renders once, above all pages.
// Page errors are caught by the inner errorElement so the navbar stays put;
// the outer one only catches errors from the layout itself.
export const routes: RouteObject[] = [
{
path: '/',
element: <Root />,
element: <Layout />,
errorElement: <NotFound />,
children: [
{
errorElement: <NotFound />,
children: [
{ path: '/', element: <Root /> },
{ path: '/test', element: <Test /> },
{
path: ROUTES.ANIMAL_AVAILABILITY,
element: <AnimalAvailability />,
},
{ path: '*', element: <NotFound /> },
],
},
],
},
{
path: '/test',
element: <Test />,
},
{
path: ROUTES.ANIMAL_AVAILABILITY,
element: <AnimalAvailability />,
},
]);
];

const router = createBrowserRouter(routes);

export const App: React.FC = () => {
useEffect(() => {
Expand Down
62 changes: 62 additions & 0 deletions apps/frontend/src/components/AnimalModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { fireEvent, waitFor } from '@testing-library/react';
import type { Mock } from 'vitest';

import apiClient, { type Animal } from '@api/apiClient';
import { renderWithProviders } from '../testUtils';
import AnimalModal from './AnimalModal';

vi.mock('@api/apiClient', () => ({
default: { createMatch: vi.fn() },
}));

const createMatch = apiClient.createMatch as Mock;

const animal = {
animalId: 1,
chameleonAnimalId: 555,
name: 'Biscuit',
age: 2,
sex: 'Male',
breed: ['Beagle'],
weight: 20,
location: 'Boston, MA',
picture: null,
fosterBioMemo: null,
} as Animal;

const renderModal = (route: string) =>
renderWithProviders(<AnimalModal animal={animal} onClose={vi.fn()} />, {
route,
});

describe('AnimalModal', () => {
beforeEach(() => {
createMatch.mockReset();
createMatch.mockResolvedValue({});
});

it('requests a match for the volunteer in the URL', async () => {
const view = renderModal('/?volunteerId=5');

fireEvent.click(await view.findByRole('button', { name: 'Apply' }));

await waitFor(() => expect(createMatch).toHaveBeenCalledTimes(1));
expect(createMatch).toHaveBeenCalledWith({
volunteerId: 5,
chameleonAnimalId: 555,
additionalComments: undefined,
});
});

it('disables the request without a volunteerId in the URL', async () => {
const view = renderModal('/');

const apply = (await view.findByRole('button', {
name: 'Apply',
})) as HTMLButtonElement;
fireEvent.click(apply);

expect(apply.disabled).toBe(true);
expect(createMatch).not.toHaveBeenCalled();
});
});
11 changes: 6 additions & 5 deletions apps/frontend/src/components/AnimalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,10 @@ import {

import apiClient, { type Animal } from '@api/apiClient';
import { formatAge } from '@utils/animals';
import { useVolunteerId } from '@utils/useVolunteerId';
import { CalendarIcon, PLACEHOLDER_IMAGE } from './AnimalCard';
import RestrictionTag from './RestrictionTag';

// TODO: replace with the logged-in volunteer's ID once the frontend has access
// to the current user's profile.
const CURRENT_VOLUNTEER_ID = 1;

interface AnimalModalProps {
animal: Animal | null;
onClose: () => void;
Expand All @@ -31,13 +28,16 @@ const AnimalModalBody: React.FC<{ animal: Animal }> = ({ animal }) => {
const [submitting, setSubmitting] = useState(false);
const [requested, setRequested] = useState(false);
const [error, setError] = useState<string | null>(null);
// TEMPORARY: comes from the URL until sign-in is implemented.
const volunteerId = useVolunteerId();

const handleRequest = async () => {
if (volunteerId === null) return;
setSubmitting(true);
setError(null);
try {
await apiClient.createMatch({
volunteerId: CURRENT_VOLUNTEER_ID,
volunteerId,
chameleonAnimalId: animal.chameleonAnimalId,
additionalComments: comments.trim() || undefined,
});
Expand Down Expand Up @@ -262,6 +262,7 @@ const AnimalModalBody: React.FC<{ animal: Animal }> = ({ animal }) => {
lineHeight="24px"
onClick={handleRequest}
loading={submitting}
disabled={volunteerId === null}
>
Apply
</Button>
Expand Down
99 changes: 99 additions & 0 deletions apps/frontend/src/components/Navbar.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { useNavigate } from 'react-router-dom';
import type { Mock } from 'vitest';

import apiClient, { type Volunteer } from '@api/apiClient';
import { renderWithProviders } from '../testUtils';
import Navbar from './Navbar';

vi.mock('@api/apiClient', () => ({
default: { getVolunteer: vi.fn() },
}));

const getVolunteer = apiClient.getVolunteer as Mock;

const volunteer = (
volunteerId: number,
firstName: string,
lastName: string,
): Volunteer => ({ volunteerId, firstName, lastName });

const GoTo: React.FC<{ to: string }> = ({ to }) => {
const navigate = useNavigate();
return <button onClick={() => navigate(to)}>go</button>;
};

const avatarText = (container: ReturnType<typeof renderWithProviders>) =>
container.getByTestId('navbar-avatar').textContent;

describe('Navbar', () => {
beforeEach(() => {
getVolunteer.mockReset();
});

it('renders the MSPCA logo', () => {
const view = renderWithProviders(<Navbar />);
expect(
view.getByAltText('MSPCA - Kindness and Care for Animals'),
).toBeTruthy();
});

it('shows the profile button', () => {
const view = renderWithProviders(<Navbar />);
expect(view.getByRole('button', { name: 'My Profile' })).toBeTruthy();
});

it("shows the volunteer's initials", async () => {
getVolunteer.mockResolvedValue(volunteer(3, 'Matt', 'Wong'));
const view = renderWithProviders(<Navbar />, { route: '/?volunteerId=3' });

expect(await view.findByText('MW')).toBeTruthy();
expect(getVolunteer).toHaveBeenCalledTimes(1);
expect(getVolunteer).toHaveBeenCalledWith(3);
});

it('shows a fallback avatar and makes no request without a volunteerId', () => {
const view = renderWithProviders(<Navbar />);

expect(avatarText(view)).toBe('');
expect(getVolunteer).not.toHaveBeenCalled();
});

it('shows a fallback avatar and warns when the volunteer request fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
getVolunteer.mockRejectedValue(new Error('404'));
const view = renderWithProviders(<Navbar />, { route: '/?volunteerId=9' });

// Waiting for the warning means the rejection has been handled.
await waitFor(() => expect(warn).toHaveBeenCalledTimes(1));
expect(avatarText(view)).toBe('');
warn.mockRestore();
});

it('ignores a stale response after the volunteerId changes', async () => {
let resolveFirst: (v: Volunteer) => void = () => undefined;
getVolunteer
.mockReturnValueOnce(
new Promise<Volunteer>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValueOnce(volunteer(2, 'Anna', 'Lee'));

const view = renderWithProviders(
<>
<Navbar />
<GoTo to="/?volunteerId=2" />
</>,
{ route: '/?volunteerId=1' },
);

fireEvent.click(view.getByText('go'));
expect(await view.findByText('AL')).toBeTruthy();

resolveFirst(volunteer(1, 'Matt', 'Wong'));
await new Promise((r) => setTimeout(r, 0));

expect(avatarText(view)).toBe('AL');
});
});
Loading
Loading