64 KiB
Raw Permalink Blame History

Frontend Test Coverage Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add comprehensive frontend unit tests (vitest + RTL + MSW) across all existing modules

Architecture: MSW intercepts fetch at the network level, so no production code changes are needed. Each test uses renderWithProviders (QueryClient + AuthProvider + MemoryRouter wrapper). Fresh QueryClient per test with retry: false.

Tech Stack: vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom, msw v2


Task 1: Infrastructure — vitest config + test helpers

Files:

  • Create: apps/frontend/vitest.config.ts

  • Create: apps/frontend/src/test/setup.ts

  • Create: apps/frontend/src/test/server.ts

  • Create: apps/frontend/src/test/handlers.ts

  • Create: apps/frontend/src/test/test-utils.tsx

  • Create: apps/frontend/src/test/factories.ts

  • Modify: apps/frontend/package.json (add test scripts)

  • Modify: package.json (add root workspace script)

  • Step 1: Create vitest.config.ts

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
  },
});
  • Step 2: Create test/setup.ts
import '@testing-library/jest-dom';
import { server } from './server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: () => {},
    removeListener: () => {},
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => false,
  }),
});
  • Step 3: Create test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
  • Step 4: Create test/handlers.ts
import { http, HttpResponse } from 'msw';
import type {
  ShareResponse,
  BondResponse,
  CandleItem,
  DividendItem,
  SearchResultItem,
  AuthResponse,
  UserResponse,
} from '../api/responses';

const API = '/api/v1';

const mockUser: UserResponse = {
  id: 1,
  email: 'user@test.com',
  name: 'Test User',
  role: 'user',
};

const mockAuth: AuthResponse = {
  user: mockUser,
  accessToken: 'mock-access-token',
};

const mockShare: ShareResponse = {
  secid: 'SBER',
  isin: 'RU0009029540',
  name: 'Сбер Банк',
  shortName: 'Сбер',
  latName: 'Sberbank',
  listLevel: 1,
  issueSize: 21586900000,
  faceValue: 3,
  faceUnit: 'RUB',
  type: 'common_share',
  marketData: {
    price: 289.5,
    change: 2.5,
    changePercent: 0.87,
    open: 287,
    high: 291,
    low: 286.5,
    volume: 15000000,
    value: 4350000000,
    issueCapitalization: 6250000000000,
    updatedAt: '2024-01-15T10:00:00Z',
  },
};

const mockBond: BondResponse = {
  secid: 'SU26238RMFS5',
  isin: 'RU000A101XU7',
  name: 'ОФЗ 26238',
  shortName: 'ОФЗ 26238',
  latName: null,
  listLevel: 1,
  issueSize: 500000000,
  faceValue: 1000,
  faceUnit: 'RUB',
  matDate: '2027-05-15',
  couponValue: 36.9,
  couponPercent: 7.5,
  couponPeriod: 182,
  nextCoupon: '2024-07-15',
  accruedInt: 8.45,
  bondType: 'ОФЗ',
  bondSubType: 'ОФЗ-ПД',
  offerDate: null,
  buybackDate: null,
  marketData: {
    price: 98.5,
    yieldToMaturity: 8.2,
    duration: 3.5,
    accruedInt: 8.45,
    couponValue: 36.9,
    couponPercent: 7.5,
    nextCouponDate: '2024-07-15',
    open: 98.0,
    high: 99.0,
    low: 97.5,
    volume: 1000000,
    updatedAt: '2024-01-15T10:00:00Z',
  },
};

const mockCandles: CandleItem[] = [
  { open: 280, high: 290, low: 278, close: 289.5, volume: 1000000, value: 280000000, begin: '2024-01-15T10:00:00Z', end: '2024-01-15T18:00:00Z' },
  { open: 289, high: 292, low: 285, close: 288, volume: 800000, value: 231200000, begin: '2024-01-16T10:00:00Z', end: '2024-01-16T18:00:00Z' },
];

