Skip to content
49 changes: 37 additions & 12 deletions pages/kusa/[username].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ import Head from 'next/head';
import dayjs from 'dayjs';
import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
import { useEffect } from 'react';
import { useInfiniteQuery, useQueryClient, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useInfiniteQuery, useQuery, useQueryClient, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Contributions from '@/components/kusa/contributions/contributions';
import { calculateCurrentStreak, calculateCoverage } from '@/lib/contribution-stats';
import {
fetchSearchPullRequests,
fetchSearchCommits,
fetchSearchIssues,
} from '@/components/kusa/contributions/search-api';
import { SearchData } from '@/components/kusa/contributions/types';

const queryClient = new QueryClient();

Expand Down Expand Up @@ -103,22 +109,37 @@ const Detail = ({ username }: { username: string }) => {
const queryClient = useQueryClient();
const queryFn = createQueryFn(username);

const { status, data, error, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery(
['events'],
queryFn,
{
getNextPageParam: (lastPage, allPages) => (allPages.length >= 3 ? undefined : allPages.length + 1),
},
);
// Events API (Star/Fork/Create/Delete/Comments用)
const {
status: eventsStatus,
data: eventsData,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery(['events'], queryFn, {
getNextPageParam: (lastPage, allPages) => (allPages.length >= 3 ? undefined : allPages.length + 1),
});

// Search API (PR/Commit/Issue用)
const { status: searchStatus, data: searchData } = useQuery<SearchData>(['search', username], async () => {
const [pullRequests, commits, issues] = await Promise.all([
fetchSearchPullRequests(username),
fetchSearchCommits(username),
fetchSearchIssues(username),
]);
return { pullRequests, commits, issues };
});

useEffect(() => {
fetchNextPage();
}, [fetchNextPage]);

return status === 'loading' ? (
const isLoading = eventsStatus === 'loading' || searchStatus === 'loading';
const isError = eventsStatus === 'error' || searchStatus === 'error';

return isLoading ? (
<div>Loading...</div>
) : status === 'error' ? (
<div>Eerror</div>
) : isError ? (
<div>Error</div>
) : (
<>
<div className="flex justify-start sm:justify-end">
Expand All @@ -133,7 +154,11 @@ const Detail = ({ username }: { username: string }) => {
</button>
</div>

<Contributions result={data.pages.flat()} username={username}></Contributions>
<Contributions
events={eventsData?.pages.flat() ?? []}
searchData={searchData ?? { pullRequests: [], commits: [], issues: [] }}
username={username}
/>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,77 +1,117 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ContributionsByRepo from '../contributions-by-repo';
import {
createPushEvent,
createPullRequestEvent,
createIssuesEvent,
createCreateEvent,
createWatchEvent,
createForkEvent,
createIssueCommentEvent,
} from './fixtures';
import { createCreateEvent, createWatchEvent, createForkEvent, createIssueCommentEvent } from './fixtures';
import { SearchData, SearchPullRequest, SearchCommit, SearchIssue } from '../types';

const emptySearchData: SearchData = { pullRequests: [], commits: [], issues: [] };

const makeSearchCommit = (overrides?: Partial<SearchCommit>): SearchCommit => ({
sha: 'abc123',
html_url: 'https://github.com/user/repo/commit/abc123',
commit: {
message: 'feat: add new feature',
author: { date: '2024-01-15T10:00:00Z', name: 'Test User', email: 'test@example.com' },
},
repository: { full_name: 'user/repo', html_url: 'https://github.com/user/repo' },
...overrides,
});

const makeSearchPR = (overrides?: Partial<SearchPullRequest>): SearchPullRequest => ({
title: 'Test PR',
number: 1,
state: 'open',
html_url: 'https://github.com/user/repo/pull/1',
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
repository_url: 'https://api.github.com/repos/user/repo',
pull_request: {
url: 'https://api.github.com/repos/user/repo/pulls/1',
html_url: 'https://github.com/user/repo/pull/1',
diff_url: 'https://github.com/user/repo/pull/1.diff',
patch_url: 'https://github.com/user/repo/pull/1.patch',
merged_at: null,
},
...overrides,
});

const makeSearchIssue = (overrides?: Partial<SearchIssue>): SearchIssue => ({
title: 'Test Issue',
number: 1,
state: 'open',
html_url: 'https://github.com/user/repo/issues/1',
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
repository_url: 'https://api.github.com/repos/user/repo',
...overrides,
});

describe('ContributionsByRepo', () => {
test('空配列でクラッシュしない', () => {
expect(() => render(<ContributionsByRepo result={[]} />)).not.toThrow();
test('空データでクラッシュしない', () => {
expect(() => render(<ContributionsByRepo events={[]} searchData={emptySearchData} />)).not.toThrow();
});

test('PushEventを含むデータでコミット情報が表示される', () => {
render(<ContributionsByRepo result={[createPushEvent()]} />);
test('Search APIのコミットデータでコミット情報が表示される', () => {
const searchData: SearchData = {
...emptySearchData,
commits: [makeSearchCommit()],
};
render(<ContributionsByRepo events={[]} searchData={searchData} />);
expect(screen.getByText(/Created 1 Commmits in 1 repositories/)).toBeInTheDocument();
});

test('PullRequestEventを含むデータでPR情報が表示される', () => {
render(<ContributionsByRepo result={[createPullRequestEvent()]} />);
test('Search APIのPRデータでPR情報が表示される', () => {
const searchData: SearchData = {
...emptySearchData,
pullRequests: [makeSearchPR()],
};
render(<ContributionsByRepo events={[]} searchData={searchData} />);
expect(screen.getByText(/Opened 1 PullRequests in 1 repositories/)).toBeInTheDocument();
});

test('IssuesEventを含むデータでIssue情報が表示される', () => {
render(<ContributionsByRepo result={[createIssuesEvent()]} />);
test('Search APIのIssueデータでIssue情報が表示される', () => {
const searchData: SearchData = {
...emptySearchData,
issues: [makeSearchIssue()],
};
render(<ContributionsByRepo events={[]} searchData={searchData} />);
expect(screen.getByText(/Opened 1 Issues in 1 repositories/)).toBeInTheDocument();
});

test('CreateEvent(repository)でリポジトリ情報が表示される', () => {
render(<ContributionsByRepo result={[createCreateEvent('repository')]} />);
test('Events APIのCreateEvent(repository)でリポジトリ情報が表示される', () => {
render(<ContributionsByRepo events={[createCreateEvent('repository')]} searchData={emptySearchData} />);
expect(screen.getByText(/Created 1 repositories/i)).toBeInTheDocument();
});

test('WatchEventでStar情報が表示される', () => {
render(<ContributionsByRepo result={[createWatchEvent()]} />);
test('Events APIのWatchEventでStar情報が表示される', () => {
render(<ContributionsByRepo events={[createWatchEvent()]} searchData={emptySearchData} />);
expect(screen.getByText(/Stared 1 repositories/i)).toBeInTheDocument();
});

test('複数イベントタイプが混在するデータで正常に表示される', () => {
const events = [createPushEvent(), createPullRequestEvent(), createIssuesEvent(), createWatchEvent()];
test('Search APIとEvents APIの両方のデータで正常に表示される', () => {
const searchData: SearchData = {
pullRequests: [makeSearchPR()],
commits: [makeSearchCommit()],
issues: [makeSearchIssue()],
};
const events = [createWatchEvent(), createCreateEvent('repository')];

expect(() => render(<ContributionsByRepo result={events} />)).not.toThrow();
expect(() => render(<ContributionsByRepo events={events} searchData={searchData} />)).not.toThrow();
});

test('PushEventのcommitsがundefinedでもクラッシュしない', () => {
const pushEventWithoutCommits = createPushEvent();
pushEventWithoutCommits.payload.commits = undefined;

expect(() => render(<ContributionsByRepo result={[pushEventWithoutCommits]} />)).not.toThrow();
});

test('created_atがundefinedのPushEventでもクラッシュしない', () => {
const pushEventWithoutCreatedAt = createPushEvent({ created_at: undefined as any });

expect(() => render(<ContributionsByRepo result={[pushEventWithoutCreatedAt]} />)).not.toThrow();
});

test('BotユーザーのコミットはPushEventから除外される', () => {
const botPushEvent = createPushEvent();
botPushEvent.payload.commits = [
{
sha: 'bot123',
message: 'bot commit',
url: 'https://api.github.com/repos/user/repo/commits/bot123',
author: { name: 'bot', email: '12345+bot@users.noreply.github.com' },
},
];

render(<ContributionsByRepo result={[botPushEvent]} />);
expect(screen.getByText(/Created 0 Commmits in 1 repositories/)).toBeInTheDocument();
test('マージ済みPRのstatsが正しく計算される', () => {
const searchData: SearchData = {
...emptySearchData,
pullRequests: [
makeSearchPR({
number: 1,
state: 'closed',
pull_request: { ...makeSearchPR().pull_request, merged_at: '2024-01-15T10:00:00Z' },
}),
makeSearchPR({ number: 2, state: 'open' }),
],
};
render(<ContributionsByRepo events={[]} searchData={searchData} />);
expect(screen.getByText(/Opened 2 PullRequests/)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import React from 'react';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Contributions from '../contributions';
import { createPushEvent, createPullRequestEvent } from './fixtures';
import { createPushEvent, createPullRequestEvent, createWatchEvent } from './fixtures';
import { SearchData } from '../types';

const emptySearchData: SearchData = { pullRequests: [], commits: [], issues: [] };

describe('Contributions', () => {
const defaultProps = {
result: [createPushEvent(), createPullRequestEvent()],
events: [createWatchEvent()],
searchData: emptySearchData,
username: 'testuser',
};

Expand Down Expand Up @@ -38,6 +42,6 @@ describe('Contributions', () => {
});

test('空配列でクラッシュしない', () => {
expect(() => render(<Contributions result={[]} username="testuser" />)).not.toThrow();
expect(() => render(<Contributions events={[]} searchData={emptySearchData} username="testuser" />)).not.toThrow();
});
});
Loading
Loading