Files
fast-next-template/frontend/tests/features/issues/hooks/useIssues.test.tsx
Felipe Cardoso 5c35702caf test(frontend): comprehensive test coverage improvements and bug fixes
- 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>
2025-12-31 19:53:41 +01:00

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');
});
});