const mockDividends: DividendItem[] = [
  { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
  { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' },
];

const mockSearchResults: SearchResultItem[] = [
  { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 },
  { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 },
];

export const handlers = [
  http.get(`${API}/securities/search`, ({ request }) => {
    const url = new URL(request.url);
    const q = url.searchParams.get('q') || '';
    if (q.length < 2) return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
    const filtered = mockSearchResults.filter(r =>
      r.secid.toLowerCase().includes(q.toLowerCase()) ||
      r.shortName.toLowerCase().includes(q.toLowerCase()),
    );
    return HttpResponse.json({ data: { data: filtered, meta: { fromCache: false, cachedAt: null } } });
  }),

  http.get(`${API}/securities/shares/:secid`, ({ params }) => {
    const { secid } = params;
    if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 });
    return HttpResponse.json({ data: { data: { ...mockShare, secid } as ShareResponse, meta: { fromCache: false, cachedAt: null } } });
  }),

  http.get(`${API}/securities/shares/:secid/candles`, () =>
    HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.get(`${API}/securities/shares/:secid/dividends`, () =>
    HttpResponse.json({ data: { data: mockDividends, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.get(`${API}/securities/bonds/:secid`, ({ params }) => {
    const { secid } = params;
    if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 });
    return HttpResponse.json({ data: { data: { ...mockBond, secid } as BondResponse, meta: { fromCache: false, cachedAt: null } } });
  }),

  http.get(`${API}/securities/bonds/:secid/candles`, () =>
    HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.get(`${API}/auth/me`, () =>
    HttpResponse.json({ data: { data: mockUser, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.post(`${API}/auth/login`, () =>
    HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.post(`${API}/auth/register`, () =>
    HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.post(`${API}/auth/refresh`, () =>
    HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.post(`${API}/auth/logout`, () => new HttpResponse(null, { status: 204 })),

  http.patch(`${API}/auth/me`, () =>
    HttpResponse.json({ data: { data: { ...mockUser, name: 'Updated' }, meta: { fromCache: false, cachedAt: null } } }),
  ),

  http.get(`${API}/health`, () =>
    HttpResponse.json({ data: { data: { status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }, meta: { fromCache: false, cachedAt: null } } }),
  ),
];

export { mockUser, mockAuth, mockShare, mockBond, mockCandles, mockDividends, mockSearchResults };
  • Step 5: Create test/test-utils.tsx
import { type ReactElement } from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '../context/AuthContext';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  queryClient?: QueryClient;
  route?: string;
}

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
      mutations: { retry: false },
    },
  });
}

export function renderWithProviders(
  ui: ReactElement,
  { queryClient = createTestQueryClient(), route = '/', ...renderOptions }: CustomRenderOptions = {},
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={[route]}>
          <AuthProvider>
            {children}
          </AuthProvider>
        </MemoryRouter>
      </QueryClientProvider>
    );
  }

  return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient };
}
  • Step 6: Create test/factories.ts
import type { ShareResponse, BondResponse, CandleItem, DividendItem, SearchResultItem, UserResponse, AuthResponse } from '../api/responses';

export function createMockShare(overrides: Partial<ShareResponse> = {}): ShareResponse {
  return {
    secid: 'SBER',
    isin: 'RU0009029540',
    name: 'Сбер Банк',
    shortName: 'Сбер',
    latName: 'Sberbank',
    listLevel: 1,
    issueSize: 21586900000,
    faceValue: 3,
    faceUnit: 'RUB',
    type: 'common_share',
    marketData: {
      price: 289.5,
      change: 2.5,
      changePercent: 0.87,
      open: 287,
      high: 291,
      low: 286.5,
      volume: 15000000,
      value: 4350000000,
      issueCapitalization: 6250000000000,
      updatedAt: '2024-01-15T10:00:00Z',
    },
    ...overrides,
  };
}

export function createMockBond(overrides: Partial<BondResponse> = {}): BondResponse {
  return {
    secid: 'SU26238RMFS5',
    isin: 'RU000A101XU7',
    name: 'ОФЗ 26238',
    shortName: 'ОФЗ 26238',
    latName: null,
    listLevel: 1,
    issueSize: 500000000,
    faceValue: 1000,
    faceUnit: 'RUB',
    matDate: '2027-05-15',
    couponValue: 36.9,
    couponPercent: 7.5,
    couponPeriod: 182,
    nextCoupon: '2024-07-15',
    accruedInt: 8.45,
    bondType: 'ОФЗ',
    bondSubType: 'ОФЗ-ПД',
    offerDate: null,
    buybackDate: null,
    marketData: {
      price: 98.5,
      yieldToMaturity: 8.2,
      duration: 3.5,
      accruedInt: 8.45,
      couponValue: 36.9,
      couponPercent: 7.5,
      nextCouponDate: '2024-07-15',
      open: 98.0,
      high: 99.0,
      low: 97.5,
      volume: 1000000,
      updatedAt: '2024-01-15T10:00:00Z',
    },
    ...overrides,
  };
}

export function createMockUser(overrides: Partial<UserResponse> = {}): UserResponse {
  return { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', ...overrides };
}

export function createMockAuth(overrides: Partial<AuthResponse> = {}): AuthResponse {
  return { user: createMockUser(), accessToken: 'mock-token', ...overrides };
}

export function createMockCandles(count = 2): CandleItem[] {
  return Array.from({ length: count }, (_, i) => ({
    open: 280 + i, high: 290 + i, low: 278 + i, close: 289.5 + i,
    volume: 1000000, value: 280000000,
    begin: `2024-01-${15 + i}T10:00:00Z`,
    end: `2024-01-${15 + i}T18:00:00Z`,
  }));
}

export function createMockDividends(): DividendItem[] {
  return [
    { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
  ];
}

export function createMockSearchResults(): SearchResultItem[] {
  return [
    { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 },
    { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 },
  ];
}
  • Step 7: Add scripts to apps/frontend/package.json
"test": "vitest run",
"test:watch": "vitest"
  • Step 8: Add to root package.json
"test:frontend": "npm run test -w apps/frontend"
  • Step 9: Install dependencies

Run: npm install -w apps/frontend vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw @types/node

  • Step 10: Verify setup

Run: npm run test:frontend Expected: "No test files found, exiting with code 0" (no tests yet, but config works)


Task 2: API client tests

Files:

  • Create: apps/frontend/src/api/client.test.ts

  • Step 1: Create client.test.ts

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client';

const API = '/api/v1';

beforeEach(() => {
  setAccessToken(null);
});

describe('request', () => {
  it('makes GET request and returns data', async () => {
    const result = await request<{ message: string }>('/api/v1/health');
    expect(result.data).toEqual({
      status: 'ok',
      timestamp: expect.any(String),
      uptime: 12345,
    });
  });

  it('includes Authorization header when token is set', async () => {
    setAccessToken('test-token');
    let capturedAuth: string | null = null;
    server.use(
      http.get(`${API}/test-auth`, ({ request }) => {
        capturedAuth = request.headers.get('Authorization');
        return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    await request('/api/v1/test-auth');
    expect(capturedAuth).toBe('Bearer test-token');
  });

  it('retries on 401 and succeeds after refresh', async () => {
    setAccessToken('expired-token');
    let attempts = 0;
    server.use(
      http.get(`${API}/test-retry`, ({ request }) => {
        attempts++;
        const auth = request.headers.get('Authorization');
        if (auth === 'Bearer expired-token') {
          return new HttpResponse(null, { status: 401 });
        }
        return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
      }),
      http.post(`${API}/auth/refresh`, () =>
        HttpResponse.json({ data: { data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'new-token' }, meta: { fromCache: false, cachedAt: null } } }),
      ),
    );
    const result = await request<{ ok: boolean }>('/api/v1/test-retry');
    expect(attempts).toBe(2);
    expect(result.data).toEqual({ ok: true });
    expect(getAccessToken()).toBe('new-token');
  });

  it('throws on persistent 401 and clears token', async () => {
    setAccessToken('expired-token');
    let unauthorizedCalled = false;
    setOnUnauthorized(() => { unauthorizedCalled = true; });
    server.use(
      http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла');
    expect(getAccessToken()).toBeNull();
    expect(unauthorizedCalled).toBe(true);
  });

  it('throws on non-ok response with status text', async () => {
    server.use(
      http.get(`${API}/test-error`, () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' })),
    );
    await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404');
  });

  it('sends JSON body for POST requests', async () => {
    let capturedBody: string | null = null;
    server.use(
      http.post(`${API}/test-post`, async ({ request }) => {
        capturedBody = await request.text();
        return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } });
    expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }));
  });

  it('does not send auth header when skipAuth is true', async () => {
    setAccessToken('test-token');
    let capturedAuth: string | null = null;
    server.use(
      http.get(`${API}/test-skip`, ({ request }) => {
        capturedAuth = request.headers.get('Authorization');
        return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    await request('/api/v1/test-skip', undefined, { skipAuth: true });
    expect(capturedAuth).toBeNull();
  });

  it('sets query params correctly', async () => {
    let capturedUrl = '';
    server.use(
      http.get(`${API}/test-params`, ({ request }) => {
        capturedUrl = request.url;
        return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    await request('/api/v1/test-params', { q: 'sber', type: 'share' });
    expect(capturedUrl).toContain('q=sber');
    expect(capturedUrl).toContain('type=share');
  });
});
  • Step 2: Run tests

Run: npx vitest run apps/frontend/src/api/client.test.ts -w apps/frontend Expected: all passing


Task 3: Auth API tests

Files:

  • Create: apps/frontend/src/api/auth.test.ts

  • Step 1: Create auth.test.ts

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { setAccessToken, getAccessToken } from './client';
import { login, register, refresh, logout, getMe, updateProfile } from './auth';

const API = '/api/v1';

beforeEach(() => {
  setAccessToken(null);
});

describe('login', () => {
  it('returns auth data and sets access token', async () => {
    const result = await login('user@test.com', 'password');
    expect(result.user.email).toBe('user@test.com');
    expect(result.accessToken).toBe('mock-access-token');
    expect(getAccessToken()).toBe('mock-access-token');
  });

  it('throws on invalid credentials', async () => {
    server.use(
      http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })),
    );
    await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
  });
});

describe('register', () => {
  it('returns auth data and sets access token', async () => {
    const result = await register('new@test.com', 'password', 'New User');
    expect(result.user.email).toBe('user@test.com');
    expect(getAccessToken()).toBe('mock-access-token');
  });
});

describe('refresh', () => {
  it('returns auth data and sets access token', async () => {
    const result = await refresh();
    expect(result.accessToken).toBe('mock-access-token');
    expect(getAccessToken()).toBe('mock-access-token');
  });
});

describe('logout', () => {
  it('clears access token', async () => {
    setAccessToken('test-token');
    await logout();
    expect(getAccessToken()).toBeNull();
  });
});

describe('getMe', () => {
  it('returns current user', async () => {
    const result = await getMe();
    expect(result.email).toBe('user@test.com');
  });
});

describe('updateProfile', () => {
  it('updates and returns user', async () => {
    const result = await updateProfile({ name: 'Updated' });
    expect(result.name).toBe('Updated');
  });
});
  • Step 2: Run tests

Run: npx vitest run apps/frontend/src/api/auth.test.ts -w apps/frontend Expected: all passing


Task 4: AuthContext tests

Files:

  • Create: apps/frontend/src/context/AuthContext.test.tsx

  • Step 1: Create AuthContext.test.tsx

import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { AuthProvider, AuthContext, type AuthContextValue } from './AuthContext';
import { useContext } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const API = '/api/v1';

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>,
  );
}

function TestConsumer() {
  const ctx = useContext(AuthContext);
  if (!ctx) return <div>no context</div>;
  return (
    <div>
      <span data-testid="auth">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
      <span data-testid="email">{ctx.user?.email ?? ''}</span>
      <button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
      <button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
      <button onClick={() => ctx.logout()}>logout</button>
      <button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
    </div>
  );
}

describe('AuthContext', () => {
  it('starts unauthenticated when refresh fails', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    renderWithProviders(
      <AuthProvider>
        <TestConsumer />
      </AuthProvider>,
    );
    await waitFor(() => {
      expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
    });
  });

  it('restores session on mount when refresh succeeds', async () => {
    renderWithProviders(
      <AuthProvider>
        <TestConsumer />
      </AuthProvider>,
    );
    await waitFor(() => {
      expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
      expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
    });
  });

  it('updates state after login', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    const user = userEvent.setup();
    renderWithProviders(
      <AuthProvider>
        <TestConsumer />
      </AuthProvider>,
    );
    await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'));
    await user.click(screen.getByRole('button', { name: 'login' }));
    await waitFor(() => {
      expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
    });
  });

  it('updates state after logout', async () => {
    const user = userEvent.setup();
    renderWithProviders(
      <AuthProvider>
        <TestConsumer />
      </AuthProvider>,
    );
    await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
    await user.click(screen.getByRole('button', { name: 'logout' }));
    await waitFor(() => {
      expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
    });
  });
});
  • Step 2: Run tests

Run: npx vitest run apps/frontend/src/context/AuthContext.test.tsx -w apps/frontend Expected: all passing


Task 5: Hook tests

Files:

  • Create: apps/frontend/src/hooks/useAuth.test.tsx

  • Create: apps/frontend/src/hooks/useSearch.test.tsx

  • Create: apps/frontend/src/hooks/useStock.test.tsx

  • Create: apps/frontend/src/hooks/useStockCandles.test.tsx

  • Create: apps/frontend/src/hooks/useStockDividends.test.tsx

  • Create: apps/frontend/src/hooks/useBond.test.tsx

  • Create: apps/frontend/src/hooks/useBondCandles.test.tsx

  • Step 1: Create useAuth.test.tsx

import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
import { renderWithProviders } from '../test/test-utils';

describe('useAuth', () => {
  it('returns auth context values', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper: ({ children }) => {
      const { Wrapper } = renderWithProviders(<>{children}</>);
      return <>{children}</>;
    }});
    // Actually use renderWithProviders via wrapper
  });

  it('throws when used outside AuthProvider', () => {
    const { renderHook: render } = await import('@testing-library/react');
    // Will be tested inline
  });
});

Wait — actually let me write proper, complete tests. Let me re-do this.

  • Step 1: Create useAuth.test.tsx properly
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '../context/AuthContext';
import { useAuth } from './useAuth';
import { type ReactNode } from 'react';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>{children}</AuthProvider>
      </QueryClientProvider>
    );
  };
}

