Skip to content
Merged
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
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,4 @@ Thumbs.db

# Environment file
*.env
*.env.*
!example.env
*.env.*
41 changes: 40 additions & 1 deletion apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import axios, { type AxiosInstance } from 'axios';
const defaultBaseUrl =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';

export type DonationCreateRequest = {
firstName: string;
lastName: string;
email: string;
amount: number; // parsed to number in the form
isAnonymous: boolean;
donationType: 'one_time' | 'recurring';
dedicationMessage: string; // allow '' from ui
showDedicationPublicly: boolean;
recurringInterval?: 'weekly' | 'bimonthly' | 'monthly' | 'quarterly';
};

export type CreateDonationResponse = { id: string };
type ApiError = { error?: string; message?: string };

export class ApiClient {
private axiosInstance: AxiosInstance;

Expand All @@ -11,7 +26,29 @@ export class ApiClient {
}

public async getHello(): Promise<string> {
return this.get('/api') as Promise<string>;
//return this.get('/api') as Promise<string>;
const res = await this.axiosInstance.get<string>('/api');
return res.data;
}

public async createDonation(
body: DonationCreateRequest,
): Promise<CreateDonationResponse> {
try {
const res = await this.axiosInstance.post('/api/donations', body);
return res.data as CreateDonationResponse;
} catch (err: unknown) {
if (axios.isAxiosError<ApiError>(err)) {
const data = err.response?.data;
const msg =
data?.error ??
data?.message ??
err.message ??
'Failed to create donation';
throw new Error(msg);
}
throw new Error('Failed to create donation');
}
}

private async get(path: string): Promise<unknown> {
Expand All @@ -36,3 +73,5 @@ export class ApiClient {
}

export default new ApiClient();

export type { DonationCreateRequest as CreateDonationRequest };
10 changes: 10 additions & 0 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import apiClient from '@api/apiClient';
import Root from '@containers/root';
import NotFound from '@containers/404';
import Test from '@containers/test';
import { DonationForm } from '@containers/donations/DonationForm';

const router = createBrowserRouter([
{
Expand All @@ -16,6 +17,15 @@ const router = createBrowserRouter([
path: '/test',
element: <Test />,
},
{
path: '/donate',
element: (
<DonationForm
onSuccess={(id) => console.log('Donation successful:', id)}
onError={(err) => console.error('Donation failed:', err)}
/>
),
},
]);

export const App: React.FC = () => {
Expand Down
137 changes: 137 additions & 0 deletions apps/frontend/src/containers/donations/DonationForm.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/** @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import apiClient from '../../api/apiClient';
import { DonationForm } from './DonationForm';
import type { CreateDonationResponse } from '../../api/apiClient';

describe('DonationForm Component', () => {
const onSuccess = vi.fn();
const onError = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
});

it('shows validation errors for empty fields', async () => {
const spy = vi.spyOn(apiClient, 'createDonation');
render(<DonationForm onSuccess={onSuccess} onError={onError} />);

fireEvent.click(screen.getByRole('button', { name: /submit donation/i }));

await waitFor(() => {
expect(screen.queryByText(/first name is required/i)).not.toBeNull();
expect(screen.queryByText(/last name is required/i)).not.toBeNull();
expect(screen.queryByText(/email is required/i)).not.toBeNull();
expect(screen.queryByText(/positive amount/i)).not.toBeNull();
});

expect(spy).not.toHaveBeenCalled();
});

it('submits valid donation and calls onSuccess', async () => {
const spy = vi
.spyOn(apiClient, 'createDonation')
.mockResolvedValueOnce({ id: '123' });

render(<DonationForm onSuccess={onSuccess} onError={onError} />);

fireEvent.change(screen.getByLabelText(/first name/i), {
target: { value: 'Hello' },
});
fireEvent.change(screen.getByLabelText(/last name/i), {
target: { value: 'Kitty' },
});
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'hello@kitty.com' },
});
fireEvent.change(screen.getByLabelText(/donation amount/i), {
target: { value: '25.50' },
});

fireEvent.click(screen.getByRole('button', { name: /submit donation/i }));

await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));

const payload = spy.mock.calls[0][0];
expect(payload).toMatchObject({
firstName: 'Hello',
lastName: 'Kitty',
email: 'hello@kitty.com',
amount: 25.5,
});
expect(onSuccess).toHaveBeenCalledWith('123');
});

it('shows error banner and calls onError when API fails', async () => {
const spy = vi
.spyOn(apiClient, 'createDonation')
.mockRejectedValueOnce(new Error('Network error'));

render(<DonationForm onSuccess={onSuccess} onError={onError} />);

fireEvent.change(screen.getByLabelText(/first name/i), {
target: { value: 'John' },
});
fireEvent.change(screen.getByLabelText(/last name/i), {
target: { value: 'Doe' },
});
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'john@northeastern.edu' },
});
fireEvent.change(screen.getByLabelText(/donation amount/i), {
target: { value: '50' },
});

fireEvent.click(screen.getByRole('button', { name: /submit donation/i }));

await waitFor(() => {
expect(screen.queryByText(/network error/i)).not.toBeNull();
});
expect(onError).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});

it('disables submit button while submitting', async () => {
let resolvePending!: (value: CreateDonationResponse) => void;

const pending = new Promise<CreateDonationResponse>((resolve) => {
resolvePending = resolve;
});
const spy = vi
.spyOn(apiClient, 'createDonation')
.mockReturnValueOnce(pending);

render(<DonationForm onSuccess={onSuccess} onError={onError} />);

fireEvent.change(screen.getByLabelText(/first name/i), {
target: { value: 'Scooby' },
});
fireEvent.change(screen.getByLabelText(/last name/i), {
target: { value: 'Doo' },
});
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'scooby@doobydoo.com' },
});
fireEvent.change(screen.getByLabelText(/donation amount/i), {
target: { value: '100' },
});

const button = screen.getByRole('button', { name: /submit donation/i });

fireEvent.click(button);

await waitFor(() => {
expect((button as HTMLButtonElement).disabled).toBe(true);
});

resolvePending({ id: 'ok' });

await waitFor(() => {
expect((button as HTMLButtonElement).disabled).toBe(false);
});

expect(spy).toHaveBeenCalledTimes(1);
});
});
Loading