Skip to content

Commit 0641ee8

Browse files
committed
test(calculateStreak): add full year zero contributions edge case test
2 parents c31b974 + 348372a commit 0641ee8

109 files changed

Lines changed: 10181 additions & 1417 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [main, dev]
66
pull_request:
77
branches: [main, dev]
8+
merge_group: # Run CI on the merged commit before it lands on main
89

910
jobs:
1011
quality:

.github/workflows/conflict-notifier.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ jobs:
8181
});
8282
}
8383
84-
// Check existing comments to enforce a 2-hour notification gap
84+
// Check existing comments to enforce a 24-hour notification gap
8585
const { data: comments } = await github.rest.issues.listComments({
8686
owner: context.repo.owner,
8787
repo: context.repo.repo,
@@ -101,8 +101,8 @@ jobs:
101101
const lastComment = conflictComments[conflictComments.length - 1];
102102
const lastCommentTime = new Date(lastComment.created_at).getTime();
103103
const nowTime = new Date().getTime();
104-
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
105-
if (nowTime - lastCommentTime >= TWO_HOURS_MS) {
104+
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
105+
if (nowTime - lastCommentTime >= TWENTY_FOUR_HOURS_MS) {
106106
shouldComment = true;
107107
}
108108
}

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ URL Parameter > Theme Default > System Fallback
189189
| `labelColor` | `hex` | No || Custom text color for the isometric labels — **without** `#` |
190190
| `versus` | `string` | No || GitHub username of an opponent to compare against in side-by-side versus mode |
191191
| `shading` | `boolean` | No | `false` | Apply intensity-based opacity shading to tower faces so lower intensity levels appear slightly dimmer |
192+
| `opacity` | `number` | No | `1.0` | Global opacity scalar for all tower fill-opacity values (0.1–1.0). `opacity=0.5` = semi-transparent ghost look. `opacity=0.8` = faded, great on light backgrounds. |
192193
| `gradient` | `boolean` | No | `false` | Opt-in to show volumetric gradients on the monolith floor |
193194

194195
### Grace Period Examples
@@ -322,6 +323,14 @@ Explore some of the built-in CommitPulse themes and quickly copy the style you l
322323

323324
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&gradient=true&shading=true)
324325

326+
<!-- Semi-transparent ghost city look -->
327+
328+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.5)
329+
330+
<!-- Slightly faded — perfect for light background embeds -->
331+
332+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.8)
333+
325334
<!-- GitHub-style Heatmap View -->
326335

327336
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=heatmap)

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ vi.mock('@/components/dashboard/Heatmap', () => ({
5656
),
5757
}));
5858

