- Raise coverage thresholds to 90% statements/lines/functions, 85% branches - Add comprehensive tests for ProjectDashboard, ProjectWizard, and all wizard steps - Add tests for issue management: IssueDetailPanel, BulkActions, IssueFilters - Expand IssueTable tests with keyboard navigation, dropdown menu, edge cases - Add useIssues hook tests covering all mutations and optimistic updates - Expand eventStore tests with selector hooks and additional scenarios - Expand useProjectEvents tests with error recovery, ping events, edge cases - Add PriorityBadge, StatusBadge, SyncStatusIndicator fallback branch tests - Add constants.test.ts for comprehensive constant validation Bug fixes: - Fix false positive rollback test to properly verify onMutate context setup - Replace deprecated substr() with substring() in mock helpers - Fix type errors: ProjectComplexity, ClientMode enum values - Fix unused imports and variables across test files - Fix @ts-expect-error directives and method override signatures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1175 lines
32 KiB
TypeScript
1175 lines
32 KiB
TypeScript
/**
|
|
* Tests for useIssues hooks
|
|
*
|
|
* Comprehensive tests for issue management React Query hooks:
|
|
* - filterAndSortIssues function (unit tests)
|
|
* - useIssues hook (pagination, filtering)
|
|
* - useIssue hook (single issue fetch)
|
|
* - useUpdateIssue hook (mutations)
|
|
* - useUpdateIssueStatus hook (optimistic updates)
|
|
* - useBulkIssueAction hook (bulk operations)
|
|
* - useSyncIssue hook (external sync)
|
|
*/
|
|
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import {
|
|
useIssues,
|
|
useIssue,
|
|
useUpdateIssue,
|
|
useUpdateIssueStatus,
|
|
useBulkIssueAction,
|
|
useSyncIssue,
|
|
issueKeys,
|
|
} from '@/features/issues/hooks/useIssues';
|
|
import { mockIssues, mockIssueDetail } from '@/features/issues/mocks';
|
|
import type { IssueFilters, IssueSort } from '@/features/issues/types';
|
|
|
|
// Import the filterAndSortIssues function for direct testing
|
|
// Since it's not exported, we'll test it indirectly through the hook
|
|
// But we can also create a test-only export or test via behavior
|
|
|
|
/**
|
|
* Create a wrapper with a fresh QueryClient for each test
|
|
*/
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
// Reduce stale time for tests
|
|
staleTime: 0,
|
|
},
|
|
mutations: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
return ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create wrapper that exposes queryClient for cache manipulation
|
|
*/
|
|
function createWrapperWithClient() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false, staleTime: 0 },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
|
|
return { wrapper, queryClient };
|
|
}
|
|
|
|
describe('issueKeys', () => {
|
|
it('generates correct all key', () => {
|
|
expect(issueKeys.all).toEqual(['issues']);
|
|
});
|
|
|
|
it('generates correct lists key', () => {
|
|
expect(issueKeys.lists()).toEqual(['issues', 'list']);
|
|
});
|
|
|
|
it('generates correct list key with project and filters', () => {
|
|
const filters: IssueFilters = { status: 'open' };
|
|
const sort: IssueSort = { field: 'priority', direction: 'desc' };
|
|
expect(issueKeys.list('proj-1', filters, sort)).toEqual([
|
|
'issues',
|
|
'list',
|
|
'proj-1',
|
|
filters,
|
|
sort,
|
|
]);
|
|
});
|
|
|
|
it('generates correct details key', () => {
|
|
expect(issueKeys.details()).toEqual(['issues', 'detail']);
|
|
});
|
|
|
|
it('generates correct detail key with issueId', () => {
|
|
expect(issueKeys.detail('issue-123')).toEqual(['issues', 'detail', 'issue-123']);
|
|
});
|
|
});
|
|
|
|
describe('useIssues', () => {
|
|
// Speed up tests by reducing timeout delays
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('fetches paginated issues for a project', async () => {
|
|
const { result } = renderHook(() => useIssues('test-project'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(true);
|
|
|
|
// Fast-forward timer for mock delay
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toBeDefined();
|
|
expect(result.current.data?.data).toBeInstanceOf(Array);
|
|
expect(result.current.data?.pagination).toBeDefined();
|
|
expect(result.current.data?.pagination.page).toBe(1);
|
|
expect(result.current.data?.pagination.page_size).toBe(25);
|
|
});
|
|
|
|
it('returns correct pagination metadata', async () => {
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, undefined, 1, 10), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const pagination = result.current.data?.pagination;
|
|
expect(pagination).toBeDefined();
|
|
expect(pagination?.page).toBe(1);
|
|
expect(pagination?.page_size).toBe(10);
|
|
expect(pagination?.total).toBe(mockIssues.length);
|
|
expect(pagination?.total_pages).toBe(Math.ceil(mockIssues.length / 10));
|
|
});
|
|
|
|
describe('filtering', () => {
|
|
it('filters by search text in title', async () => {
|
|
const filters: IssueFilters = { search: 'authentication' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Should find the authentication issue
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.every((i) => i.title.toLowerCase().includes('authentication'))).toBe(true);
|
|
});
|
|
|
|
it('filters by search text in description', async () => {
|
|
const filters: IssueFilters = { search: 'reusable' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.some((i) => i.description.toLowerCase().includes('reusable'))).toBe(true);
|
|
});
|
|
|
|
it('filters by search text matching issue number', async () => {
|
|
const filters: IssueFilters = { search: '42' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.some((i) => i.number === 42)).toBe(true);
|
|
});
|
|
|
|
it('filters by status', async () => {
|
|
const filters: IssueFilters = { status: 'open' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.every((i) => i.status === 'open')).toBe(true);
|
|
});
|
|
|
|
it('filters by status "all" returns all issues', async () => {
|
|
const filters: IssueFilters = { status: 'all' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.pagination.total).toBe(mockIssues.length);
|
|
});
|
|
|
|
it('filters by priority', async () => {
|
|
const filters: IssueFilters = { priority: 'high' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.every((i) => i.priority === 'high')).toBe(true);
|
|
});
|
|
|
|
it('filters by priority "all" returns all issues', async () => {
|
|
const filters: IssueFilters = { priority: 'all' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.pagination.total).toBe(mockIssues.length);
|
|
});
|
|
|
|
it('filters by sprint', async () => {
|
|
const filters: IssueFilters = { sprint: 'Sprint 3' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.every((i) => i.sprint === 'Sprint 3')).toBe(true);
|
|
});
|
|
|
|
it('filters by "backlog" returns issues without sprint', async () => {
|
|
const filters: IssueFilters = { sprint: 'backlog' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.every((i) => i.sprint === null)).toBe(true);
|
|
});
|
|
|
|
it('filters by sprint "all" returns all issues', async () => {
|
|
const filters: IssueFilters = { sprint: 'all' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.pagination.total).toBe(mockIssues.length);
|
|
});
|
|
|
|
it('filters by assignee', async () => {
|
|
const filters: IssueFilters = { assignee: 'agent-be' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.every((i) => i.assignee?.id === 'agent-be')).toBe(true);
|
|
});
|
|
|
|
it('filters by "unassigned" returns issues without assignee', async () => {
|
|
const filters: IssueFilters = { assignee: 'unassigned' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.every((i) => i.assignee === null)).toBe(true);
|
|
});
|
|
|
|
it('filters by assignee "all" returns all issues', async () => {
|
|
const filters: IssueFilters = { assignee: 'all' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.pagination.total).toBe(mockIssues.length);
|
|
});
|
|
|
|
it('combines multiple filters', async () => {
|
|
const filters: IssueFilters = {
|
|
status: 'in_progress',
|
|
priority: 'high',
|
|
sprint: 'Sprint 3',
|
|
};
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
issues.forEach((issue) => {
|
|
expect(issue.status).toBe('in_progress');
|
|
expect(issue.priority).toBe('high');
|
|
expect(issue.sprint).toBe('Sprint 3');
|
|
});
|
|
});
|
|
|
|
it('returns empty array when no issues match filters', async () => {
|
|
const filters: IssueFilters = { search: 'xyznonexistent123' };
|
|
const { result } = renderHook(() => useIssues('test-project', filters), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.data).toEqual([]);
|
|
expect(result.current.data?.pagination.total).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('sorting', () => {
|
|
it('sorts by number ascending', async () => {
|
|
const sort: IssueSort = { field: 'number', direction: 'asc' };
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
for (let i = 1; i < issues.length; i++) {
|
|
expect(issues[i].number).toBeGreaterThanOrEqual(issues[i - 1].number);
|
|
}
|
|
});
|
|
|
|
it('sorts by number descending', async () => {
|
|
const sort: IssueSort = { field: 'number', direction: 'desc' };
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
for (let i = 1; i < issues.length; i++) {
|
|
expect(issues[i].number).toBeLessThanOrEqual(issues[i - 1].number);
|
|
}
|
|
});
|
|
|
|
it('sorts by priority ascending', async () => {
|
|
const priorityOrder: Record<string, number> = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
const sort: IssueSort = { field: 'priority', direction: 'asc' };
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
for (let i = 1; i < issues.length; i++) {
|
|
expect(priorityOrder[issues[i].priority]).toBeGreaterThanOrEqual(
|
|
priorityOrder[issues[i - 1].priority]
|
|
);
|
|
}
|
|
});
|
|
|
|
it('sorts by priority descending', async () => {
|
|
const priorityOrder: Record<string, number> = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
const sort: IssueSort = { field: 'priority', direction: 'desc' };
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
for (let i = 1; i < issues.length; i++) {
|
|
expect(priorityOrder[issues[i].priority]).toBeLessThanOrEqual(
|
|
priorityOrder[issues[i - 1].priority]
|
|
);
|
|
}
|
|
});
|
|
|
|
it('sorts by updated_at ascending', async () => {
|
|
const sort: IssueSort = { field: 'updated_at', direction: 'asc' };
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
for (let i = 1; i < issues.length; i++) {
|
|
expect(new Date(issues[i].updated_at).getTime()).toBeGreaterThanOrEqual(
|
|
new Date(issues[i - 1].updated_at).getTime()
|
|
);
|
|
}
|
|
});
|
|
|
|
it('sorts by created_at descending', async () => {
|
|
const sort: IssueSort = { field: 'created_at', direction: 'desc' };
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issues = result.current.data?.data || [];
|
|
for (let i = 1; i < issues.length; i++) {
|
|
expect(new Date(issues[i].created_at).getTime()).toBeLessThanOrEqual(
|
|
new Date(issues[i - 1].created_at).getTime()
|
|
);
|
|
}
|
|
});
|
|
|
|
it('handles unknown sort field gracefully', async () => {
|
|
// Cast to unknown first to bypass type checking for edge case test
|
|
const sort = { field: 'unknown_field', direction: 'asc' } as unknown as IssueSort;
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, sort), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Should still return data without errors
|
|
expect(result.current.data?.data).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('pagination', () => {
|
|
it('paginates correctly for page 1', async () => {
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, undefined, 1, 3), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.data.length).toBeLessThanOrEqual(3);
|
|
expect(result.current.data?.pagination.page).toBe(1);
|
|
});
|
|
|
|
it('paginates correctly for page 2', async () => {
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, undefined, 2, 3), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.pagination.page).toBe(2);
|
|
expect(result.current.data?.pagination.has_prev).toBe(true);
|
|
});
|
|
|
|
it('calculates has_next correctly', async () => {
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, undefined, 1, 3), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// With 8 mock issues and page size 3, page 1 should have next
|
|
if (mockIssues.length > 3) {
|
|
expect(result.current.data?.pagination.has_next).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('calculates has_prev correctly', async () => {
|
|
const { result } = renderHook(() => useIssues('test-project', undefined, undefined, 1, 10), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.pagination.has_prev).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useIssue', () => {
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('fetches a single issue by ID', async () => {
|
|
const { result } = renderHook(() => useIssue('ISS-001'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(true);
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(250);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toBeDefined();
|
|
expect(result.current.data?.id).toBe('ISS-001');
|
|
});
|
|
|
|
it('returns mock issue detail with correct structure', async () => {
|
|
const { result } = renderHook(() => useIssue('test-issue'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(250);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const issue = result.current.data;
|
|
expect(issue).toMatchObject({
|
|
id: 'test-issue',
|
|
title: expect.any(String),
|
|
description: expect.any(String),
|
|
status: expect.any(String),
|
|
priority: expect.any(String),
|
|
labels: expect.any(Array),
|
|
activity: expect.any(Array),
|
|
});
|
|
});
|
|
|
|
it('is disabled when issueId is empty', async () => {
|
|
const { result } = renderHook(() => useIssue(''), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
// Query should not be loading because it's disabled
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.isFetching).toBe(false);
|
|
expect(result.current.data).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('useUpdateIssue', () => {
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('updates an issue successfully', async () => {
|
|
const { result } = renderHook(() => useUpdateIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
data: { title: 'Updated Title' },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.title).toBe('Updated Title');
|
|
expect(result.current.data?.id).toBe('ISS-001');
|
|
});
|
|
|
|
it('updates issue status', async () => {
|
|
const { result } = renderHook(() => useUpdateIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
data: { status: 'closed' },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.status).toBe('closed');
|
|
});
|
|
|
|
it('updates issue priority', async () => {
|
|
const { result } = renderHook(() => useUpdateIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
data: { priority: 'critical' },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.priority).toBe('critical');
|
|
});
|
|
|
|
it('clears sprint when set to null', async () => {
|
|
const { result } = renderHook(() => useUpdateIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
data: { sprint: null },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.sprint).toBeNull();
|
|
});
|
|
|
|
it('clears due_date when set to null', async () => {
|
|
const { result } = renderHook(() => useUpdateIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
data: { due_date: null },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.due_date).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('useUpdateIssueStatus', () => {
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('performs optimistic update on status change', async () => {
|
|
const { wrapper, queryClient } = createWrapperWithClient();
|
|
|
|
// Pre-populate the cache with issue data
|
|
queryClient.setQueryData(issueKeys.detail('ISS-001'), {
|
|
...mockIssueDetail,
|
|
id: 'ISS-001',
|
|
status: 'open',
|
|
});
|
|
|
|
const { result } = renderHook(() => useUpdateIssueStatus(), { wrapper });
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
status: 'in_progress',
|
|
});
|
|
});
|
|
|
|
// Check optimistic update was applied
|
|
const cachedIssue = queryClient.getQueryData(issueKeys.detail('ISS-001')) as any;
|
|
expect(cachedIssue?.status).toBe('in_progress');
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('sets up rollback context with previous value in onMutate', async () => {
|
|
const { wrapper, queryClient } = createWrapperWithClient();
|
|
|
|
// Pre-populate the cache with original state
|
|
const originalIssue = {
|
|
...mockIssueDetail,
|
|
id: 'ISS-001',
|
|
status: 'open',
|
|
};
|
|
queryClient.setQueryData(issueKeys.detail('ISS-001'), originalIssue);
|
|
|
|
const { result } = renderHook(() => useUpdateIssueStatus(), { wrapper });
|
|
|
|
// Start the mutation and wait for onMutate to process
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
status: 'closed',
|
|
});
|
|
});
|
|
|
|
// After onMutate completes, optimistic update should be applied
|
|
let cachedIssue = queryClient.getQueryData(issueKeys.detail('ISS-001')) as any;
|
|
expect(cachedIssue?.status).toBe('closed'); // Optimistic update applied
|
|
|
|
// Get the current mutation from cache to verify rollback context was set up
|
|
const mutationCache = queryClient.getMutationCache();
|
|
const activeMutation = mutationCache.getAll().find((m) => m.state.status === 'pending');
|
|
|
|
// Verify onMutate set up the rollback context with previous value
|
|
const mutationContext = activeMutation?.state.context as { previousIssue?: any } | undefined;
|
|
expect(mutationContext?.previousIssue).toBeDefined();
|
|
expect(mutationContext?.previousIssue.status).toBe('open');
|
|
|
|
// Simulate what onError would do by restoring from context
|
|
if (mutationContext?.previousIssue) {
|
|
queryClient.setQueryData(issueKeys.detail('ISS-001'), mutationContext.previousIssue);
|
|
}
|
|
|
|
// Verify rollback mechanism works
|
|
cachedIssue = queryClient.getQueryData(issueKeys.detail('ISS-001')) as any;
|
|
expect(cachedIssue?.status).toBe('open'); // Rolled back to original
|
|
|
|
// Clean up - let mutation complete
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('handles undefined status gracefully', async () => {
|
|
const { wrapper, queryClient } = createWrapperWithClient();
|
|
|
|
queryClient.setQueryData(issueKeys.detail('ISS-001'), {
|
|
...mockIssueDetail,
|
|
id: 'ISS-001',
|
|
status: 'open',
|
|
});
|
|
|
|
const { result } = renderHook(() => useUpdateIssueStatus(), { wrapper });
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'ISS-001',
|
|
status: undefined,
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('skips optimistic update when no previous data exists', async () => {
|
|
const { wrapper } = createWrapperWithClient();
|
|
|
|
// Don't pre-populate cache - no previous issue data
|
|
|
|
const { result } = renderHook(() => useUpdateIssueStatus(), { wrapper });
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
issueId: 'nonexistent-issue',
|
|
status: 'in_progress',
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(350);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useBulkIssueAction', () => {
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('performs bulk action successfully', async () => {
|
|
const { result } = renderHook(() => useBulkIssueAction(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
action: 'change_status',
|
|
issue_ids: ['ISS-001', 'ISS-002', 'ISS-003'],
|
|
payload: { status: 'closed' },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(550);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.affected_count).toBe(3);
|
|
});
|
|
|
|
it('handles empty issue_ids array', async () => {
|
|
const { result } = renderHook(() => useBulkIssueAction(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
action: 'assign',
|
|
issue_ids: [],
|
|
payload: { assignee_id: 'agent-1' },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(550);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.affected_count).toBe(0);
|
|
});
|
|
|
|
it('handles add_labels action', async () => {
|
|
const { result } = renderHook(() => useBulkIssueAction(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
action: 'add_labels',
|
|
issue_ids: ['ISS-001', 'ISS-002'],
|
|
payload: { labels: ['urgent', 'bug'] },
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(550);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.affected_count).toBe(2);
|
|
});
|
|
|
|
it('handles delete action', async () => {
|
|
const { result } = renderHook(() => useBulkIssueAction(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({
|
|
action: 'delete',
|
|
issue_ids: ['ISS-005'],
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(550);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.affected_count).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('useSyncIssue', () => {
|
|
beforeAll(() => {
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('syncs an issue with external tracker', async () => {
|
|
const { result } = renderHook(() => useSyncIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({ issueId: 'ISS-001' });
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(1050);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.sync_status).toBe('synced');
|
|
});
|
|
|
|
it('returns the matching mock issue when found', async () => {
|
|
const { result } = renderHook(() => useSyncIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({ issueId: 'ISS-001' });
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(1050);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Should return the issue with id ISS-001
|
|
expect(result.current.data?.id).toBe('ISS-001');
|
|
});
|
|
|
|
it('returns first mock issue when issue not found', async () => {
|
|
const { result } = renderHook(() => useSyncIssue(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.mutate({ issueId: 'nonexistent-issue' });
|
|
});
|
|
|
|
await act(async () => {
|
|
jest.advanceTimersByTime(1050);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Should return first mock issue as fallback
|
|
expect(result.current.data?.sync_status).toBe('synced');
|
|
});
|
|
});
|