describe('useAuth', () => {
  it('returns auth context with user after mount', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(true);
    });
    expect(result.current.user?.email).toBe('user@test.com');
    expect(result.current.accessToken).toBe('mock-access-token');
  });

  it('provides login function', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
    expect(typeof result.current.login).toBe('function');
  });

  it('provides logout function', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
    expect(typeof result.current.logout).toBe('function');
  });

  it('provides register function', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
    expect(typeof result.current.register).toBe('function');
  });

  it('throws when used without AuthProvider', () => {
    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
    expect(() => {
      renderHook(() => useAuth(), {
        wrapper: ({ children }) => (
          <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
        ),
      });
    }).toThrow('useAuth must be used within an AuthProvider');
  });
});
  • Step 2: Create useSearch.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useSearch } from './useSearch';
import { type ReactNode } from 'react';

const API = '/api/v1';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  };
}

describe('useSearch', () => {
  it('does not fetch when query is empty', () => {
    const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
    expect(result.current.isFetching).toBe(false);
    expect(result.current.data).toBeUndefined();
  });

  it('does not fetch when query is too short', () => {
    const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
    expect(result.current.data).toBeUndefined();
  });

  it('returns search results for valid query', async () => {
    const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
    expect(result.current.data).toBeDefined();
    expect(result.current.data?.length).toBeGreaterThan(0);
    expect(result.current.data?.[0].secid).toBe('SBER');
  });

  it('returns empty array when no results', async () => {
    server.use(
      http.get(`${API}/securities/search`, () => {
        return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
    expect(result.current.data).toEqual([]);
  });

  it('returns error state on network failure', async () => {
    server.use(
      http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })),
    );
    const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
    await waitFor(() => {
      expect(result.current.isError).toBe(true);
    });
  });
});
  • Step 3: Create useStock.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useStock } from './useStock';
