Skip to content
Open
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
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ jobs:
cache: npm
cache-dependency-path: harvest-finance/backend/package-lock.json

- run: npm ci || true
- run: npm run lint || true
- run: npm ci
- run: npm run lint

backend-test:
name: Backend — Unit + Integration Tests
Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:

- run: npm ci
- run: npm run build
- run: npm test -- --forceExit --passWithNoTests || true
- run: npm run test:cov -- --forceExit
- name: Upload coverage
uses: actions/upload-artifact@v4
if: always()
Expand Down Expand Up @@ -149,7 +149,7 @@ jobs:
cache-dependency-path: harvest-finance/frontend/package-lock.json

- run: npm ci
- run: npm test -- --passWithNoTests
- run: npm run test:cov -- --passWithNoTests
- run: npm run test:vitest -- --run --passWithNoTests

frontend-build:
Expand All @@ -171,7 +171,7 @@ jobs:
cache: npm
cache-dependency-path: harvest-finance/frontend/package-lock.json

- run: npm ci || true
- run: npm ci
- run: npm run build
- name: Route-level bundle budget
run: npm run check:budget
Expand Down Expand Up @@ -207,11 +207,11 @@ jobs:

- name: Run forge tests
working-directory: contracts
run: forge test -vvv || true
run: forge test -vvv

- name: Forge coverage
working-directory: contracts
run: forge coverage --report lcov || true
run: forge coverage --report lcov

- name: Upload coverage
uses: actions/upload-artifact@v4
Expand Down
10 changes: 9 additions & 1 deletion harvest-finance/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
"testEnvironment": "node",
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ describe('InputSanitizerService', () => {
).toBe(validStellarPublicKey);
});

it.each(['', ' ', 'G' + 'A'.repeat(55), validStellarPublicKey.slice(0, -1)])(
'rejects invalid public key "%s"',
(value) => {
expect(() => service.validateStellarPublicKey(value)).toThrow(
BadRequestException,
);
},
);

it.each([null, undefined, 42, {}, []])(
'rejects non-string public key value %p',
(value) => {
expect(() =>
service.validateStellarPublicKey(value as unknown as string),
).toThrow(BadRequestException);
},
);

it('rejects malformed Stellar public keys with format guidance', () => {
expect(() => service.validateStellarPublicKey('invalid')).toThrow(
/G-address with a correct Stellar StrKey checksum/,
Expand Down Expand Up @@ -173,18 +191,88 @@ describe('InputSanitizerService', () => {
});

describe('validateAmount', () => {
it.each([
[0, 0],
[100.5, 100.5],
['25.25', 25.25],
])('accepts amount %p and returns %p', (input, expected) => {
expect(service.validateAmount(input)).toBe(expected);
});

it.each([
[-0.01, 0, 100],
[100.01, 0, 100],
[10, 11, 20],
[10, 0, 9],
])('rejects amount %p outside [%p, %p]', (input, min, max) => {
expect(() => service.validateAmount(input, min, max)).toThrow(
BadRequestException,
);
});

it('rejects non-finite amounts with bounds guidance', () => {
expect(() => service.validateAmount(Number.POSITIVE_INFINITY)).toThrow(
/finite numeric value/,
);
});

it.each([NaN, Number.NEGATIVE_INFINITY, 'not-a-number', {}])(
'rejects non-numeric amount %p',
(value) => {
expect(() => service.validateAmount(value)).toThrow(BadRequestException);
},
);
});

describe('sanitizeString', () => {
it('trims surrounding whitespace and removes null bytes', () => {
expect(service.sanitizeString(' hello\0 world ')).toBe('hello world');
});

it('accepts a value exactly at the maximum length', () => {
expect(service.sanitizeString('a'.repeat(3), 3)).toBe('aaa');
});

it('rejects oversized strings with max length guidance', () => {
expect(() => service.sanitizeString('abcd', 3)).toThrow(
/3 characters or fewer/,
);
});

it.each([null, undefined, 123, {}, []])(
'rejects non-string input %p',
(value) => {
expect(() => service.sanitizeString(value as unknown as string)).toThrow(
BadRequestException,
);
},
);
});

describe('validatePagination', () => {
it('uses safe defaults when parameters are omitted', () => {
expect(service.validatePagination()).toEqual({ skip: 0, limit: 20 });
});

it('floors values and clamps skip and limit to safe bounds', () => {
expect(service.validatePagination(4.9, 8.9, 10)).toEqual({
skip: 4,
limit: 8,
});
expect(service.validatePagination(-3, 0, 10)).toEqual({
skip: 0,
limit: 1,
});
expect(service.validatePagination(2, 100, 10)).toEqual({
skip: 2,
limit: 10,
});
});

it('handles invalid numeric values using defaults', () => {
expect(service.validatePagination(NaN, NaN)).toEqual({
skip: 0,
limit: 20,
});
});
});
9 changes: 9 additions & 0 deletions harvest-finance/frontend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ const config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};

module.exports = createJestConfig(config);
50 changes: 50 additions & 0 deletions harvest-finance/frontend/src/app/login/login-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import LoginPage from './page';
import { useAuthStore } from '@/lib/stores/auth-store';

jest.mock('@/lib/stores/auth-store');
jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) }));
jest.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));

