Skip to content

Commit 9d50f63

Browse files
committed
Merge branch 'origin/main' into feat/repo-ecosystem
2 parents ab39f30 + 6ede310 commit 9d50f63

37 files changed

Lines changed: 2576 additions & 87 deletions

CONTRIBUTING.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
- [The Standard We Hold](#-the-standard-we-hold)
1111
- [Local Setup](#-local-setup)
12+
- [Testing Your Changes](#-testing-your-changes)
1213
- [What to Contribute](#-what-to-contribute)
1314
- [Automated Issue Management & Claiming](#-automated-issue-management--claiming)
1415
- [Branch & Commit Conventions](#-branch--commit-conventions)
@@ -88,6 +89,110 @@ http://localhost:3000/api/streak?user=YOUR_GITHUB_USERNAME
8889
8990
---
9091

92+
## 🧪 Testing Your Changes
93+
94+
This section covers everything you need to verify your changes work correctly **before opening a PR**. There are two layers of testing: visual browser preview and the automated test suite.
95+
96+
### 1. Visual Browser Preview
97+
98+
With your dev server running (`npm run dev`), open your browser and visit these URLs to preview the SVG output directly:
99+
100+
**Standard badge — valid username:**
101+
102+
```
103+
http://localhost:3000/api/streak?user=YOUR_GITHUB_USERNAME
104+
```
105+
106+
**Monthly view:**
107+
108+
```
109+
http://localhost:3000/api/streak?user=YOUR_GITHUB_USERNAME&view=monthly
110+
```
111+
112+
**Custom theme — test your color changes:**
113+
114+
```
115+
http://localhost:3000/api/streak?user=YOUR_GITHUB_USERNAME&theme=YOUR_THEME_NAME
116+
```
117+
118+
**Invalid username — must render a styled SVG error card, not raw JSON:**
119+
120+
```
121+
http://localhost:3000/api/streak?user=vivek%20Sangani
122+
```
123+
124+
**Non-existent username — must render the ghost-city not-found badge:**
125+
126+
```
127+
http://localhost:3000/api/streak?user=xyzabc999notreallll
128+
```
129+
130+
> **⚠️ Browser XML error warning:** If your browser shows an `EntityRef: expecting ';'` error instead of rendering the SVG, it means an unescaped `&` character exists somewhere in the SVG output. All `&` characters inside SVG `<style>` blocks (e.g. in Google Fonts `@import` URLs) must be written as `&amp;`. Check `lib/svg/generator.ts` for any raw `&` in template literals.
131+
132+
> **💡 Tip:** For a cleaner SVG preview, open the URL in **Firefox** — it renders SVG directly in the browser with no wrapper page. Chrome wraps it in an XML viewer which can show false parse warnings.
133+
134+
### 2. Running the Vitest Test Suite
135+
136+
CommitPulse uses **Vitest** for unit and integration tests. Run the full test suite with:
137+
138+
```bash
139+
npm run test
140+
```
141+
142+
To run tests in watch mode while you develop (reruns on every file save):
143+
144+
```bash
145+
npm run test -- --watch
146+
```
147+
148+
To run only a specific test file:
149+
150+
```bash
151+
npm run test -- lib/calculate.test.ts
152+
```
153+
154+
**What a passing run looks like:**
155+
156+
```
157+
✓ lib/calculate.test.ts (12 tests)
158+
✓ lib/svg/generator.test.ts (8 tests)
159+
✓ app/api/streak/route.test.ts (6 tests)
160+
161+
Test Files 3 passed (3)
162+
Tests 26 passed (26)
163+
```
164+
165+
> **🚨 All tests must pass before you open a PR.** The CI pipeline runs `npm run test` automatically on every pull request and will block merging if any test fails.
166+
167+
### 3. Interpreting SVG Output in the Browser
168+
169+
When you open a badge URL in your browser, here is what each response means:
170+
171+
| What you see | What it means |
172+
| -------------------------------------------- | ---------------------------------------------------------- |
173+
| Animated isometric city renders correctly | ✅ Everything is working |
174+
| Ghost-city badge with "NOT FOUND" label | ✅ Working — the username doesn't exist on GitHub |
175+
| Styled error card with "Invalid username" | ✅ Working — the username format was invalid |
176+
| Raw JSON `{"error":"Invalid parameters"...}` | ❌ Bug — validation errors must return SVG, not JSON |
177+
| Browser XML parse error / blank white page | ❌ Bug — unescaped `&` or malformed SVG in generator |
178+
| `401 Unauthorized` in the terminal | ❌ Your `GITHUB_PAT` in `.env.local` is missing or invalid |
179+
180+
### 4. Lint and Format
181+
182+
Run these before every commit:
183+
184+
```bash
185+
# Auto-format all files
186+
npm run format
187+
188+
# Check for linting errors
189+
npm run lint
190+
```
191+
192+
Fix every error before pushing. CI will fail if either check reports issues.
193+
194+
---
195+
91196
## 🤝 Contributor Onboarding
92197

93198
### 📁 Project Structure

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
@@ -55,6 +55,13 @@ describe('GET /api/stats', () => {
5555
expect(fetchGitHubContributions).not.toHaveBeenCalled();
5656
});
5757

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

app/api/streak/route.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,16 @@ describe('GET /api/streak', () => {
437437
});
438438
});
439439

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

@@ -670,6 +680,17 @@ describe('GET /api/streak', () => {
670680
expect(response.headers.get('Cache-Control')).toBe('no-store');
671681
});
672682

683+
it('sets the SVG Content-Security-Policy header on generic error responses', async () => {
684+
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('Network failure'));
685+
686+
const response = await GET(makeRequest({ user: 'octocat' }));
687+
const csp = response.headers.get('Content-Security-Policy');
688+
689+
expect(response.status).toBe(500);
690+
expect(csp).toContain("default-src 'none'");
691+
expect(csp).not.toContain('script-src');
692+
});
693+
673694
it('returns 429 with no-cache headers and rate limit SVG when rate limited', async () => {
674695
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API Rate Limit Exceeded'));
675696

@@ -1178,4 +1199,18 @@ describe('GET /api/streak', () => {
11781199
expect(response.headers.get('Cache-Control')).not.toContain('stale-while-revalidate=86400');
11791200
});
11801201
});
1202+
1203+
describe('org parameter validation', () => {
1204+
it('returns 400 when org parameter is a User instead of an Organization', async () => {
1205+
vi.mocked(getOrgDashboardData).mockRejectedValueOnce(
1206+
new Error('This endpoint is strictly for organizations.')
1207+
);
1208+
1209+
const response = await GET(makeRequest({ user: 'octocat', org: 'notanorg' }));
1210+
expect(response.status).toBe(400);
1211+
1212+
const body = await response.text();
1213+
expect(body).toContain('strictly for organizations');
1214+
});
1215+
});
11811216
});

app/api/streak/route.ts

Lines changed: 4 additions & 2 deletions
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';
@@ -249,7 +249,8 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
249249
const isValidationError =
250250
(error instanceof Error && error.name === 'ValidationError') ||
251251
message.toLowerCase().includes('invalid') ||
252-
message.toLowerCase().includes('validation');
252+
message.toLowerCase().includes('validation') ||
253+
message.toLowerCase().includes('strictly for organizations');
253254

254255
const errBg = `#${(parseResult.success && parseResult.data.bg) || '0d1117'}`;
255256
const errAccent = `#${
@@ -336,6 +337,7 @@ function buildErrorResponse(error: unknown, parseResult: ParseResult): NextRespo
336337
headers: {
337338
'Content-Type': 'image/svg+xml',
338339
'Cache-Control': 'no-store',
340+
'Content-Security-Policy': SVG_CSP_HEADER,
339341
},
340342
});
341343
}

0 commit comments

Comments
 (0)