import { type ReactNode } from 'react';

const API = '/api/v1';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  };
}

describe('useStock', () => {
  it('returns share data', async () => {
    const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data?.secid).toBe('SBER');
    expect(result.current.data?.shortName).toBe('Сбер');
    expect(result.current.data?.marketData.price).toBe(289.5);
  });

  it('returns error for not found', async () => {
    const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isError).toBe(true));
  });

  it('starts in loading state', () => {
    const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() });
    expect(result.current.isLoading).toBe(true);
  });
});
  • Step 4: Create useStockCandles.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useStockCandles } from './useStockCandles';
import { type ReactNode } from 'react';

const API = '/api/v1';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  };
}

describe('useStockCandles', () => {
  it('returns candle data', async () => {
    const { result } = renderHook(
      () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
      { wrapper: createWrapper() },
    );
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toHaveLength(2);
    expect(result.current.data?.[0].open).toBe(280);
  });

  it('returns empty array when no candles', async () => {
    server.use(
      http.get(`${API}/securities/shares/:secid/candles`, () => {
        return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    const { result } = renderHook(
      () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
      { wrapper: createWrapper() },
    );
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toEqual([]);
  });
});
  • Step 5: Create useStockDividends.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useStockDividends } from './useStockDividends';
import { type ReactNode } from 'react';

const API = '/api/v1';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  };
}

