Skip to content

Commit 5755d0c

Browse files
committed
fix: resolve merge conflict in lib/github.ts and fix typecheck/eslint errors
2 parents a5af1be + 6ede310 commit 5755d0c

34 files changed

Lines changed: 2284 additions & 68 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ URL Parameter > Theme Default > System Fallback
212212
| `dark` _(default)_ | GitHub dark | `0d1117` | `58a6ff` | `c9d1d9` |
213213
| `neon` | Cyberpunk | `000000` | `ff00ff` | `00ffcc` |
214214
| `dracula` | Dracula Pro | `282a36` | `bd93f9` | `f8f8f2` |
215-
| `github` | GitHub green | `0d1117` | `238636` | `ffffff` |
215+
| `github` | GitHub green | `0d1117` | `39d353` | `ffffff` |
216216
| `light` | Clean & minimal | `ffffff` | `0969da` | `24292f` |
217217
| `gruvbox` | retro warm dark | `282828` | `fe8019` | `ebdbb2` |
218218
| `random` | Surprise theme on reload | _varies_ | _varies_ | _varies_ |

THEMES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ https://commitpulse.vercel.app/api/badge?username=YOUR_USERNAME&theme=<slug>
1616
| dark | `#0d1117` | `#c9d1d9` | `#58a6ff` |
1717
| light | `#ffffff` | `#24292f` | `#0969da` |
1818
| neon | `#000000` | `#00ffcc` | `#ff00ff` |
19-
| github | `#0d1117` | `#ffffff` | `#238636` |
19+
| github | `#0d1117` | `#ffffff` | `#39d353` |
2020
| dracula | `#282a36` | `#f8f8f2` | `#bd93f9` |
2121
| ocean | `#0a192f` | `#ccd6f6` | `#64ffda` |
2222
| sunset | `#1a0a0a` | `#ffd6c0` | `#ff6b35` |

app/api/github/route.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ describe('GET /api/github', () => {
2929
expect(body.error).toContain('Invalid parameters');
3030
});
3131

32+
it('returns 400 and skips GitHub when username format is invalid', async () => {
33+
const response = await GET(makeRequest({ username: 'bad user' }));
34+
const body = await response.json();
35+
36+
expect(response.status).toBe(400);
37+
expect(body.error).toContain('Invalid parameters');
38+
expect(getFullDashboardData).not.toHaveBeenCalled();
39+
});
40+
3241
// Test 2 — valid username → 200
3342
it('returns 200 with JSON body for a valid username', async () => {
3443
vi.mocked(getFullDashboardData).mockResolvedValue({ profile: 'octocat' } as never);

app/api/stats/route.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ describe('GET /api/stats', () => {
5858
expect(fetchGitHubContributions).not.toHaveBeenCalled();
5959
});
6060

61+
it('returns 400 and skips GitHub when the username format is invalid', async () => {
62+
const response = await GET(makeRequest({ user: 'octo/cat' }));
63+
64+
expect(response.status).toBe(400);
65+
expect(fetchGitHubContributions).not.toHaveBeenCalled();
66+
});
67+
6168
it('returns 400 for an unknown timezone', async () => {
6269
const response = await GET(makeRequest({ user: 'testuser', tz: 'Not/ATimezone' }));
6370
expect(response.status).toBe(400);

app/api/streak/route.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,16 @@ describe('GET /api/streak', () => {
440440
});
441441
});
442442

443+
it('passes correct from/to range when ?year=2008 is provided', async () => {
444+
await GET(makeRequest({ user: 'octocat', year: '2008' }));
445+
446+
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', {
447+
bypassCache: false,
448+
from: '2008-01-01T00:00:00Z',
449+
to: '2008-12-31T23:59:59Z',
450+
});
451+
});
452+
443453
it('functions normally when the year parameter is missing', async () => {
444454
const response = await GET(makeRequest({ user: 'octocat' }));
445455

@@ -1204,4 +1214,18 @@ describe('GET /api/streak', () => {
12041214
expect(response.headers.get('Cache-Control')).not.toContain('stale-while-revalidate=86400');
12051215
});
12061216
});
1217+
1218+
describe('org parameter validation', () => {
1219+
it('returns 400 when org parameter is a User instead of an Organization', async () => {
1220+
vi.mocked(getOrgDashboardData).mockRejectedValueOnce(
1221+
new Error('This endpoint is strictly for organizations.')
1222+
);
1223+
1224+
const response = await GET(makeRequest({ user: 'octocat', org: 'notanorg' }));
1225+
expect(response.status).toBe(400);
1226+
1227+
const body = await response.text();
1228+
expect(body).toContain('strictly for organizations');
1229+
});
1230+
});
12071231
});

