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
71 changes: 71 additions & 0 deletions apps/web/src/__tests__/components/ActivityTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const { mockGqlRequest } = vi.hoisted(() => ({
mockGqlRequest: vi.fn(),
}));

vi.mock('#/graphql/client', () => ({
gqlClient: { request: mockGqlRequest },
}));

import { ActivityTab } from '#/routes/_authenticated/applications/$applicationId/-components/ActivityTab';

const makeClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });

function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={makeClient()}>{children}</QueryClientProvider>;
}

describe('ActivityTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('shows an empty state when there is no activity', async () => {
mockGqlRequest.mockResolvedValue({ activityLogs: [] });
render(<ActivityTab applicationId="app-1" />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('No activity yet.')).toBeInTheDocument();
});
});

it('renders a status_changed event with its from/to detail', async () => {
mockGqlRequest.mockResolvedValue({
activityLogs: [
{
id: 'log-1',
eventType: 'status_changed',
payload: JSON.stringify({ from: 'applied', to: 'interviewing' }),
createdAt: '2024-01-01T00:00:00.000Z',
},
],
});
render(<ActivityTab applicationId="app-1" />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('Status changed')).toBeInTheDocument();
});
expect(screen.getByText(/applied → interviewing/)).toBeInTheDocument();
});

it('falls back to the raw eventType label for an unrecognized event', async () => {
mockGqlRequest.mockResolvedValue({
activityLogs: [
{
id: 'log-2',
eventType: 'something_new',
payload: '{}',
createdAt: '2024-01-01T00:00:00.000Z',
},
],
});
render(<ActivityTab applicationId="app-1" />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('something_new')).toBeInTheDocument();
});
});
});
91 changes: 91 additions & 0 deletions apps/web/src/__tests__/components/AnalyticsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const { mockGqlRequest } = vi.hoisted(() => ({
mockGqlRequest: vi.fn(),
}));

vi.mock('#/graphql/client', () => ({
gqlClient: { request: mockGqlRequest },
}));

import { AnalyticsPage } from '#/routes/_authenticated/-analytics-page';

const makeClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });

function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={makeClient()}>{children}</QueryClientProvider>;
}

const app = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: 'app-1',
company: 'Acme',
role: 'Engineer',
status: 'applied',
appliedAt: '2024-01-01T00:00:00.000Z',
createdAt: '2024-01-01T00:00:00.000Z',
...overrides,
});

describe('AnalyticsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('shows an error state with a retry action when the query fails', async () => {
mockGqlRequest.mockRejectedValue(new Error('Network error'));
render(<AnalyticsPage />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByRole('button', { name: /retry|try again/i })).toBeInTheDocument();
});
});

it('shows "No data yet." for both charts when there are no applications', async () => {
mockGqlRequest.mockResolvedValue({ applications: [] });
render(<AnalyticsPage />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('Analytics')).toBeInTheDocument();
});
expect(screen.getAllByText('No data yet.')).toHaveLength(2);
expect(screen.getByText('Total').nextSibling).toHaveTextContent('0');
});

it('computes total, active, response rate, and offer rate from applications', async () => {
mockGqlRequest.mockResolvedValue({
applications: [
app({ id: '1', status: 'draft' }),
app({ id: '2', status: 'applied' }),
app({ id: '3', status: 'interviewing' }),
app({ id: '4', status: 'offered' }),
app({ id: '5', status: 'accepted' }),
app({ id: '6', status: 'rejected' }),
],
});
render(<AnalyticsPage />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('Analytics')).toBeInTheDocument();
});

// Total: 6, Active (applied/interviewing/offered): 3
expect(screen.getByText('Total').parentElement).toHaveTextContent('6');
expect(screen.getByText('Active').parentElement).toHaveTextContent('3');
// appliedOrBeyond excludes draft (5), gotResponse excludes applied/draft (interviewing, offered, accepted, rejected = 4) => 80%
expect(screen.getByText('Response rate').parentElement).toHaveTextContent('80%');
// offer rate = accepted / total = 1/6 -> 17%
expect(screen.getByText('Offer rate').parentElement).toHaveTextContent('17%');
});

it('renders chart section headings once data has loaded', async () => {
mockGqlRequest.mockResolvedValue({ applications: [app()] });
render(<AnalyticsPage />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('Applications per week')).toBeInTheDocument();
});
expect(screen.getByText('Stage funnel')).toBeInTheDocument();
});
});
121 changes: 121 additions & 0 deletions apps/web/src/__tests__/components/BoardPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const { mockGqlRequest } = vi.hoisted(() => ({
mockGqlRequest: vi.fn(),
}));

vi.mock('@tanstack/react-router', () => ({
Link: ({
children,
to,
...rest
}: {
children: React.ReactNode;
to: string;
[key: string]: unknown;
}) => (
<a href={to} {...rest}>
{children}
</a>
),
}));

vi.mock('#/graphql/client', () => ({
gqlClient: { request: mockGqlRequest },
}));

import { KanbanBoard } from '#/routes/_authenticated/applications/-board-page';

const makeClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });

function Wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={makeClient()}>{children}</QueryClientProvider>;
}

const app = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: 'app-1',
company: 'Acme',
role: 'Engineer',
status: 'applied',
location: 'Remote',
appliedAt: '2024-01-01T00:00:00.000Z',
starred: false,
createdAt: '2024-01-01T00:00:00.000Z',
likelyGhosted: false,
...overrides,
});

describe('KanbanBoard', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('shows an error state with a retry action when the query fails', async () => {
mockGqlRequest.mockRejectedValue(new Error('Network error'));
render(<KanbanBoard />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
});
});

it('renders all status columns with zero counts when there are no applications', async () => {
mockGqlRequest.mockResolvedValue({ applications: [] });
render(<KanbanBoard />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('draft')).toBeInTheDocument();
});
for (const status of [
'draft',
'applied',
'interviewing',
'offered',
'accepted',
'rejected',
'withdrawn',
]) {
expect(screen.getByText(status)).toBeInTheDocument();
}
});

it('places each application card into its matching status column', async () => {
mockGqlRequest.mockResolvedValue({
applications: [
app({ id: '1', company: 'Acme', status: 'applied' }),
app({ id: '2', company: 'Globex', status: 'interviewing' }),
],
});
render(<KanbanBoard />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('Acme')).toBeInTheDocument();
});
expect(screen.getByText('Globex')).toBeInTheDocument();
});

it('shows the "Likely ghosted" badge for a flagged application', async () => {
mockGqlRequest.mockResolvedValue({
applications: [app({ id: '1', likelyGhosted: true })],
});
render(<KanbanBoard />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByText('Likely ghosted')).toBeInTheDocument();
});
});

it('links "New" to the new application route', async () => {
mockGqlRequest.mockResolvedValue({ applications: [] });
render(<KanbanBoard />, { wrapper: Wrapper });

await waitFor(() => {
expect(screen.getByRole('link', { name: /new application/i })).toHaveAttribute(
'href',
'/applications/new',
);
});
});
});
Loading
Loading