Skip to content

Commit ab39f30

Browse files
committed
test: Add RepositoryGraph unit tests and cover fetchContributedRepos
1 parent cc53559 commit ab39f30

3 files changed

Lines changed: 209 additions & 20 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { render, screen, fireEvent, act } from '@testing-library/react';
4+
import React from 'react';
5+
import RepositoryGraph from './RepositoryGraph';
6+
import type { GraphNode, GraphLink } from '@/types';
7+
8+
// Mock next/dynamic to return our ForceGraphMock component directly and synchronously
9+
vi.mock('next/dynamic', () => {
10+
const DynamicForceGraphMock = React.forwardRef((props: any, ref: any) => {
11+
React.useImperativeHandle(ref, () => ({
12+
centerAt: vi.fn(),
13+
zoom: vi.fn(),
14+
}));
15+
16+
return (
17+
<div data-testid="force-graph-2d">
18+
{props.graphData?.nodes?.map((node: any) => (
19+
<button
20+
key={node.id}
21+
data-testid={`graph-node-${node.id}`}
22+
onClick={() => props.onNodeClick?.(node)}
23+
onMouseEnter={() => props.onNodeHover?.(node)}
24+
onMouseLeave={() => props.onNodeHover?.(null)}
25+
>
26+
{node.name}
27+
</button>
28+
))}
29+
</div>
30+
);
31+
});
32+
DynamicForceGraphMock.displayName = 'ForceGraph2D';
33+
return {
34+
default: () => DynamicForceGraphMock,
35+
};
36+
});
37+
38+
// Mock window.innerWidth/innerHeight or clientWidth/clientHeight
39+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
40+
configurable: true,
41+
value: 800,
42+
});
43+
44+
describe('RepositoryGraph', () => {
45+
const mockData = {
46+
nodes: [
47+
{ id: 'user1', name: 'User 1', type: 'User', val: 30, color: '#FFF' },
48+
{
49+
id: 'repo1',
50+
name: 'Repo 1',
51+
type: 'Repo',
52+
val: 15,
53+
color: '#FFF',
54+
stats: { stars: 10, forks: 2, language: 'TypeScript', updatedAt: '2024-05-30T00:00:00Z' },
55+
},
56+
{
57+
id: 'fork1',
58+
name: 'Fork 1',
59+
type: 'Fork',
60+
val: 10,
61+
color: '#FFF',
62+
stats: { stars: 0, forks: 0, language: 'JavaScript' },
63+
},
64+
{
65+
id: 'contrib1',
66+
name: 'Contrib 1',
67+
type: 'Contribution',
68+
val: 20,
69+
color: '#FFF',
70+
stats: { stars: 100, forks: 50, language: 'Rust' },
71+
},
72+
] as GraphNode[],
73+
links: [
74+
{ source: 'user1', target: 'repo1' },
75+
{ source: 'user1', target: 'fork1' },
76+
{ source: 'user1', target: 'contrib1' },
77+
] as GraphLink[],
78+
};
79+
80+
beforeEach(() => {
81+
vi.clearAllMocks();
82+
});
83+
84+
it('renders the empty state if data has 1 or fewer nodes', () => {
85+
const emptyData = { nodes: [mockData.nodes[0]], links: [] };
86+
render(<RepositoryGraph data={emptyData} />);
87+
expect(screen.getByText('No repository relationship data available yet.')).toBeDefined();
88+
});
89+
90+
it('renders the title and filter buttons', () => {
91+
render(<RepositoryGraph data={mockData} />);
92+
expect(screen.getByText('🌐 Repository Dependency Graph')).toBeDefined();
93+
expect(screen.getByText('Personal')).toBeDefined();
94+
expect(screen.getByText('Contributions')).toBeDefined();
95+
expect(screen.getByText('Forks')).toBeDefined();
96+
});
97+
98+
it('toggles filters when clicked', () => {
99+
render(<RepositoryGraph data={mockData} />);
100+
101+
const personalBtn = screen.getByText('Personal');
102+
const contributionsBtn = screen.getByText('Contributions');
103+
const forksBtn = screen.getByText('Forks');
104+
105+
// Turn off Personal
106+
fireEvent.click(personalBtn);
107+
expect(screen.queryByTestId('graph-node-repo1')).toBeNull();
108+
109+
// Turn off Contributions
110+
fireEvent.click(contributionsBtn);
111+
expect(screen.queryByTestId('graph-node-contrib1')).toBeNull();
112+
113+
// Turn off Forks
114+
fireEvent.click(forksBtn);
115+
expect(screen.queryByTestId('graph-node-fork1')).toBeNull();
116+
117+
// Turn Personal back on
118+
fireEvent.click(personalBtn);
119+
expect(screen.getByTestId('graph-node-repo1')).toBeDefined();
120+
});
121+
122+
it('displays a tooltip on node hover', async () => {
123+
render(<RepositoryGraph data={mockData} />);
124+
125+
const repoNode = screen.getByTestId('graph-node-repo1');
126+
127+
// Hover
128+
fireEvent.mouseEnter(repoNode);
129+
expect(screen.getAllByText('Repo 1').length).toBeGreaterThan(1);
130+
expect(screen.getByText('10 Stars')).toBeDefined();
131+
expect(screen.getByText('2 Forks')).toBeDefined();
132+
expect(screen.getByText('TypeScript')).toBeDefined();
133+
134+
// Unhover
135+
fireEvent.mouseLeave(repoNode);
136+
expect(screen.queryByText('10 Stars')).toBeNull();
137+
});
138+
139+
it('zooms on node click', () => {
140+
render(<RepositoryGraph data={mockData} />);
141+
const repoNode = screen.getByTestId('graph-node-repo1');
142+
fireEvent.click(repoNode);
143+
// Click successfully processed without throwing
144+
expect(repoNode).toBeDefined();
145+
});
146+
147+
it('handles window resize', () => {
148+
render(<RepositoryGraph data={mockData} />);
149+
act(() => {
150+
window.dispatchEvent(new Event('resize'));
151+
});
152+
// Check that it didn't throw
153+
expect(screen.getByText('🌐 Repository Dependency Graph')).toBeDefined();
154+
});
155+
156+
it('calculates and renders correct insights in the sidebar', () => {
157+
render(<RepositoryGraph data={mockData} />);
158+
// mockData has 1 repo + 1 fork = 2 ecosystem size
159+
expect(screen.getByText('2 Repositories connected')).toBeDefined();
160+
// Contrib 1 is the most starred (100 stars)
161+
expect(screen.getAllByText(/Contrib 1/).length).toBeGreaterThan(0);
162+
});
163+
});