app/api/streak/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,8 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
251251
const isValidationError =
252252
(error instanceof Error && error.name === 'ValidationError') ||
253253
message.toLowerCase().includes('invalid') ||
254-
message.toLowerCase().includes('validation');
254+
message.toLowerCase().includes('validation') ||
255+
message.toLowerCase().includes('strictly for organizations');
255256

256257
const errBg = `#${(parseResult.success && parseResult.data.bg) || '0d1117'}`;
257258
const errAccent = `#${

app/api/wrapped/route.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('../../../lib/github', () => ({
5+
getWrappedData: vi.fn(),
6+
fetchGitHubContributions: vi.fn(),
7+
}));
8+
9+
import { getWrappedData, fetchGitHubContributions } from '../../../lib/github';
10+
import type { ContributionCalendar } from '../../../types';
11+
import type { WrappedStats } from '../../../types/dashboard';
12+
13+
const mockCalendar: ContributionCalendar = {
14+
totalContributions: 1420,
15+
weeks: [
16+
{
17+
contributionDays: [
18+
{ contributionCount: 5, date: '2025-11-19' },
19+
{ contributionCount: 42, date: '2025-11-20' },
20+
{ contributionCount: 12, date: '2025-11-21' },
21+
],
22+
},
23+
],
24+
};
25+
26+
const mockWrappedStats: WrappedStats = {
27+
totalContributions: 1420,
28+
mostActiveDate: '2025-11-20',
29+
highestDailyCount: 42,
30+
busiestMonth: '2025-11',
31+
weekendRatio: 24,
32+
topLanguage: 'TypeScript',
33+
};
34+
35+
function makeRequest(params: Record<string, string> = {}): Request {
36+
const url = new URL('http://localhost/api/wrapped');
37+
for (const [key, value] of Object.entries(params)) {
38+
url.searchParams.set(key, value);
39+
}
40+
return new Request(url.toString());
41+
}
42+
43+
describe('GET /api/wrapped', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks();
46+
vi.mocked(getWrappedData).mockResolvedValue(mockWrappedStats);
47+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
48+
calendar: mockCalendar,
49+
} as unknown as import('../../../types').ExtendedContributionData);
50+
});
51+
52+
describe('parameter validation', () => {
53+
it('returns 400 when the user parameter is missing', async () => {
54+
const response = await GET(makeRequest());
55+
expect(response.status).toBe(400);
56+
const body = await response.json();
57+
expect(body.error).toBe('Invalid parameters');
58+
});
59+
60+
it('does not hit the GitHub API at all when user is missing', async () => {
61+
await GET(makeRequest());
62+
expect(getWrappedData).not.toHaveBeenCalled();
63+
});
64+
65+
it('returns 400 for malformed GitHub usernames', async () => {
66+
const invalidUsers = ['http://localhost', 'harendra-', 'a--b', 'a'.repeat(40)];
67+
for (const user of invalidUsers) {
68+
const response = await GET(makeRequest({ user }));
69+
expect(response.status).toBe(400);
70+
}
71+
expect(getWrappedData).not.toHaveBeenCalled();
72+
});
73+
74+
it('returns 400 for invalid year format', async () => {
75+
const response = await GET(makeRequest({ user: 'octocat', year: 'abcd' }));
76+
expect(response.status).toBe(400);
77+
});
78+
79+
it('returns 400 for years before GitHub existed', async () => {
80+
const response = await GET(makeRequest({ user: 'octocat', year: '2007' }));
81+
expect(response.status).toBe(400);
82+
});
83+
84+
it('returns 400 for future years', async () => {
85+
const futureYear = (new Date().getFullYear() + 1).toString();
86+
const response = await GET(makeRequest({ user: 'octocat', year: futureYear }));
87+
expect(response.status).toBe(400);
88+
});
89+
});
90+
91+
describe('successful response', () => {
92+
it('returns 200 with SVG content type', async () => {
93+
const response = await GET(makeRequest({ user: 'octocat' }));
94+
expect(response.status).toBe(200);
95+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
96+
});
97+
98+
it('returns a well-formed SVG body representing Wrapped stats', async () => {
99+
const response = await GET(makeRequest({ user: 'octocat', year: '2025' }));
100+
const body = await response.text();
101+
102+
expect(body).toContain('<svg');
103+
expect(body).toContain('OCTOCAT');
104+
expect(body).toContain('2025 WRAPPED');
105+
expect(body).toContain('1420'); // Total contributions
106+
expect(body).toContain('TypeScript'); // Top language
107+
expect(body).toContain('42 COMMITS'); // Peak day
108+
expect(body).toContain('NOVEMBER'); // Busiest month
109+
expect(body).toContain('24%'); // Weekend ratio text
110+
expect(body).toContain('</svg>');
111+
});
112+
113+
it('customizes the theme colors when theme parameter is neon', async () => {
114+
const response = await GET(makeRequest({ user: 'octocat', theme: 'neon' }));
115+
const body = await response.text();
116+
expect(response.status).toBe(200);
117+
expect(body).toContain('#ff00ff'); // Neon accent color
118+
});
119+
120+
it('embeds custom background, accent, text overrides when provided', async () => {
121+
const response = await GET(
122+
makeRequest({ user: 'octocat', bg: 'ff0000', accent: '00ff00', text: '0000ff' })
123+
);
124+
const body = await response.text();
125+
expect(response.status).toBe(200);
126+
expect(body).toContain('#ff0000');
127+
expect(body).toContain('#00ff00');
128+
expect(body).toContain('#0000ff');
129+
});
130+
131+
it('supports autoTheme and embeds media queries for color schemes', async () => {
132+
const response = await GET(makeRequest({ user: 'octocat', theme: 'auto' }));
133+
const body = await response.text();
134+
expect(response.status).toBe(200);
135+
expect(body).toContain('prefers-color-scheme: dark');
136+
expect(body).toContain('--cp-bg');
137+
});
138+
139+
it('customizes dimensions when width and height parameters are passed', async () => {
140+
const response = await GET(makeRequest({ user: 'octocat', width: '500', height: '300' }));
141+
const body = await response.text();
142+
expect(response.status).toBe(200);
143+
expect(body).toContain('width="500"');
144+
expect(body).toContain('height="300"');
145+
});
146+
147+
it('customizes border corner radius when radius parameter is passed', async () => {
148+
const response = await GET(makeRequest({ user: 'octocat', radius: '15' }));
149+
const body = await response.text();
150+
expect(response.status).toBe(200);
151+
expect(body).toContain('rx="15"');
152+
});
153+
});
154+
155+
describe('cache-control header', () => {
156+
it('caches for 24 hours by default', async () => {
157+
const response = await GET(makeRequest({ user: 'octocat' }));
158+
expect(response.headers.get('Cache-Control')).toBe(
159+
'public, s-maxage=86400, stale-while-revalidate=86400'
160+
);
161+
});
162+
163+
it('bypasses the cache entirely when refresh=true is specified', async () => {
164+
const response = await GET(makeRequest({ user: 'octocat', refresh: 'true' }));
165+
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
166+
expect(getWrappedData).toHaveBeenCalledWith('octocat', expect.any(String), {
167+
bypassCache: true,
168+
});
169+
});
170+
});
171+
172+
describe('security headers', () => {
173+
it('sets a strict Content-Security-Policy with safe SVG styling rules', async () => {
174+
const response = await GET(makeRequest({ user: 'octocat' }));
175+
const csp = response.headers.get('Content-Security-Policy');
176+
177+
expect(csp).toContain("default-src 'none'");
178+
expect(csp).toContain("style-src 'unsafe-inline'");
179+
expect(csp).toContain('https://fonts.googleapis.com');
180+
});
181+
});
182+
183+
describe('error handling', () => {
184+
it('returns 500 with SVG error structure when fetch throws', async () => {
185+
vi.mocked(getWrappedData).mockRejectedValue(new Error('GitHub is down'));
186+
const response = await GET(makeRequest({ user: 'octocat' }));
187+
expect(response.status).toBe(500);
188+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
189+
const body = await response.text();
190+
expect(body).toContain('Something went wrong. Please try again later.');
191+
});
192+
193+
it('returns 404 with SVG error structure when user is not found', async () => {
194+
vi.mocked(getWrappedData).mockRejectedValue(new Error('User not found'));
195+
const response = await GET(makeRequest({ user: 'not-real-user' }));
196+
expect(response.status).toBe(404);
197+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
198+
const body = await response.text();
199+
expect(body).toContain('NOT FOUND');
200+
});
201+
202+
it('returns 429 with SVG rate limit card when rate limited', async () => {
203+
vi.mocked(getWrappedData).mockRejectedValue(new Error('API Rate Limit Exceeded'));
204+
const response = await GET(makeRequest({ user: 'octocat' }));
205+
expect(response.status).toBe(429);
206+
const body = await response.text();
207+
expect(body).toContain('RATE LIMITED');
208+
});
209+
});
210+
});

0 commit comments

Comments
 (0)