Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
116 commits
Select commit Hold shift + click to select a range
9b0d7b4
test(StudentProfileModel-type-compiler): verify TypeScript Compiler V…
atul-upadhyay-7 Jun 2, 2026
fc4b629
Add massive scaling tests for BackgroundRefresh
atul-upadhyay-7 Jun 3, 2026
b189dac
test(leaderboard): add unit tests for Leaderboard component
boss477 Jun 3, 2026
cc589f7
test(feature-cards): add unit tests for FeatureCards component
boss477 Jun 3, 2026
e8daaf7
test(brand-particles): add unit tests for BrandParticles component
boss477 Jun 3, 2026
3e985b8
test(refresh-policy): fix flaky singleton reset stability assertion
boss477 Jun 3, 2026
496ee27
test(rate-limiter): optimize assertions inside loop in massive-scalin…
boss477 Jun 3, 2026
87ee021
test(ResumeProfileSection-timezone-boundaries): verify Timezone Norma…
Muskan-S123 Jun 3, 2026
271c709
test(ResumeProfileSection-mock-integrations): verify Asynchronous Ser…
Muskan-S123 Jun 3, 2026
b467b45
fix
kanishka-2007-tech Jun 3, 2026
27ca10a
test(mock-integrations): add ResumeUpload async service coverage
saagnik23 Jun 3, 2026
4c41140
test(error-resilience): add ResumeUpload failure handling coverage
saagnik23 Jun 3, 2026
87c1a7c
test(ResumeProfileSection): add empty fallback coverage
Birundalakshmi Jun 3, 2026
51a35a1
test
kanishka-2007-tech Jun 3, 2026
17ca88d
test(accessibility): add ResumeUpload accessibility coverage
saagnik23 Jun 3, 2026
3986e40
test: add ComparePage mock integration coverage
Sreeya-kumari Jun 3, 2026
ba6d939
2700
dakshitcodes Jun 3, 2026
2d3fb5b
test
kanishka-2007-tech Jun 3, 2026
141bb35
test : sunset test added
kanishka-2007-tech Jun 3, 2026
a3adbeb
2701
dakshitcodes Jun 3, 2026
c1a49cc
resolved conflicts
dakshitcodes Jun 3, 2026
2432666
2704
dakshitcodes Jun 3, 2026
7434956
2708
dakshitcodes Jun 3, 2026
8f8611a
test: add scroll restoration error resilience coverage
coffeee27 Jun 3, 2026
f9a210f
test: add scroll restoration responsive breakpoint coverage
coffeee27 Jun 3, 2026
dedcb50
test: add scroll restoration timezone boundary coverage
coffeee27 Jun 3, 2026
1bb2b86
test: add theme switch massive scaling coverage
coffeee27 Jun 3, 2026
9dd75c0
test : achievement empty fallback
kanishka-2007-tech Jun 3, 2026
dedac22
test: add scroll restoration mock integrations coverage
coffeee27 Jun 3, 2026
53a474c
feat: Add dynamic glowing milestone badges for streaks and contributions
Jun 3, 2026
bc65bcc
test(contributors): implement loading error resilience unit tests
alishaalmeida10 Jun 2, 2026
1f5481a
feat(onboarding): improve badge generation experience
RosheshChaware Jun 3, 2026
33c86c8
test(Icons-timezone-boundaries): verify Timezone Normalization & Cale…
adityack477 Jun 3, 2026
aa90f4f
fix(ui): prevent NaN scroll progress when page is not scrollable
mohdsubhan1756 Jun 3, 2026
d5be359
test(Icons-mock-integrations): verify Asynchronous Service Layer Mock…
adityack477 Jun 3, 2026
653df10
fix(api): prevent rate limit bypass when client IP is unknown in noti…
mohdsubhan1756 Jun 3, 2026
4b56d9f
test: add TypeScript compiler validation coverage
Sreeya-kumari Jun 3, 2026
be689e6
fix(api): enforce rate limiting for unknown client IPs
mohdsubhan1756 Jun 3, 2026
21c846d
fix: update package name from github-streak-badge to commitpulse
ScarsAndSource Jun 3, 2026
156fcb9
test(ScrollRestoration-accessibility): verify Accessibility Standards…
adityack477 Jun 3, 2026
8e6760d
fix(actions): support GITHUB_PAT in check-duplicates script
mohdsubhan1756 Jun 3, 2026
96aba91
test(theme-switch): add component animation tests
gamana618 Jun 2, 2026
fcc5328
test(ResumePreviewForm-type-compiler): verify TypeScript Compiler Val…
Muskan-S123 Jun 3, 2026
fbb6440
refactor(api): extract cache-control header builder into utility func…
lokeshkumar69 Jun 3, 2026
78c7b6f
fix(test): add vitest imports to cacheControl test file
lokeshkumar69 Jun 3, 2026
e4b30da
fix(github): preserve GraphQL response body after retry exhaustion
mohdsubhan1756 Jun 3, 2026
0e30623
feat: add animated custom cursor with hover effects (#3022)
hari2k7 Jun 3, 2026
35c7983
english
kanishka-2007-tech Jun 3, 2026
3a43112
test(components): add vitest suite for commitpulse-logo
TanmayNelge Jun 3, 2026
f475553
test(template): add type compiler validation tests
pari7maheshwari Jun 2, 2026
c371816
test(resume-parser): add responsive breakpoint validation tests
pari7maheshwari Jun 3, 2026
35c19f4
test: add error resilience tests for QuotaMonitor
pari7maheshwari Jun 3, 2026
2f9ff15
test(models): add StudentProfile theme contrast stability tests
pari7maheshwari Jun 3, 2026
6c18df1
test: add mock integration tests for resume parser
pari7maheshwari Jun 3, 2026
a789212
fix: footer responsive layout on mobile screens
sarthakshruti999-code Jun 3, 2026
b4253d5
test(footer): update responsive layout test
sarthakshruti999-code Jun 3, 2026
1e9088d
test: add tooltip utils empty fallback tests
mayank200529 Jun 2, 2026
dbf0686
Doc Changes made
Aryan-Agarwal-creator Jun 2, 2026
e75a581
test(compareclient-type-compiler): add type validation coverage
VirenSumbly Jun 2, 2026
2d7a913
test(discordbutton-mouse-interactivity): add hover interaction coverage
VirenSumbly Jun 2, 2026
410cb86
test(commitpulse-logo-accessibility): add accessibility coverage
VirenSumbly Jun 2, 2026
e13a04e
test(quota-monitor): add massive scaling coverage
riddhimagupta2 Jun 2, 2026
294acd2
Delete services/github/background-refresh.type-compiler.test.ts
riddhimagupta2 Jun 2, 2026
4ebeaaf
test(background-refresh): add type compiler validation coverage
riddhimagupta2 Jun 2, 2026
8927225
test: add edge-case test for isolated single-day streak
HimanshuGaur14 Jun 2, 2026
0d0733f
test(ShareButtons): verify Dark and Light Prefers-Color-Scheme Visual…
Mohammedsami001 Jun 2, 2026
27ecd13
test: timezone normalization and calendar boundary alignment for Back…
Subhooo5 Jun 2, 2026
6c0e105
test(Leaderboard): verify Responsive Multi-device Columns & Mobile Vi…
Mohammedsami001 Jun 2, 2026
05e2072
test(ContributorsPage-theme-contrast): verify Dark and Light Prefers-…
ShafinNigamana Jun 2, 2026
7ed2dd6
test(ContributorsPage-massive-scaling): verify Massive Data Sets and …
ShafinNigamana Jun 2, 2026
c710e37
test(ui): verify responsive rendering and elements of RadarChart (#1509)
YerraguntaAjayKumar Jun 2, 2026
f403789
fix(test): restore correct glow filters count to 2
YerraguntaAjayKumar Jun 3, 2026
2753247
style(test): format RadarChart.test.tsx with prettier
YerraguntaAjayKumar Jun 3, 2026
5e48498
fix: add pointer cursor to activity landscape timeframe filters
24-Harshdeep Jun 2, 2026
3c849bd
worked on copiolet comments
24-Harshdeep Jun 2, 2026
61b5d0c
test(ContributorsLoading-mock-integrations): verify Asynchronous Serv…
ShafinNigamana Jun 2, 2026
2f86ff7
test(ContributorsLoading-timezone-boundaries): verify Timezone Normal…
ShafinNigamana Jun 2, 2026
364d054
test(dashboard): create timezone normalization and data boundary test…
ashishraj1504 Jun 2, 2026
dc003d4
test(background-refresh): add responsive breakpoints coverage for mul…
Subhooo5 Jun 2, 2026
b9b6826
test(VisualizationTooltip): add empty-fallback edge case coverage
ARPANPATRA111 Jun 2, 2026
1e444b7
test(ThemeSwitch-timezone-boundaries): verify Timezone Normalization …
ShafinNigamana Jun 2, 2026
65f7fde
test(compare): verify interactive tooltips and hover behavior
Thacker-Meet Jun 2, 2026
bcac371
test: add invalid bg hex validation coverage
Jun 2, 2026
77be592
test(dashboard): verify error resilience and fallback boundaries for …
ashishraj1504 Jun 2, 2026
c0419f0
fix(api): rate limit stats refresh bypass
eshaanag Jun 2, 2026
4f415fb
test(background-refresh): add error resilience coverage for hydration…
Subhooo5 Jun 2, 2026
55d4b3c
test: add accessibility tests for background refresh
bhanvigupta Jun 2, 2026
32fa071
test(themes): add unit tests for glacier theme color validation
Tapasya-12 Jun 2, 2026
20c07e9
test(Leaderboard): verify Interactive Tooltips, Cursor Hovers & Touch…
Mohammedsami001 Jun 2, 2026
5abdf6c
style: apply prettier formatting
Mohammedsami001 Jun 2, 2026
364991e
fix(test): use proper typescript interfaces in mocks
Mohammedsami001 Jun 2, 2026
859e3c7
test(Leaderboard): verify Massive Data Sets and Extreme High Bounds S…
Mohammedsami001 Jun 2, 2026
cc6c408
style: apply prettier formatting
Mohammedsami001 Jun 2, 2026
7ec9ce9
fix(test): use proper typescript interfaces in mocks
Mohammedsami001 Jun 2, 2026
3deb667
fix(test): bump performance bounds timeout for slower CI runners
Mohammedsami001 Jun 2, 2026
219b3a9
test(themes): add rose theme tests (#2309)
Priti-1001 Jun 2, 2026
19a785c
feat(github): support multiple token rotation and rate limit fallback
nishtha-agarwal-211 Jun 2, 2026
2a0180e
fix: preserve dates outside base calendar range in aggregateCalendars
ArnavJoshi6391 Jun 2, 2026
9ecc8d7
test(FeatureCards): verify Responsive Multi-device Columns & Mobile V…
Mohammedsami001 Jun 2, 2026
8d99297
test(compare): verify dark and light theme contrast states
Thacker-Meet Jun 2, 2026
93d933e
fix(validation): clarify hex color error messages to reflect # is acc…
Pranav-IIITM Jun 2, 2026
9cd7ad4
test(resume-preview-form): add massive scaling coverage
mayank200529 Jun 2, 2026
628bbfa
fix(svg): add aria attributes and desc to pulse badge SVG functions
Pranav-IIITM Jun 2, 2026
f7f0fe7
test(resumeprofilesection): add type compiler validation coverage
aanyacloud Jun 2, 2026
41df2c8
test: add massive scaling coverage for WallOfLove
khyatiagrawal-2025 Jun 2, 2026
3726dd0
test: add timezone boundary alignment coverage
Sreeya-kumari Jun 2, 2026
e17aad5
test(leaderboard): add theme contrast coverage
imposterbharathkumarburugu15-cpu Jun 2, 2026
936cf68
test(template): verify responsive multi-device columns and mobile lay…
realtushartyagi Jun 2, 2026
e5d4e50
test(resume-preview-form): add empty fallback coverage
mayank200529 Jun 2, 2026
d1ad4d1
test(feature-cards): add theme contrast coverage
chavanGaneshDatta Jun 2, 2026
c2f5f83
test(feature-cards): add accessibility coverage
chavanGaneshDatta Jun 2, 2026
e51ad29
test(discordbutton): add error resilience coverage
imposterbharathkumarburugu15-cpu Jun 2, 2026
b5ae83a
test(feature-cards): add mouse interactivity coverage
chavanGaneshDatta Jun 2, 2026
445eeca
test(svg-constants-type-compiler): verify TypeScript Compiler Validat…
RavindiFernando Jun 2, 2026
69b7945
test(feature-cards): add mock integrations coverage
chavanGaneshDatta Jun 2, 2026
4a09003
docs: improve query parameter documentation
REHAN-503 Jun 3, 2026
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
4 changes: 2 additions & 2 deletions .github/scripts/check-duplicates.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ function cosineSimilarity(vecA, vecB) {

async function run() {
const geminiApiKey = process.env.GEMINI_API_KEY;
const githubToken = process.env.GITHUB_TOKEN;
const githubToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN;

if (!geminiApiKey) {
throw new Error('❌ Missing GEMINI_API_KEY environment variable.');
}
if (!githubToken) {
throw new Error('❌ Missing GITHUB_TOKEN environment variable.');
throw new Error('❌ Missing GITHUB_PAT or GITHUB_TOKEN environment variable.');
}

const octokit = github.getOctokit(githubToken);
Expand Down
146 changes: 74 additions & 72 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion app/api/notify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export async function POST(req: NextRequest): Promise<NextResponse<NotificationR
// Rate limiting
const ip = getClientIp(req);

if (ip !== 'unknown' && !(await notifyRateLimiter.check(ip))) {
// fallback ensures rate limit is ALWAYS applied
const rateLimitKey =
ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`;

if (!(await notifyRateLimiter.check(rateLimitKey))) {
return NextResponse.json(
{ success: false, message: 'Too many requests, please try again later.' },
{ status: 429 }
Expand Down
57 changes: 55 additions & 2 deletions app/api/stats/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ vi.mock('../../../lib/github', () => ({

import { fetchGitHubContributions } from '../../../lib/github';
import type { ContributionCalendar } from '../../../types';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';

// Calendar with a known, predictable streak so assertions are deterministic.
// Last day (2024-06-16) has commits; "today" in tests is set to that date.
Expand All @@ -27,17 +30,25 @@ const mockCalendar: ContributionCalendar = {
],
};

function makeRequest(params: Record<string, string> = {}): Request {
function makeRequest(
params: Record<string, string> = {},
headers: Record<string, string> = {}
): Request {
const url = new URL('http://localhost/api/stats');
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return new Request(url.toString());
return new Request(url.toString(), {
headers: new Headers(headers),
});
}

describe('GET /api/stats', () => {
beforeEach(() => {
vi.clearAllMocks();
quotaMonitor.reset();
refreshPolicy.reset();
refreshRateLimiter.reset();
vi.mocked(fetchGitHubContributions).mockResolvedValue({
calendar: mockCalendar,
repoContributions: [],
Expand Down Expand Up @@ -110,6 +121,7 @@ describe('GET /api/stats', () => {
expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate');
expect(response.headers.get('Pragma')).toBe('no-cache');
expect(response.headers.get('Expires')).toBe('0');
expect(response.headers.get('X-Refresh-Status')).toBe('Fresh');
});

it('still returns valid stats data when refresh=true', async () => {
Expand Down Expand Up @@ -143,6 +155,47 @@ describe('GET /api/stats', () => {
expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: false });
});

it('serves cached stats instead of bypassing cache during refresh cooldown', async () => {
await GET(makeRequest({ user: 'testuser', refresh: 'true' }));

const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));

expect(response.status).toBe(200);
expect(fetchGitHubContributions).toHaveBeenLastCalledWith('testuser', {
bypassCache: false,
});
expect(response.headers.get('X-Refresh-Status')).toBe('Cooldown-Served-Cached');
expect(response.headers.get('Cache-Control')).toBe(
'public, s-maxage=3600, stale-while-revalidate=86400'
);
});

it('returns 429 when stats refresh exceeds the client refresh rate limit', async () => {
refreshRateLimiter.setLimit(1);

await GET(makeRequest({ user: 'testuser', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' }));

const response = await GET(
makeRequest({ user: 'octocat', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' })
);

expect(response.status).toBe(429);
const body = await response.json();
expect(body.error).toContain('Refresh rate limit exceeded');
expect(response.headers.get('X-RateLimit-Limit')).toBe('1');
});

it('blocks stats refresh when the shared GitHub quota is low', async () => {
quotaMonitor.setQuota(5000, 400, Date.now() + 60_000);

const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' }));

expect(response.status).toBe(429);
const body = await response.json();
expect(body.error).toContain('quota is low');
expect(fetchGitHubContributions).not.toHaveBeenCalled();
});

it('accepts a valid IANA timezone without error', async () => {
const response = await GET(makeRequest({ user: 'testuser', tz: 'America/New_York' }));
expect(response.status).toBe(200);
Expand Down
72 changes: 70 additions & 2 deletions app/api/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import { NextResponse } from 'next/server';
import { fetchGitHubContributions } from '@/lib/github';
import { calculateStreak } from '@/lib/calculate';
import { statsParamsSchema } from '@/lib/validations';
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter';

function logSecurityEvent(event: string, details: Record<string, unknown>) {
console.warn(
JSON.stringify({
timestamp: new Date().toISOString(),
type: 'SECURITY_EVENT',
event,
...details,
})
);
}

/**
* GET /api/stats?user=<username>[&refresh=true][&tz=<IANA timezone>]
Expand All @@ -20,6 +35,7 @@ import { statsParamsSchema } from '@/lib/validations';
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const ip = getClientIp(request);

const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));

Expand Down Expand Up @@ -47,6 +63,54 @@ export async function GET(request: Request) {

const { user, refresh, tz } = parseResult.data;

if (refresh && quotaMonitor.isQuotaLow()) {
logSecurityEvent('LOW_QUOTA_STATS_REFRESH_BLOCKED', {
user,
ip,
remainingQuota: quotaMonitor.getQuota().remaining,
});
return NextResponse.json(
{ error: 'GitHub API quota is low. Stats refresh temporarily disabled.' },
{ status: 429 }
);
}

if (refresh) {
const rateLimitCheck = refreshRateLimiter.checkLimit(ip);
if (!rateLimitCheck.success) {
logSecurityEvent('STATS_REFRESH_RATE_LIMIT_EXCEEDED', {
user,
ip,
limit: rateLimitCheck.limit,
});
return NextResponse.json(
{ error: 'Refresh rate limit exceeded. Please try again later.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': rateLimitCheck.limit.toString(),
'X-RateLimit-Remaining': rateLimitCheck.remaining.toString(),
'X-RateLimit-Reset': rateLimitCheck.reset.toString(),
},
}
);
}
}

let shouldBypassCache = refresh;
if (refresh) {
if (!refreshPolicy.isRefreshAllowed(user)) {
logSecurityEvent('STATS_REFRESH_COOLDOWN_VIOLATION', {
user,
ip,
remainingMs: refreshPolicy.getRemainingCooldown(user),
});
shouldBypassCache = false;
} else {
refreshPolicy.recordRefresh(user);
}
}

// Validate the optional IANA timezone early so callers get a clear 400
// rather than a silent fallback or a 500.
let timezone = 'UTC';
Expand All @@ -59,19 +123,23 @@ export async function GET(request: Request) {
}

try {
const userData = await fetchGitHubContributions(user, { bypassCache: refresh });
const userData = await fetchGitHubContributions(user, { bypassCache: shouldBypassCache });
const calendar = userData.calendar;
const stats = calculateStreak(calendar, timezone);
const headers = new Headers({
// Cache until next UTC midnight; clients can bust with ?refresh=true
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
});

if (refresh) {
if (shouldBypassCache) {
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate');
headers.set('Pragma', 'no-cache');
headers.set('Expires', '0');
}
headers.set(
'X-Refresh-Status',
shouldBypassCache ? 'Fresh' : refresh ? 'Cooldown-Served-Cached' : 'Cached'
);

return NextResponse.json(
{
Expand Down
4 changes: 4 additions & 0 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,11 @@ describe('GET /api/streak', () => {
expect(body.details).not.toBeNull();
});
});
it('returns 400 when an invalid hex color is passed as bg', async () => {
const response = await GET(makeRequest({ user: 'octocat', bg: '#ZZZZZZ' }));

expect(response.status).toBe(400);
});
describe('hide parameters', () => {
it('removes the username title when hide_title=true', async () => {
const response = await GET(makeRequest({ user: 'octocat', hide_title: 'true' }));
Expand Down
2 changes: 2 additions & 0 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export async function GET(request: Request) {
glow,
format,
days,
badges,
} = parseResult.data;
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
const themeName = theme || 'dark';
Expand Down Expand Up @@ -182,6 +183,7 @@ export async function GET(request: Request) {
disable_particles,
glow,
animate,
badges,
};

let calendar;
Expand Down
4 changes: 3 additions & 1 deletion app/api/track-user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export async function POST(req: Request) {
// Get IP for rate limiting securely
const ip = getClientIp(req);

if (ip !== '127.0.0.1' && ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) {
const rateLimitKey = ip === 'unknown' ? 'unknown-client' : ip;

if (ip !== '127.0.0.1' && !(await trackUserRateLimiter.check(rateLimitKey))) {
return NextResponse.json(
{ success: false, error: 'Too many requests, please try again later.' },
{ status: 429 }
Expand Down
104 changes: 104 additions & 0 deletions app/api/user-details/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET } from './route';

vi.mock('@/lib/github', () => ({
fetchUserProfile: vi.fn(),
fetchGitHubContributions: vi.fn(),
}));

import { fetchUserProfile, fetchGitHubContributions } from '@/lib/github';
import type { ContributionCalendar } from '@/types';

const mockCalendar: ContributionCalendar = {
totalContributions: 15,
weeks: [
{
contributionDays: [
{ contributionCount: 5, date: '2024-06-10' },
{ contributionCount: 5, date: '2024-06-11' },
{ contributionCount: 5, date: '2024-06-12' },
],
},
],
};

const mockProfile = {
login: 'testuser',
name: 'Test User',
avatar_url: 'https://github.com/testuser.png',
public_repos: 12,
};

function makeRequest(params: Record<string, string> = {}): Request {
const url = new URL('http://localhost/api/user-details');
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return new Request(url.toString());
}

describe('GET /api/user-details', () => {
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fetchUserProfile).mockResolvedValue(mockProfile as any);
vi.mocked(fetchGitHubContributions).mockResolvedValue({
calendar: mockCalendar,
repoContributions: [],
totalPRs: 0,
totalIssues: 0,
});
});

it('returns 400 when username is missing', async () => {
const response = await GET(makeRequest());
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('Username is required');
});

it('returns 400 when username format is invalid', async () => {
const response = await GET(makeRequest({ username: 'invalid_user_name_@' }));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toBe('Invalid username format');
});

it('returns 200 with user details and streak stats on success', async () => {
const response = await GET(makeRequest({ username: 'testuser' }));
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({
exists: true,
login: 'testuser',
name: 'Test User',
avatar_url: 'https://github.com/testuser.png',
public_repos: 12,
stats: {
currentStreak: 3,
longestStreak: 3,
totalContributions: 15,
},
});
});

it('returns 404 when user is not found', async () => {
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found'));
const response = await GET(makeRequest({ username: 'missinguser' }));
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBe('User not found');
});

it('gracefully handles contributions fetch failure and returns profile details', async () => {
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API limit reached'));
const response = await GET(makeRequest({ username: 'testuser' }));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.stats).toEqual({
currentStreak: 0,
longestStreak: 0,
totalContributions: 0,
});
});
});
Loading
Loading