const mockedAuthStore = useAuthStore as jest.MockedFunction<typeof useAuthStore>;

describe('LoginPage', () => {
it('shows validation errors and submits valid credentials', async () => {
const login = jest.fn().mockResolvedValue(undefined);
mockedAuthStore.mockReturnValue({
login,
isLoading: false,
error: null,
clearError: jest.fn(),
isAuthenticated: false,
hydrate: jest.fn(),
} as any);
render(<LoginPage />);

fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
expect(await screen.findByText('Please enter a valid email address')).toBeInTheDocument();

fireEvent.change(screen.getByLabelText('Email address'), { target: { value: 'farmer@example.com' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => expect(login).toHaveBeenCalledWith('farmer@example.com', 'secret'));
});

it('renders store errors and toggles password visibility', () => {
mockedAuthStore.mockReturnValue({
login: jest.fn(),
isLoading: false,
error: 'Invalid credentials',
clearError: jest.fn(),
isAuthenticated: false,
hydrate: jest.fn(),
} as any);
render(<LoginPage />);

expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
const password = screen.getByLabelText('Password');
fireEvent.click(screen.getByRole('button', { name: 'Show password' }));
expect(password).toHaveAttribute('type', 'text');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { VaultTable } from './VaultTable';
import { YieldChart } from '../YieldChart';
import { vaultApi } from '@/lib/api/vault-client';

jest.mock('@/lib/i18n', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

jest.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
AreaChart: ({ children }: { children: React.ReactNode }) => <div role="img" aria-label="APY chart">{children}</div>,
LineChart: ({ children }: { children: React.ReactNode }) => <div role="img" aria-label="APY chart">{children}</div>,
Area: () => null,
Line: () => null,
XAxis: () => null,
YAxis: () => null,
CartesianGrid: () => null,
Tooltip: () => null,
}));

jest.mock('@/lib/api/vault-client', () => ({
vaultApi: { getApyHistory: jest.fn() },
}));

const vaults = [
{ id: 'low', name: 'Low Risk', asset: 'USDC', apy: 4, tvl: 100, riskLevel: 'Low' as const, balance: '0', walletBalance: '50', seasonalTarget: 10 },
{ id: 'high', name: 'High Yield', asset: 'XLM', apy: 9, tvl: 200, riskLevel: 'High' as const, balance: '0', walletBalance: '50', seasonalTarget: 10 },
];

describe('VaultTable', () => {
it('sorts vaults and routes deposit and withdrawal actions', () => {
const onDeposit = jest.fn();
const onWithdraw = jest.fn();
render(<VaultTable vaults={vaults} onDeposit={onDeposit} onWithdraw={onWithdraw} />);

expect(screen.getByText('High Yield')).toBeInTheDocument();
fireEvent.click(screen.getByText('dashboard.vault_name'));
expect(screen.getAllByRole('row')[1]).toHaveTextContent('High Yield');

fireEvent.click(screen.getAllByRole('button', { name: 'common.deposit' })[0]);
fireEvent.click(screen.getAllByRole('button', { name: 'common.withdraw' })[0]);
expect(onDeposit).toHaveBeenCalledWith('high');
expect(onWithdraw).toHaveBeenCalledWith('high');
});

it('renders an empty state', () => {
render(<VaultTable vaults={[]} onDeposit={jest.fn()} onWithdraw={jest.fn()} />);
expect(screen.getByText('dashboard.no_vaults_found')).toBeInTheDocument();
});
});

describe('YieldChart', () => {
it('renders loading, data, and API error states', async () => {
const getApyHistory = vaultApi.getApyHistory as jest.Mock;
getApyHistory.mockResolvedValueOnce([{ date: '2026-01-01', apy: 7.25 }]);
const { rerender } = render(<YieldChart vaultId="vault-1" />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('img', { name: 'APY chart' })).toBeInTheDocument());

getApyHistory.mockRejectedValueOnce(new Error('RPC unavailable'));
rerender(<YieldChart vaultId="vault-2" />);
await waitFor(() => expect(screen.getByText('RPC unavailable')).toBeInTheDocument());
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});
51 changes: 51 additions & 0 deletions harvest-finance/frontend/src/components/ui/ui-components.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Button } from './Button';
import { Modal, ModalBody, ModalHeader } from './Modal';

describe('Button', () => {
it('renders content, forwards type, and invokes its handler', () => {
const onClick = jest.fn();
render(
<Button type="submit" leftIcon={<span aria-hidden="true">+</span>} onClick={onClick}>
Save
</Button>,
);

fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onClick).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});

it('disables the button and exposes busy state while loading', () => {
render(<Button isLoading>Saving</Button>);

expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
expect(screen.getByText('Saving')).toBeInTheDocument();
});
});

describe('Modal', () => {
it('renders nothing when closed and supports close button and Escape', () => {
const onClose = jest.fn();
const { rerender } = render(
<Modal isOpen={false} onClose={onClose}>
Hidden
</Modal>,
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

rerender(
<Modal isOpen onClose={onClose}>
<ModalHeader title="Confirm" />
<ModalBody>Visible content</ModalBody>
</Modal>,
);

expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
expect(screen.getByText('Visible content')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});