describe('useStockDividends', () => {
  it('returns dividend data', async () => {
    const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toHaveLength(2);
    expect(result.current.data?.[0].value).toBe(35);
  });

  it('returns empty array when no dividends', async () => {
    server.use(
      http.get(`${API}/securities/shares/:secid/dividends`, () => {
        return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toEqual([]);
  });
});
  • Step 6: Create useBond.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useBond } from './useBond';
import { type ReactNode } from 'react';

const API = '/api/v1';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  };
}

describe('useBond', () => {
  it('returns bond data', async () => {
    const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data?.shortName).toBe('ОФЗ 26238');
    expect(result.current.data?.marketData.price).toBe(98.5);
  });

  it('returns error on 404', async () => {
    const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() });
    await waitFor(() => expect(result.current.isError).toBe(true));
  });
});
  • Step 7: Create useBondCandles.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useBondCandles } from './useBondCandles';
import { type ReactNode } from 'react';

const API = '/api/v1';

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
  };
}

describe('useBondCandles', () => {
  it('returns candle data', async () => {
    const { result } = renderHook(
      () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
      { wrapper: createWrapper() },
    );
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toHaveLength(2);
  });

  it('returns empty array when no candles', async () => {
    server.use(
      http.get(`${API}/securities/bonds/:secid/candles`, () => {
        return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    const { result } = renderHook(
      () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
      { wrapper: createWrapper() },
    );
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toEqual([]);
  });
});
  • Step 8: Run all hook tests

Run: npx vitest run apps/frontend/src/hooks/ -w apps/frontend Expected: all passing


Task 6: UI Component tests

Files:

  • Create: apps/frontend/src/components/SearchBar.test.tsx

  • Create: apps/frontend/src/components/ProtectedRoute.test.tsx

  • Create: apps/frontend/src/components/StockDetails.test.tsx

  • Create: apps/frontend/src/components/BondDetails.test.tsx

  • Create: apps/frontend/src/components/PriceChart.test.tsx

  • Create: apps/frontend/src/components/Layout.test.tsx

  • Step 1: Create SearchBar.test.tsx

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/test-utils';

describe('SearchBar', () => {
  it('renders input with placeholder', () => {
    renderWithProviders(<div />); // SearchBar is inside Layout, test it within
  });
});

Actually the SearchBar is rendered through Layout but can be tested standalone. Let me fix:

import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { SearchBar } from './SearchBar';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

function renderSearchBar() {
  return renderWithProviders(<SearchBar />);
}

describe('SearchBar', () => {
  it('renders search input', () => {
    renderSearchBar();
    expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
  });

  it('shows dropdown on focus', async () => {
    renderSearchBar();
    const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
    await userEvent.type(input, 'sber');
    await waitFor(() => {
      expect(screen.getByText('Сбер')).toBeInTheDocument();
    });
  });

  it('shows loading state while fetching', async () => {
    server.use(
      http.get(`${API}/securities/search`, () => {
        return new Promise(() => {}); // never resolves
      }),
    );
    renderSearchBar();
    const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
    await userEvent.type(input, 'sber');
    await waitFor(() => {
      expect(screen.getByText('Загрузка...')).toBeInTheDocument();
    });
  });

  it('shows no results message', async () => {
    server.use(
      http.get(`${API}/securities/search`, () => {
        return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    renderSearchBar();
    const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
    await userEvent.type(input, 'zzzzz');
    await waitFor(() => {
      expect(screen.getByText('Ничего не найдено')).toBeInTheDocument();
    });
  });

  it('hides dropdown when clicking outside', async () => {
    renderSearchBar();
    const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
    await userEvent.type(input, 'sber');
    await waitFor(() => {
      expect(screen.getByText('Сбер')).toBeInTheDocument();
    });
    await userEvent.click(document.body);
    await waitFor(() => {
      expect(screen.queryByText('Сбер')).not.toBeInTheDocument();
    });
  });

  it('navigates to stock page on share result click', async () => {
    const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() }));
    vi.mock('react-router-dom', async (importOriginal) => {
      const actual = await importOriginal();
      return { ...(actual as object), useNavigate: () => mockNavigate };
    });

    renderSearchBar();
    const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
    await userEvent.type(input, 'sber');
    await waitFor(() => {
      expect(screen.getByText('Сбер')).toBeInTheDocument();
    });
    await userEvent.click(screen.getByText('Сбер'));
    expect(mockNavigate).toHaveBeenCalledWith('/stocks/SBER');
  });
});
  • Step 2: Create ProtectedRoute.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProtectedRoute } from './ProtectedRoute';

describe('ProtectedRoute', () => {
  it('renders children when authenticated', async () => {
    // Renders through AuthProvider which auto-authenticates via refresh
    const { renderWithProviders } = await import('../test/test-utils');
    renderWithProviders(
      <ProtectedRoute>
        <div data-testid="protected-content">Secret</div>
      </ProtectedRoute>,
    );
    const content = await screen.findByTestId('protected-content');
    expect(content).toBeInTheDocument();
  });

  it('redirects to login when not authenticated', async () => {
    const { http, HttpResponse } = await import('msw');
    const { server } = await import('../test/server');
    const API = '/api/v1';

    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );

    const { renderWithProviders } = await import('../test/test-utils');
    renderWithProviders(
      <ProtectedRoute>
        <div data-testid="protected-content">Secret</div>
      </ProtectedRoute>,
      { route: '/profile' },
    );

    await screen.findByText('Загрузка...');

    // After loading, should redirect to login
    // Use findByText for async redirect check
  });
});

Actually this imports are messy with dynamic imports. Let me write cleaner:

import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { ProtectedRoute } from './ProtectedRoute';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('ProtectedRoute', () => {
  it('renders children when authenticated', async () => {
    renderWithProviders(
      <ProtectedRoute>
        <div data-testid="protected-content">Secret</div>
      </ProtectedRoute>,
    );
    expect(await screen.findByTestId('protected-content')).toBeInTheDocument();
  });

  it('shows loading state initially then redirects when not authenticated', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );

    renderWithProviders(
      <ProtectedRoute>
        <div data-testid="protected-content">Secret</div>
      </ProtectedRoute>,
      { route: '/profile' },
    );

    expect(screen.getByText('Загрузка...')).toBeInTheDocument();

    await waitFor(() => {
      // After auth resolves, should redirect — we check the protected content is gone
      expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
    });
  });
});
  • Step 3: Create StockDetails.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StockDetails } from './StockDetails';
