Skip to content
Open
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
326 changes: 325 additions & 1 deletion src/hooks/useGitHubDataFetching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,332 @@ describe('useGitHubDataFetching', () => {
currentUsername: '',
handleSearch: expect.any(Function),
});

// The implementation should stop at page 3 even if more pages are available
// This is handled internally by the maxPages constant in fetchAllEvents
});

describe('search items pagination', () => {
const createMockSearchResponse = (items: object[], totalCount: number) => ({
total_count: totalCount,
incomplete_results: false,
items: items,
});

const createMockItem = (id: number) => ({
id,
number: id,
title: `Issue ${id}`,
html_url: `https://github.com/test/repo/issues/${id}`,
state: 'open',
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
user: { login: 'testuser', id: 1 },
});

const createMockEventsResponse = () => [];

beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
});

it('should fetch multiple pages when total_count exceeds per_page', async () => {
// Create 150 issues total - should require 2 pages
const page1Items = Array.from({ length: 100 }, (_, i) => createMockItem(i + 1));
const page2Items = Array.from({ length: 50 }, (_, i) => createMockItem(i + 101));

let issueCallCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
// Check if this is an issue query
if (url.includes('is%3Aissue')) {
issueCallCount++;
if (issueCallCount === 1) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse(page1Items, 150)),
});
} else {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse(page2Items, 150)),
});
}
}
// PR query - return empty
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([], 0)),
});
}
// Events API - return empty
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

// Start the search
const searchPromise = result.current.handleSearch();

// Advance timers to allow async operations
await vi.runAllTimersAsync();
await searchPromise;

// Verify search API was called for issues (2 pages)
const issueCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: string[]) => call[0].includes('/search/issues') && call[0].includes('is%3Aissue')
);
expect(issueCalls.length).toBe(2);

// Verify page parameter increments for issues
expect(issueCalls[0][0]).toContain('page=1');
expect(issueCalls[1][0]).toContain('page=2');

// Verify storeSearchItems was called with all 150 items
expect(mockStoreSearchItems).toHaveBeenCalled();
const storedItems = mockStoreSearchItems.mock.calls[0][1];
expect(storedItems.length).toBe(150);
});

it('should stop fetching when all items are retrieved (items.length < perPage)', async () => {
// Only 50 issues total - single page per query
const items = Array.from({ length: 50 }, (_, i) => createMockItem(i + 1));

(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
if (url.includes('is%3Aissue')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse(items, 50)),
});
}
// PR query - return empty
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([], 0)),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

const searchPromise = result.current.handleSearch();
await vi.runAllTimersAsync();
await searchPromise;

// Should call search API 2 times (once for issues, once for PRs)
const searchCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: string[]) => call[0].includes('/search/issues')
);
expect(searchCalls.length).toBe(2);
});

it('should stop at max pages limit (10 pages per query)', async () => {
// Simulate a response that always returns 100 unique items with huge total_count
let callCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
callCount++;
// Each call returns unique items to avoid deduplication stopping pagination
const pageItems = Array.from({ length: 100 }, (_, i) => createMockItem(callCount * 1000 + i + 1));
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse(pageItems, 5000)), // More than 1000
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

const searchPromise = result.current.handleSearch();
await vi.runAllTimersAsync();
await searchPromise;

// Should stop at 10 pages max per query (2 queries × 10 pages = 20)
const searchCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: string[]) => call[0].includes('/search/issues')
);
expect(searchCalls.length).toBe(20);
});

it('should preserve assignee data and add original property', async () => {
const itemWithAssignee = {
id: 1,
number: 1,
title: 'Test Issue',
html_url: 'https://github.com/test/repo/issues/1',
state: 'open',
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
user: { login: 'testuser', id: 1 },
assignee: { login: 'assignee1', id: 2 },
assignees: [{ login: 'assignee1', id: 2 }, { login: 'assignee2', id: 3 }],
};

(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
if (url.includes('is%3Aissue')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([itemWithAssignee], 1)),
});
}
// PR query - return empty
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([], 0)),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

const searchPromise = result.current.handleSearch();
await vi.runAllTimersAsync();
await searchPromise;

expect(mockStoreSearchItems).toHaveBeenCalled();
const storedItems = mockStoreSearchItems.mock.calls[0][1];
expect(storedItems.length).toBe(1);
expect(storedItems[0].assignee).toEqual({ login: 'assignee1', id: 2 });
expect(storedItems[0].assignees).toEqual([{ login: 'assignee1', id: 2 }, { login: 'assignee2', id: 3 }]);
expect(storedItems[0].original).toBeDefined();
});

it('should deduplicate items returned by multiple queries', async () => {
// Same item returned by both issue and PR queries (edge case)
const sharedItem = createMockItem(1);

(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
// Both issue and PR queries return the same item
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([sharedItem], 1)),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

const searchPromise = result.current.handleSearch();
await vi.runAllTimersAsync();
await searchPromise;

expect(mockStoreSearchItems).toHaveBeenCalled();
const storedItems = mockStoreSearchItems.mock.calls[0][1];
// Should only have 1 item even though it was returned by 2 queries
expect(storedItems.length).toBe(1);
});

it('should handle 422 pagination limit error gracefully', async () => {
// First page succeeds, second page returns 422
const page1Items = Array.from({ length: 100 }, (_, i) => createMockItem(i + 1));

let issueCallCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
if (url.includes('is%3Aissue')) {
issueCallCount++;
if (issueCallCount === 1) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse(page1Items, 200)),
});
} else {
// Return 422 pagination limit error
return Promise.resolve({
ok: false,
status: 422,
json: () => Promise.resolve({ message: 'Only the first 1000 search results are available. pagination is limited.' }),
});
}
}
// PR query - return empty
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([], 0)),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

const searchPromise = result.current.handleSearch();
await vi.runAllTimersAsync();
await searchPromise;

// Should store the 100 items from page 1
expect(mockStoreSearchItems).toHaveBeenCalled();
const storedItems = mockStoreSearchItems.mock.calls[0][1];
expect(storedItems.length).toBe(100);
});

it('should return partial results when error occurs mid-pagination', async () => {
// First page succeeds, second page throws network error
const page1Items = Array.from({ length: 100 }, (_, i) => createMockItem(i + 1));

let issueCallCount = 0;
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
if (url.includes('/search/issues')) {
if (url.includes('is%3Aissue')) {
issueCallCount++;
if (issueCallCount === 1) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse(page1Items, 200)),
});
} else {
// Simulate network error
return Promise.reject(new Error('Network error'));
}
}
// PR query - return empty
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockSearchResponse([], 0)),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(createMockEventsResponse()),
});
});

const { result } = renderHook(() => useGitHubDataFetching(defaultProps));

const searchPromise = result.current.handleSearch();
await vi.runAllTimersAsync();
await searchPromise;

// Should store the 100 items from page 1 (partial results)
expect(mockStoreSearchItems).toHaveBeenCalled();
const storedItems = mockStoreSearchItems.mock.calls[0][1];
expect(storedItems.length).toBe(100);
});
});
});
Loading