Skip to content

Commit 889f3a2

Browse files
feat(api): add /api/wrapped endpoint to render annual GitHub Wrapped stats (JhaSourav07#1669)
## Description Resolves JhaSourav07#1647. Adds a dedicated `/api/wrapped` API route to CommitPulse, enabling developers to render and embed their annual "GitHub Wrapped" stats directly into their GitHub Profile READMEs as a premium, dynamically updating SVG badge. ### Core Features: - **Comprehensive Validation (`lib/validations.ts`)**: Implements Zod schema mapping to enforce clean formats, founding-year constraints (2008 to current), custom dimensions (`width`/`height`), radius boundaries, and theme inputs. - **24-Hour Endpoint Caching**: Provides a 24-hour default Cache-Control header (`public, s-maxage=86400`) suitable for annual stats, allowing manual cache-busting via `?refresh=true`. - **Syncopate Header Layout**: Integrates uppercase, letter-spaced username and annual Wrapped markers matching the CommitPulse styling architecture. - **4 Key Stat Plaques**: - Total Contributions for the year (with a Gaussian blur neon glow filter). - Top Language (resolved from user repo metadata). - Peak Day (commits count and formatted calendar day, e.g. `Nov 20`). - Busiest Month (e.g. `NOVEMBER `). - **Watermarked Background Monolith**: Integrates a scaled 14-week 3D isometric monolith landscape rendered at very low opacity behind the primary stat, establishing a stunning holographic aesthetic context. - **Weekend Grind Arc**: Integrates a dynamic circular SVG progress indicator measuring the weekend contribution ratio with the percentage styled inside. - **Customization Overrides**: Fully supports all core customizing parameters: `theme`, `bg`, `text`, `accent`, `radius`, `speed`, and `auto` themes. --- ## Pillar - [ ] Pillar 1 — New Theme Design - [ ] Pillar 2 — Geometric SVG Improvement - [ ] Pillar 3 — Timezone Logic Optimization - [x] Other (New Endpoint & SVG Design) --- ## Technical Verification & Quality Gates All checks compile flawlessly and conform strictly to formatting guidelines: - **Unit & Integration Tests**: Added 19 integration tests in `app/api/wrapped/route.test.ts` mapping parameter parsing, custom overriding, cache-headers, security CSP guidelines, and error responses. Running Vitest results in 100% success: ```bash ✓ app/api/wrapped/route.test.ts (19 tests) 68ms Test Files 1 passed (1) Tests 19 passed (19)
1 parent 0587e7f commit 889f3a2

7 files changed

Lines changed: 835 additions & 16 deletions

File tree

app/api/streak/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const SVG_CSP_HEADER =
1919
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
2020

2121
// 1. Define a custom Error class for Validation
22-
export class ValidationError extends Error {
22+
class ValidationError extends Error {
2323
constructor(message: string) {
2424
super(message);
2525
this.name = 'ValidationError';

app/api/wrapped/route.test.ts

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

0 commit comments

Comments
 (0)