import { createMockShare } from '../test/factories';

describe('StockDetails', () => {
  it('renders stock details', () => {
    const stock = createMockShare();
    render(<StockDetails stock={stock} />);
    expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument();
    expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
  });

  it('displays positive change in green', () => {
    const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: 5, changePercent: 2 } });
    render(<StockDetails stock={stock} />);
    const changeEl = screen.getByText('+5.00 (2.00%)');
    expect(changeEl).toBeInTheDocument();
    expect(changeEl).toHaveStyle({ color: 'var(--color-positive)' });
  });

  it('displays negative change in red', () => {
    const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: -3, changePercent: -1 } });
    render(<StockDetails stock={stock} />);
    const changeEl = screen.getByText('-3.00 (-1.00%)');
    expect(changeEl).toBeInTheDocument();
    expect(changeEl).toHaveStyle({ color: 'var(--color-negative)' });
  });

  it('shows price formatted', () => {
    const stock = createMockShare();
    render(<StockDetails stock={stock} />);
    expect(screen.getByText('289,50')).toBeInTheDocument();
  });

  it('shows dash for null high', () => {
    const stock = createMockShare({ marketData: { ...createMockShare().marketData, high: null } });
    render(<StockDetails stock={stock} />);
    const rows = screen.getAllByText('—');
    expect(rows.length).toBeGreaterThanOrEqual(1);
  });

  it('shows capitalization in billions', () => {
    const stock = createMockShare();
    render(<StockDetails stock={stock} />);
    expect(screen.getByText('6250,00 млрд ₽')).toBeInTheDocument();
  });
});
  • Step 4: Create BondDetails.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BondDetails } from './BondDetails';
import { createMockBond } from '../test/factories';

describe('BondDetails', () => {
  it('renders bond details', () => {
    const bond = createMockBond();
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
    expect(screen.getByText('RU000A101XU7')).toBeInTheDocument();
  });

  it('shows price as percentage', () => {
    const bond = createMockBond();
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('98.50%')).toBeInTheDocument();
  });

  it('renders maturity date', () => {
    const bond = createMockBond();
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('2027-05-15')).toBeInTheDocument();
  });

  it('shows coupon with percentage', () => {
    const bond = createMockBond();
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
  });

  it('shows coupon without percentage when null', () => {
    const bond = createMockBond({ marketData: { ...createMockBond().marketData, couponPercent: null } });
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
  });

  it('shows dash for missing next coupon date', () => {
    const bond = createMockBond({ marketData: { ...createMockBond().marketData, nextCouponDate: null } });
    render(<BondDetails bond={bond} />);
    const dashes = screen.getAllByText('—');
    expect(dashes.length).toBeGreaterThanOrEqual(1);
  });

  it('shows dash for null yieldToMaturity', () => {
    const bond = createMockBond({ marketData: { ...createMockBond().marketData, yieldToMaturity: null } });
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('—')).toBeInTheDocument();
  });

  it('shows bond type', () => {
    const bond = createMockBond();
    render(<BondDetails bond={bond} />);
    expect(screen.getByText('ОФЗ')).toBeInTheDocument();
  });
});
  • Step 5: Create PriceChart.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { PriceChart } from './PriceChart';

