diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 960f15ac8..9217b9d1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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() @@ -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: @@ -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 @@ -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 diff --git a/harvest-finance/backend/package.json b/harvest-finance/backend/package.json index 49fb09865..d6fea2fa1 100644 --- a/harvest-finance/backend/package.json +++ b/harvest-finance/backend/package.json @@ -144,6 +144,14 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + } } } diff --git a/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts b/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts index 22ae7265a..ea8fbb29d 100644 --- a/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts +++ b/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts @@ -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/, @@ -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, + }); }); }); diff --git a/harvest-finance/frontend/jest.config.js b/harvest-finance/frontend/jest.config.js index 8111f1390..0082b004d 100644 --- a/harvest-finance/frontend/jest.config.js +++ b/harvest-finance/frontend/jest.config.js @@ -13,6 +13,15 @@ const config = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, }; module.exports = createJestConfig(config); diff --git a/harvest-finance/frontend/src/app/login/login-page.test.tsx b/harvest-finance/frontend/src/app/login/login-page.test.tsx new file mode 100644 index 000000000..709e1ea29 --- /dev/null +++ b/harvest-finance/frontend/src/app/login/login-page.test.tsx @@ -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; + +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(); + + 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(); + + 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'); + }); +}); \ No newline at end of file diff --git a/harvest-finance/frontend/src/components/dashboard/dashboard-components.test.tsx b/harvest-finance/frontend/src/components/dashboard/dashboard-components.test.tsx new file mode 100644 index 000000000..e2b2c8a46 --- /dev/null +++ b/harvest-finance/frontend/src/components/dashboard/dashboard-components.test.tsx @@ -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 }) =>
{children}
, + AreaChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + LineChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + 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(); + + 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(); + 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(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('img', { name: 'APY chart' })).toBeInTheDocument()); + + getApyHistory.mockRejectedValueOnce(new Error('RPC unavailable')); + rerender(); + await waitFor(() => expect(screen.getByText('RPC unavailable')).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/harvest-finance/frontend/src/components/ui/ui-components.test.tsx b/harvest-finance/frontend/src/components/ui/ui-components.test.tsx new file mode 100644 index 000000000..18ba2fcc1 --- /dev/null +++ b/harvest-finance/frontend/src/components/ui/ui-components.test.tsx @@ -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( + , + ); + + 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(); + + 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( + + Hidden + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + rerender( + + + Visible content + , + ); + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByText('Visible content')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file