59+
vi.mock('@/components/dashboard/HistoricalTrendView', () => ({
60+
default: ({ period, activity }: { period: { label: string }; activity: unknown[] }) => (
61+
<div data-testid="historical-trend-view" data-prop={JSON.stringify(activity)}>
62+
{period.label}
63+
</div>
64+
),
65+
}));
66+
5967
vi.mock('@/components/dashboard/AIInsights', () => ({
6068
default: () => <div data-testid="ai-insights">AIInsights</div>,
6169
}));
@@ -92,6 +100,7 @@ describe('DashboardPage', () => {
92100
achievements: [],
93101
commitClock: [],
94102
graphData: { nodes: [], links: [] },
103+
lastSyncedAt: undefined,
95104
};
96105

97106
beforeEach(() => {
@@ -147,9 +156,15 @@ describe('DashboardPage', () => {
147156

148157
render(PageContent);
149158

150-
expect(getFullDashboardData).toHaveBeenCalledWith('octocat', {
151-
bypassCache: false,
152-
});
159+
expect(getFullDashboardData).toHaveBeenCalledWith(
160+
'octocat',
161+
expect.objectContaining({
162+
bypassCache: false,
163+
from: expect.any(String),
164+
to: expect.any(String),
165+
rangeLabel: 'Last 12 months',
166+
})
167+
);
153168

154169
const generateLink = screen.getByText('Generate Your Own').closest('a');
155170
expect(generateLink).toBeDefined();
@@ -158,7 +173,7 @@ describe('DashboardPage', () => {
158173
expect(screen.getByTestId('activity-landscape')).toBeDefined();
159174
expect(screen.getByTestId('language-chart')).toBeDefined();
160175
expect(screen.getByTestId('commit-clock')).toBeDefined();
161-
expect(screen.getByTestId('heatmap')).toBeDefined();
176+
expect(screen.getByTestId('historical-trend-view')).toBeDefined();
162177
expect(screen.getByTestId('ai-insights')).toBeDefined();
163178
expect(screen.getByTestId('achievements')).toBeDefined();
164179
expect(screen.getAllByTestId('stats-card')).toHaveLength(3);
@@ -175,21 +190,46 @@ describe('DashboardPage', () => {
175190

176191
render(PageContent);
177192

178-
expect(getFullDashboardData).toHaveBeenCalledWith('octocat', {
179-
bypassCache: true,
193+
expect(getFullDashboardData).toHaveBeenCalledWith(
194+
'octocat',
195+
expect.objectContaining({
196+
bypassCache: true,
197+
from: expect.any(String),
198+
to: expect.any(String),
199+
rangeLabel: 'Last 12 months',
200+
})
201+
);
202+
});
203+
204+
it('passes a calendar-year query through to getFullDashboardData', async () => {
205+
const PageContent = await DashboardPage({
206+
params: Promise.resolve({ username: 'octocat' }),
207+
searchParams: Promise.resolve({ year: '2024' }),
180208
});
209+
210+
render(PageContent);
211+
212+
expect(getFullDashboardData).toHaveBeenCalledWith(
213+
'octocat',
214+
expect.objectContaining({
215+
bypassCache: false,
216+
from: '2024-01-01T00:00:00.000Z',
217+
to: '2024-12-31T23:59:59.999Z',
218+
rangeLabel: '2024',
219+
})
220+
);
181221
});
182222

183-
it('passes the correct activity data to Heatmap', async () => {
223+
it('passes the correct activity data to the historical trend view', async () => {
184224
const PageContent = await DashboardPage({
185225
params: Promise.resolve({ username: 'octocat' }),
186226
searchParams: Promise.resolve({}),
187227
});
188228

189229
render(PageContent);
190230

191-
const heatmap = screen.getByTestId('heatmap');
192-
expect(JSON.parse(heatmap.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity);
231+
const trendView = screen.getByTestId('historical-trend-view');
232+
expect(JSON.parse(trendView.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity);
193233
});
194234

195235
it('calls notFound when dashboard data fetch throws an error', async () => {

app/(root)/dashboard/[username]/page.tsx

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
44
import DashboardClient from '@/components/dashboard/DashboardClient';
55
import { getFullDashboardData, fetchUserProfile } from '@/lib/github';
66
import { notFound, redirect } from 'next/navigation';
7+
import { resolveDashboardPeriod } from '@/utils/dashboardPeriod';
78

89
export const revalidate = 3600; // Cache for 1 hour
910

@@ -60,22 +61,42 @@ export default async function DashboardPage({
6061
searchParams,
6162
}: {
6263
params: Promise<{ username: string }>;
63-
searchParams: Promise<{ refresh?: string }>;
64+
searchParams: Promise<{
65+
refresh?: string;
66+
compare?: string;
67+
year?: string;
68+
month?: string;
69+
from?: string;
70+
to?: string;
71+
}>;
6472
}) {
6573
const { username } = await params;
66-
const refreshParams = await searchParams;
67-
const bypassCache = refreshParams?.refresh === 'true';
74+
const resolvedSearchParams = await searchParams;
75+
const bypassCache = resolvedSearchParams?.refresh === 'true';
76+
const compareUsername = resolvedSearchParams?.compare;
77+
const period = resolveDashboardPeriod({
78+
year: resolvedSearchParams?.year,
79+
month: resolvedSearchParams?.month,
80+
from: resolvedSearchParams?.from,
81+
to: resolvedSearchParams?.to,
82+
});
6883

6984
let data;
7085

7186
try {
72-
data = await getFullDashboardData(username, { bypassCache });
87+
data = await getFullDashboardData(username, {
88+
bypassCache,
89+
from: period.from,
90+
to: period.to,
91+
rangeLabel: period.label,
92+
});
7393
} catch (error) {
7494
if (error instanceof Error && error.message.includes('not found')) {
75-
// Smart Redirect: If the GraphQL "user" query fails, check if it's actually an Organization
7695
let fallbackProfile;
7796
try {
78-
fallbackProfile = await fetchUserProfile(username, { bypassCache });
97+
fallbackProfile = await fetchUserProfile(username, {
98+
bypassCache,
99+
});
79100
} catch {
80101
return notFound();
81102
}
@@ -87,5 +108,24 @@ export default async function DashboardPage({
87108
throw error;
88109
}
89110

90-
return <DashboardClient initialData={data} username={username} />;
111+
let compareData = null;
112+
113+
if (compareUsername && compareUsername.toLowerCase() !== username.toLowerCase()) {
114+
try {
115+
compareData = await getFullDashboardData(compareUsername, {
116+
bypassCache,
117+
});
118+
} catch {
119+
compareData = null;
120+
}
121+
}
122+
123+
return (
124+
<DashboardClient
125+
initialData={data}
126+
username={username}
127+
compareData={compareData}
128+
period={period}
129+
/>
130+
);
91131
}

app/api/compare/route.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('@/lib/github', () => ({
5+
getFullDashboardData: vi.fn(),
6+
}));
7+
8+
import { getFullDashboardData } from '@/lib/github';
9+
10+
const makeRequest = (search: string) => new Request(`http://localhost:3000/api/compare?${search}`);
11+
12+
describe('GET /api/compare', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
vi.mocked(getFullDashboardData).mockResolvedValue({
16+
calendar: { totalContributions: 50, weeks: [] },
17+
} as never);
18+
});
19+
20+
// ── Validation ────────────────────────────────────────────────────────────
21+
22+
it('returns 400 when user1 is missing', async () => {
23+
const res = await GET(makeRequest('user2=octocat'));
24+
expect(res.status).toBe(400);
25+
});
26+
27+
it('returns 400 when user2 is missing', async () => {
28+
const res = await GET(makeRequest('user1=octocat'));
29+
expect(res.status).toBe(400);
30+
});
31+
32+
it('returns 400 when both users are missing', async () => {
33+
const res = await GET(makeRequest(''));
34+
expect(res.status).toBe(400);
35+
});
36+
37+
it('returns 400 for invalid GitHub username format for user1', async () => {
38+
const res = await GET(makeRequest('user1=-invalid&user2=octocat'));
39+
expect(res.status).toBe(400);
40+
const data = await res.json();
41+
expect(data.details.fieldErrors.user1).toBeDefined();
42+
});
43+
44+
it('returns 400 for invalid GitHub username format for user2', async () => {
45+
const res = await GET(makeRequest('user1=octocat&user2=-invalid'));
46+
expect(res.status).toBe(400);
47+
const data = await res.json();
48+
expect(data.details.fieldErrors.user2).toBeDefined();
49+
});
50+
51+
it('returns 400 for username exceeding 39 characters', async () => {
52+
const res = await GET(makeRequest(`user1=${'a'.repeat(40)}&user2=octocat`));
53+
expect(res.status).toBe(400);
54+
});
55+
56+
it('returns 400 when comparing a user with themselves', async () => {
57+
const res = await GET(makeRequest('user1=octocat&user2=octocat'));
58+
expect(res.status).toBe(400);
59+
const data = await res.json();
60+
expect(data.details.fieldErrors.user2).toContain('Cannot compare a user with themselves.');
61+
});
62+
63+
it('returns 400 for self-comparison regardless of case', async () => {
64+
const res = await GET(makeRequest('user1=OctoCat&user2=octocat'));
65+
expect(res.status).toBe(400);
66+
});
67+
68+
// ── Success ──────────────────────────────────────────────────────────────
69+
70+
it('returns 200 with comparison data for valid users', async () => {
71+
const res = await GET(makeRequest('user1=alice&user2=bob'));
72+
expect(res.status).toBe(200);
73+
const data = await res.json();
74+
expect(data.user1).toBeDefined();
75+
expect(data.user2).toBeDefined();
76+
});
77+
78+
// ── Error handling ────────────────────────────────────────────────────────
79+
80+
it('returns 404 when user1 is not found on GitHub', async () => {
81+
vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('Not found'));
82+
const res = await GET(makeRequest('user1=ghost123&user2=octocat'));
83+
expect(res.status).toBe(404);
84+
});
85+
86+
it('returns 404 when user2 is not found on GitHub', async () => {
87+
vi.mocked(getFullDashboardData)
88+
.mockResolvedValueOnce({ calendar: { totalContributions: 0, weeks: [] } } as never)
89+
.mockRejectedValueOnce(new Error('Not found'));
90+
const res = await GET(makeRequest('user1=octocat&user2=ghost123'));
91+
expect(res.status).toBe(404);
92+
});
93+
94+
it('returns 403 when the user1 fetch hits a GitHub rate limit', async () => {
95+
vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('API Rate Limit Exceeded'));
96+
97+
const res = await GET(makeRequest('user1=octocat&user2=torvalds'));
98+
99+
expect(res.status).toBe(403);
100+
const data = await res.json();
101+
expect(data.error).toBe('GitHub API rate limit reached. Please configure GITHUB_TOKEN.');
102+
});
103+
104+
it('returns 403 when the user2 fetch hits a GitHub rate limit', async () => {
105+
vi.mocked(getFullDashboardData)
106+
.mockResolvedValueOnce({ calendar: { totalContributions: 0, weeks: [] } } as never)
107+
.mockRejectedValueOnce(new Error('GitHub GraphQL API returned status 403'));
108+
109+
const res = await GET(makeRequest('user1=octocat&user2=torvalds'));
110+
111+
expect(res.status).toBe(403);
112+
const data = await res.json();
113+
expect(data.error).toBe('GitHub API rate limit reached. Please configure GITHUB_TOKEN.');
114+
});
115+
116+
it('returns 500 for non-rate-limit upstream failures instead of reporting not found', async () => {
117+
vi.mocked(getFullDashboardData).mockRejectedValueOnce(
118+
new Error('GitHub GraphQL API returned status 500')
119+
);
120+
121+
const res = await GET(makeRequest('user1=octocat&user2=torvalds'));
122+
123+
expect(res.status).toBe(500);
124+
const data = await res.json();
125+
expect(data.error).toContain('GitHub GraphQL API returned status 500');
126+
});
127+
});

0 commit comments

Comments
 (0)