describe('PriceChart', () => {
  it('renders chart container', () => {
    const { container } = render(<PriceChart data={[]} />);
    expect(container.querySelector('div')).toBeInTheDocument();
  });

  it('renders with candle data', () => {
    const data = [
      { open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' },
    ];
    const { container } = render(<PriceChart data={data} />);
    expect(container.querySelector('div')).toBeInTheDocument();
  });

  it('accepts custom height', () => {
    const { container } = render(<PriceChart data={[]} height={600} />);
    expect(container.querySelector('div')).toBeInTheDocument();
  });
});
  • Step 6: Create Layout.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { Layout } from './Layout';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('Layout', () => {
  it('renders logo and search bar', async () => {
    renderWithProviders(<Layout />);
    expect(screen.getByText('MoexVibe')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
  });

  it('shows login link when not authenticated', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    renderWithProviders(<Layout />);
    await waitFor(() => {
      expect(screen.getByText('Войти')).toBeInTheDocument();
    });
  });

  it('shows user name and logout when authenticated', async () => {
    renderWithProviders(<Layout />);
    await waitFor(() => {
      expect(screen.getByText('Test User')).toBeInTheDocument();
      expect(screen.getByText('Выйти')).toBeInTheDocument();
    });
  });

  it('shows email when user has no name', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => {
        return HttpResponse.json({
          data: {
            data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'mock' },
            meta: { fromCache: false, cachedAt: null },
          },
        });
      }),
    );
    renderWithProviders(<Layout />);
    await waitFor(() => {
      expect(screen.getByText('user@test.com')).toBeInTheDocument();
    });
  });
});
  • Step 7: Run component tests

Run: npx vitest run apps/frontend/src/components/ -w apps/frontend Expected: all passing


Task 7: Page tests

Files:

  • Create: apps/frontend/src/pages/HomePage.test.tsx

  • Create: apps/frontend/src/pages/StockPage.test.tsx

  • Create: apps/frontend/src/pages/BondPage.test.tsx

  • Create: apps/frontend/src/pages/LoginPage.test.tsx

  • Create: apps/frontend/src/pages/RegisterPage.test.tsx

  • Create: apps/frontend/src/pages/ProfilePage.test.tsx

  • Step 1: Create HomePage.test.tsx

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HomePage } from './HomePage';