lib/github.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
fetchWithRetry,
55
fetchUserProfile,
66
fetchUserRepos,
7+
fetchContributedRepos,
78
getFullDashboardData,
89
generateAchievements,
910
buildCommitClock,
@@ -562,6 +563,51 @@ describe('fetchUserRepos', () => {
562563
});
563564
});
564565

566+
describe('fetchContributedRepos', () => {
567+
beforeEach(() => vi.spyOn(global, 'fetch'));
568+
afterEach(() => vi.restoreAllMocks());
569+
570+
it('returns contributed repos on success', async () => {
571+
const mockNodes = [
572+
{
573+
name: 'repo1',
574+
nameWithOwner: 'owner/repo1',
575+
stargazerCount: 10,
576+
forkCount: 5,
577+
primaryLanguage: { name: 'TypeScript' },
578+
updatedAt: '2024-01-01T00:00:00Z',
579+
},
580+
];
581+
582+
vi.mocked(fetch).mockResolvedValue(
583+
mockResponse({
584+
data: {
585+
user: {
586+
repositoriesContributedTo: {
587+
nodes: mockNodes,
588+
},
589+
},
590+
},
591+
})
592+
);
593+
594+
const result = await fetchContributedRepos('octocat');
595+
expect(result).toEqual(mockNodes);
596+
});
597+
598+
it('returns empty array when fetch fails', async () => {
599+
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 }));
600+
const result = await fetchContributedRepos('octocat');
601+
expect(result).toEqual([]);
602+
});
603+
604+
it('returns empty array if data structure is missing', async () => {
605+
vi.mocked(fetch).mockResolvedValue(mockResponse({ data: null }));
606+
const result = await fetchContributedRepos('octocat');
607+
expect(result).toEqual([]);
608+
});
609+
});
610+
565611
describe('getFullDashboardData', () => {
566612
beforeEach(() => vi.spyOn(global, 'fetch'));
567613
afterEach(() => vi.restoreAllMocks());

lib/github.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -723,26 +723,6 @@ export function buildCommitClock(allDays: ContributionDay[]) {
723723
return dayNames.map((name, i) => ({ day: name, commits: dayTotals[i] }));
724724
}
725725

726-
export async function fetchUserOrganizations(
727-
username: string,
728-
options: FetchOptions = {}
729-
): Promise<{ login: string; avatar_url: string; description: string | null }[]> {
730-
const encodedUsername = encodeURIComponent(username);
731-
const res = await fetchWithRetry(`${GITHUB_REST_URL}/users/${encodedUsername}/orgs`, {
732-
headers: getHeaders(),
733-
cache: 'no-store',
734-
signal: options.signal,
735-
});
736-
737-
if (!res.ok) {
738-
if (res.status === 404) return [];
739-
throwIfRateLimited(res);
740-
return [];
741-
}
742-
743-
return await res.json();
744-
}
745-
746726
export async function fetchContributedRepos(
747727
username: string,
748728
options: FetchOptions = {}

0 commit comments

Comments
 (0)