describe('HomePage', () => {
  it('renders welcome title', () => {
    render(<HomePage />);
    expect(screen.getByText('MoexVibe')).toBeInTheDocument();
  });

  it('renders description', () => {
    render(<HomePage />);
    expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
  });

  it('renders hint text', () => {
    render(<HomePage />);
    expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
  });

  it('renders data delay notice', () => {
    render(<HomePage />);
    expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument();
  });
});
  • Step 2: Create StockPage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { StockPage } from './StockPage';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('StockPage', () => {
  it('shows loading state', () => {
    server.use(
      http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})),
    );
    renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
    expect(screen.getByText('Загрузка...')).toBeInTheDocument();
  });

  it('renders stock details after loading', async () => {
    renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
    expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument();
  });

  it('renders price chart', async () => {
    renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
    expect(await screen.findByText('График цены')).toBeInTheDocument();
  });

  it('renders dividends section', async () => {
    renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
    expect(await screen.findByText('Дивиденды')).toBeInTheDocument();
    expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument();
  });

  it('hides dividends section when empty', async () => {
    server.use(
      http.get(`${API}/securities/shares/:secid/dividends`, () => {
        return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
      }),
    );
    renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
    await waitFor(() => {
      expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument();
    });
  });

  it('shows error state for not found', async () => {
    server.use(
      http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })),
      http.get(`${API}/securities/shares/:secid/candles`, () => new HttpResponse(null, { status: 404 })),
      http.get(`${API}/securities/shares/:secid/dividends`, () => new HttpResponse(null, { status: 404 })),
    );
    renderWithProviders(<StockPage />, { route: '/stocks/NOTFOUND' });
    expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
  });
});
  • Step 3: Create BondPage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { BondPage } from './BondPage';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('BondPage', () => {
  it('shows loading state', () => {
    server.use(
      http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})),
    );
    renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
    expect(screen.getByText('Загрузка...')).toBeInTheDocument();
  });

  it('renders bond details after loading', async () => {
    renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
    expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument();
  });

  it('renders price chart', async () => {
    renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
    expect(await screen.findByText('График цены')).toBeInTheDocument();
  });

  it('shows error state for not found', async () => {
    server.use(
      http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })),
      http.get(`${API}/securities/bonds/:secid/candles`, () => new HttpResponse(null, { status: 404 })),
    );
    renderWithProviders(<BondPage />, { route: '/bonds/NOTFOUND' });
    expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
  });
});
  • Step 4: Create LoginPage.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { LoginPage } from './LoginPage';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('LoginPage', () => {
  it('renders login form', () => {
    // Mock refresh to fail so we don't auto-auth
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    renderWithProviders(<LoginPage />, { route: '/login' });
    expect(screen.getByText('Вход')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
  });

  it('redirects to home on successful login', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    const user = userEvent.setup();
    renderWithProviders(<LoginPage />, { route: '/login' });

    await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
    await user.type(screen.getByPlaceholderText('••••••••'), 'password');
    await user.click(screen.getByRole('button', { name: 'Войти' }));

    await waitFor(() => {
      // Login succeeded, should redirect
      expect(screen.queryByText('Вход')).not.toBeInTheDocument();
    });
  });

  it('shows error on failed login', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
      http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })),
    );
    const user = userEvent.setup();
    renderWithProviders(<LoginPage />, { route: '/login' });

    await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com');
    await user.type(screen.getByPlaceholderText('••••••••'), 'wrong');
    await user.click(screen.getByRole('button', { name: 'Войти' }));

    expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument();
  });

  it('shows loading state while submitting', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
      http.post(`${API}/auth/login`, () => new Promise(() => {})),
    );
    const user = userEvent.setup();
    renderWithProviders(<LoginPage />, { route: '/login' });

    await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
    await user.type(screen.getByPlaceholderText('••••••••'), 'password');
    await user.click(screen.getByRole('button', { name: 'Войти' }));

    expect(screen.getByText('Вход...')).toBeInTheDocument();
  });
});
  • Step 5: Create RegisterPage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { RegisterPage } from './RegisterPage';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('RegisterPage', () => {
  it('renders registration form', () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    renderWithProviders(<RegisterPage />, { route: '/register' });
    expect(screen.getByText('Регистрация')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument();
    expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
  });

  it('shows password mismatch error', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    const user = userEvent.setup();
    renderWithProviders(<RegisterPage />, { route: '/register' });

    await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
    await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password1');
    await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2');
    await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));

    expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument();
  });

  it('redirects on successful registration', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
    );
    const user = userEvent.setup();
    renderWithProviders(<RegisterPage />, { route: '/register' });

    await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
    await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password');
    await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password');
    await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));

    await waitFor(() => {
      expect(screen.queryByText('Регистрация')).not.toBeInTheDocument();
    });
  });

  it('shows error on failed registration', async () => {
    server.use(
      http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
      http.post(`${API}/auth/register`, () => new HttpResponse(null, { status: 409, statusText: 'Conflict' })),
    );
    const user = userEvent.setup();
    renderWithProviders(<RegisterPage />, { route: '/register' });

    await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com');
    await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password');
    await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password');
    await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));

    expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument();
  });
});
  • Step 6: Create ProfilePage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { ProfilePage } from './ProfilePage';
import { renderWithProviders } from '../test/test-utils';

const API = '/api/v1';

describe('ProfilePage', () => {
  it('renders user profile', async () => {
    renderWithProviders(
      <ProfilePage />,
      { route: '/profile' },
    );
    expect(await screen.findByText('Профиль')).toBeInTheDocument();
    expect(await screen.findByText('user@test.com')).toBeInTheDocument();
    expect(await screen.findByText('user')).toBeInTheDocument();
  });

  it('shows user name in input', async () => {
    renderWithProviders(<ProfilePage />, { route: '/profile' });
    const input = await screen.findByDisplayValue('Test User');
    expect(input).toBeInTheDocument();
  });

  it('updates profile on save', async () => {
    const user = userEvent.setup();
    renderWithProviders(<ProfilePage />, { route: '/profile' });

    const input = await screen.findByDisplayValue('Test User');
    await user.clear(input);
    await user.type(input, 'Updated User');
    await user.click(screen.getByRole('button', { name: 'Сохранить' }));

    expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument();
  });

  it('shows error message on failed update', async () => {
    server.use(
      http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 })),
    );
    const user = userEvent.setup();
    renderWithProviders(<ProfilePage />, { route: '/profile' });

    const input = await screen.findByDisplayValue('Test User');
    await user.clear(input);
    await user.type(input, 'New Name');
    await user.click(screen.getByRole('button', { name: 'Сохранить' }));

    expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument();
  });

  it('shows saving state', async () => {
    server.use(
      http.patch(`${API}/auth/me`, () => new Promise(() => {})),
    );
    const user = userEvent.setup();
    renderWithProviders(<ProfilePage />, { route: '/profile' });

    const input = await screen.findByDisplayValue('Test User');
    await user.clear(input);
    await user.type(input, 'New Name');
    await user.click(screen.getByRole('button', { name: 'Сохранить' }));

    expect(await screen.findByText('Сохранение...')).toBeInTheDocument();
  });
});
  • Step 7: Run all page tests

Run: npx vitest run apps/frontend/src/pages/ -w apps/frontend Expected: all passing


Task 8: Final verification

  • Step 1: Run all tests

Run: npm run test:frontend Expected: all tests passing

  • Step 2: Run format check

Run: npm run format:check Expected: no formatting issues