Skip to content

Commit 69e3c83

Browse files
committed
Merge remote-tracking branch 'upstream/main' into fix/issue-1489
# Conflicts: # lib/calculate.test.ts
2 parents 2a58ebd + 0e7609b commit 69e3c83

8 files changed

Lines changed: 207 additions & 17 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,54 @@ describe('GET /api/streak', () => {
291291
});
292292
});
293293

294+
describe('edge cases for empty/private profiles', () => {
295+
it('Scenario 1: Normal active GitHub user', async () => {
296+
const response = await GET(makeRequest({ user: 'octocat' }));
297+
expect(response.status).toBe(200);
298+
const body = await response.text();
299+
expect(body).toContain('<svg');
300+
});
301+
302+
it('Scenario 2 & 3: User with 0 public repositories or private profile (empty calendar)', async () => {
303+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
304+
calendar: {
305+
totalContributions: 0,
306+
weeks: [],
307+
},
308+
repoContributions: [],
309+
} as unknown as ExtendedContributionData);
310+
311+
const response = await GET(makeRequest({ user: 'private-user' }));
312+
expect(response.status).toBe(200);
313+
const body = await response.text();
314+
expect(body).toContain('<svg');
315+
// Should show 0 contributions and streaks
316+
expect(body).toContain('>0<');
317+
});
318+
319+
it('Scenario 4: Nonexistent username', async () => {
320+
vi.mocked(fetchGitHubContributions).mockRejectedValue(
321+
new Error('GitHub user "nonexistent" not found')
322+
);
323+
324+
const response = await GET(makeRequest({ user: 'nonexistent' }));
325+
expect(response.status).toBe(404);
326+
const body = await response.text();
327+
expect(body).toContain('<svg');
328+
expect(body).toContain('NOT FOUND');
329+
});
330+
331+
it('Scenario 5: GitHub API failure', async () => {
332+
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API Rate Limit Exceeded'));
333+
334+
const response = await GET(makeRequest({ user: 'octocat' }));
335+
expect(response.status).toBe(429);
336+
const body = await response.text();
337+
expect(body).toContain('<svg');
338+
expect(body).toContain('API RATE LIMIT');
339+
});
340+
});
341+
294342
describe('cache-control header', () => {
295343
it('caches until UTC midnight by default, using the value from getSecondsUntilUTCMidnight', async () => {
296344
const response = await GET(makeRequest({ user: 'octocat' }));
@@ -826,6 +874,20 @@ describe('GET /api/streak', () => {
826874

827875
expect(getSecondsUntilMidnightInTimezone).toHaveBeenCalledWith('Australia/Sydney');
828876
});
877+
878+
// =========================================================================
879+
// ISSUE OBJECTIVE: Reject fictitious planetary timezone (Variation 4)
880+
// =========================================================================
881+
it('returns 400 when a fictitious planetary timezone Mars/Cyonia is supplied', async () => {
882+
// Mars/Cyonia is structurally plausible (Region/City format) but does not
883+
// exist in the IANA tz database — the schema must reject it before the
884+
// request reaches the GitHub API.
885+
const response = await GET(makeRequest({ user: 'octocat', tz: 'Mars/Cyonia' }));
886+
887+
expect(response.status).toBe(400);
888+
const body = await response.json();
889+
expect(body.details.fieldErrors.tz[0]).toContain('Invalid timezone');
890+
});
829891
});
830892

831893
describe('hide_background parameter', () => {

app/components/Footer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export function Footer() {
4040
</div>
4141

4242
{/* BOTTOM */}
43-
<div className="mt-6 border-t border-black/5 pt-3 text-center text-xs text-zinc-400 dark:border-white/5 dark:text-white/60">
44-
© 2026 CommitPulse. All rights reserved.
43+
<div className="mt-6 border-t border-black/5 pt-3 text-center text-xs text-zinc-500 dark:border-white/5 dark:text-zinc-500">
44+
© {new Date().getFullYear()} CommitPulse. All rights reserved.
4545
</div>
4646
</footer>
4747
);

lib/calculate.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,35 @@ describe('calculateStreak', () => {
489489
expect(resultTuesday.longestStreak).toBe(2);
490490
});
491491

492+
it('verify streak formulas for different starting days of the week timeline (Variation 2)', () => {
493+
// Week 1: 0, 0, 1, 1, 1, 1, 1 (Starts on Wednesday, 5 days)
494+
// Week 2: 1, 1, 1, 1, 1, 1, 1 (7 days)
495+
// Week 3: 1, 1, 1 // Ends on Wednesday (3 days)
496+
// Total continuous streak = 15 days, ending on the last day.
497+
const calendar = buildCalendar([
498+
0,
499+
0,
500+
1,
501+
1,
502+
1,
503+
1,
504+
1, // Week 1 (Starts Wed)
505+
1,
506+
1,
507+
1,
508+
1,
509+
1,
510+
1,
511+
1, // Week 2
512+
1,
513+
1,
514+
1, // Week 3 (Ends Wed)
515+
]);
516+
const result = calculateStreak(calendar);
517+
expect(result.currentStreak).toBe(15);
518+
expect(result.longestStreak).toBe(15);
519+
});
520+
492521
it('verify streak formulas for multiple weeks gaps timeline (Variation 3)', () => {
493522
// Streak 1: 5 days
494523
// Gap 1: 14 days (2 weeks of zeros)

lib/calculate.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export function calculateStreak(
2828
now: Date = new Date(),
2929
grace: number = 1
3030
): StreakStats {
31-
const weeks = calendar.weeks;
32-
const days = weeks.flatMap((week) => week.contributionDays);
31+
const weeks = calendar?.weeks || [];
32+
const days = weeks.flatMap((week) => week?.contributionDays || []);
3333

3434
let currentStreak = 0;
3535
let longestStreak = 0;
@@ -95,7 +95,8 @@ export function calculateMonthlyStats(
9595
timezone: string = 'UTC',
9696
now: Date = new Date()
9797
): MonthlyStats {
98-
const days = calendar.weeks.flatMap((week) => week.contributionDays);
98+
const weeks = calendar?.weeks || [];
99+
const days = weeks.flatMap((week) => week?.contributionDays || []);
99100

100101
const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
101102
const [currentYearStr, currentMonthStr] = localTodayStr.split('-');
@@ -171,13 +172,13 @@ export function aggregateCalendars(calendars: ContributionCalendar[]): Contribut
171172
// Find the calendar with the most weeks to serve as our structural base
172173
let baseCalendar = calendars[0];
173174
for (const cal of calendars) {
174-
if (cal.weeks.length > baseCalendar.weeks.length) {
175+
if ((cal.weeks?.length || 0) > (baseCalendar.weeks?.length || 0)) {
175176
baseCalendar = cal;
176177
}
177178

178179
// Populate the Map with all contributions from all calendars
179-
cal.weeks.forEach((week) => {
180-
week.contributionDays.forEach((day) => {
180+
(cal.weeks || []).forEach((week) => {
181+
(week?.contributionDays || []).forEach((day) => {
181182
const currentCount = dateMap.get(day.date) || 0;
182183
dateMap.set(day.date, currentCount + day.contributionCount);
183184
});
@@ -190,8 +191,8 @@ export function aggregateCalendars(calendars: ContributionCalendar[]): Contribut
190191
aggregatedBase.totalContributions = totalContributions;
191192

192193
// Re-map the structural base using our aggregated date map
193-
aggregatedBase.weeks.forEach((week) => {
194-
week.contributionDays.forEach((day) => {
194+
(aggregatedBase.weeks || []).forEach((week) => {
195+
(week?.contributionDays || []).forEach((day) => {
195196
day.contributionCount = dateMap.get(day.date) || 0;
196197
});
197198
});
@@ -202,7 +203,8 @@ export function aggregateCalendars(calendars: ContributionCalendar[]): Contribut
202203
* Processes a calendar to generate deep insights for "GitHub Wrapped"
203204
*/
204205
export function calculateWrappedStats(calendar: ContributionCalendar) {
205-
const days = calendar.weeks.flatMap((w) => w.contributionDays);
206+
const weeks = calendar?.weeks || [];
207+
const days = weeks.flatMap((w) => w?.contributionDays || []);
206208

207209
let mostActiveDay = { date: '', count: 0 };
208210
const monthCounts: Record<string, number> = {};

lib/github.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,16 @@ export async function fetchGitHubContributions(
469469
throw new Error(`GitHub user "${username}" not found`);
470470
}
471471

472-
let calendar = data.data.user.contributionsCollection.contributionCalendar;
472+
let calendar = data.data.user.contributionsCollection?.contributionCalendar;
473+
const repoContributions =
474+
data.data.user.contributionsCollection?.commitContributionsByRepository || [];
475+
476+
if (!calendar || !calendar.weeks) {
477+
calendar = {
478+
totalContributions: 0,
479+
weeks: [],
480+
};
481+
}
473482

474483
if (isDeltaSync && cached) {
475484
calendar = mergeCalendars(cached.calendar, calendar);
@@ -514,14 +523,14 @@ export async function fetchGitHubContributions(
514523
key,
515524
{
516525
calendar,
517-
repoContributions: data.data.user.contributionsCollection.commitContributionsByRepository,
526+
repoContributions,
518527
},
519528
LONG_CACHE_TTL
520529
);
521530
}
522531
return {
523532
calendar,
524-
repoContributions: data.data.user.contributionsCollection.commitContributionsByRepository,
533+
repoContributions,
525534
};
526535
};
527536

lib/mongodb.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import mongoose from 'mongoose';
33
import dbConnect from './mongodb';
44

5+
const { mockMongooseConnection } = vi.hoisted(() => ({
6+
mockMongooseConnection: {
7+
readyState: 0,
8+
},
9+
}));
10+
511
vi.mock('mongoose', () => ({
612
default: {
713
connect: vi.fn(),
14+
disconnect: vi.fn(),
15+
connection: mockMongooseConnection,
816
},
917
}));
1018

19+
const setConnectedMongoose = (resolvedValue: typeof mongoose) => {
20+
vi.mocked(mongoose.connect).mockImplementation(async () => {
21+
mockMongooseConnection.readyState = 1;
22+
return resolvedValue;
23+
});
24+
};
25+
1126
describe('dbConnect', () => {
1227
beforeEach(() => {
1328
vi.clearAllMocks();
@@ -17,10 +32,15 @@ describe('dbConnect', () => {
1732
global.mongoose.conn = null;
1833
global.mongoose.promise = null;
1934
}
35+
36+
delete process.env.NEXT_RUNTIME;
37+
delete process.env.MONGODB_URI;
38+
mockMongooseConnection.readyState = 0;
2039
});
2140

2241
afterEach(() => {
2342
delete process.env.MONGODB_URI;
43+
delete process.env.NEXT_RUNTIME;
2444
});
2545

2646
it('throws an error if MONGODB_URI is not defined', async () => {
@@ -34,12 +54,16 @@ describe('dbConnect', () => {
3454
it('connects to mongoose and caches the connection', async () => {
3555
process.env.MONGODB_URI = 'mongodb://localhost:27017/test';
3656
const mockMongoose = { connection: 'mock' };
37-
vi.mocked(mongoose.connect).mockResolvedValue(mockMongoose as unknown as typeof mongoose);
57+
setConnectedMongoose(mockMongoose as unknown as typeof mongoose);
3858

3959
const conn1 = await dbConnect();
4060
expect(mongoose.connect).toHaveBeenCalledTimes(1);
4161
expect(mongoose.connect).toHaveBeenCalledWith('mongodb://localhost:27017/test', {
4262
bufferCommands: false,
63+
maxPoolSize: 10,
64+
minPoolSize: 0,
65+
maxIdleTimeMS: 30000,
66+
serverSelectionTimeoutMS: 5000,
4367
});
4468
expect(conn1).toBe(mockMongoose);
4569

@@ -64,12 +88,16 @@ describe('dbConnect', () => {
6488
process.env.MONGODB_URI = specificUri;
6589

6690
const mockMongoose = { connection: 'mock' };
67-
vi.mocked(mongoose.connect).mockResolvedValue(mockMongoose as unknown as typeof mongoose);
91+
setConnectedMongoose(mockMongoose as unknown as typeof mongoose);
6892

6993
await dbConnect();
7094

7195
expect(mongoose.connect).toHaveBeenCalledWith(specificUri, {
7296
bufferCommands: false,
97+
maxPoolSize: 10,
98+
minPoolSize: 0,
99+
maxIdleTimeMS: 30000,
100+
serverSelectionTimeoutMS: 5000,
73101
});
74102
});
75103

@@ -85,4 +113,29 @@ describe('dbConnect', () => {
85113
// The promise should be cleared so it can try again
86114
expect(global.mongoose.promise).toBeNull();
87115
});
116+
117+
it('throws when called from the Edge runtime', async () => {
118+
vi.stubEnv('NEXT_RUNTIME', 'edge');
119+
process.env.MONGODB_URI = 'mongodb://localhost:27017/test';
120+
121+
await expect(dbConnect()).rejects.toThrow(
122+
'MongoDB is not supported in the Edge runtime. Use the Node.js runtime.'
123+
);
124+
125+
expect(mongoose.connect).not.toHaveBeenCalled();
126+
});
127+
128+
it('clears a stale cached connection before reconnecting', async () => {
129+
process.env.MONGODB_URI = 'mongodb://localhost:27017/test';
130+
global.mongoose.conn = {} as typeof mongoose;
131+
132+
const mockMongoose = { connection: 'mock' };
133+
setConnectedMongoose(mockMongoose as unknown as typeof mongoose);
134+
135+
const conn = await dbConnect();
136+
137+
expect(mongoose.connect).toHaveBeenCalledTimes(1);
138+
expect(global.mongoose.conn).toBe(mockMongoose);
139+
expect(conn).toBe(mockMongoose);
140+
});
88141
});

lib/mongodb.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import mongoose from 'mongoose';
22

33
declare global {
4+
// Cached across hot reloads and repeated serverless invocations in the same process.
45
var mongoose: {
56
conn: typeof import('mongoose') | null;
67
promise: Promise<typeof import('mongoose')> | null;
@@ -14,10 +15,19 @@ if (!cached) {
1415
}
1516

1617
async function dbConnect() {
17-
if (cached.conn) {
18+
if (process.env.NEXT_RUNTIME === 'edge') {
19+
throw new Error('MongoDB is not supported in the Edge runtime. Use the Node.js runtime.');
20+
}
21+
22+
if (cached.conn && mongoose.connection.readyState === 1) {
1823
return cached.conn;
1924
}
2025

26+
if (cached.conn && mongoose.connection.readyState !== 1) {
27+
cached.conn = null;
28+
cached.promise = null;
29+
}
30+
2131
if (!cached.promise) {
2232
const MONGODB_URI = process.env.MONGODB_URI;
2333

@@ -27,6 +37,10 @@ async function dbConnect() {
2737

2838
const opts = {
2939
bufferCommands: false,
40+
maxPoolSize: 10,
41+
minPoolSize: 0,
42+
maxIdleTimeMS: 30000,
43+
serverSelectionTimeoutMS: 5000,
3044
};
3145

3246
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {

lib/validations.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,3 +756,24 @@ describe('streakParamsSchema — Date Range Boundary Robustness (Variation 1)',
756756
}
757757
});
758758
});
759+
760+
/* ==========================================================================
761+
* TZ PARAMETER — IANA TIMEZONE VALIDATION (VARIATION 4)
762+
* ========================================================================== */
763+
764+
describe('streakParamsSchema — tz IANA timezone validation (Variation 4)', () => {
765+
it('rejects a fictitious planetary timezone that is not a valid IANA zone', () => {
766+
// Mars/Cyonia looks structurally plausible (Region/City format) but does not
767+
// exist in the IANA tz database, so Intl.DateTimeFormat must throw and the
768+
// schema must surface a field-level validation error.
769+
const result = streakParamsSchema.safeParse({
770+
user: 'octocat',
771+
tz: 'Mars/Cyonia',
772+
});
773+
774+
expect(result.success).toBe(false);
775+
if (!result.success) {
776+
expect(result.error.issues[0]?.message).toContain('Invalid timezone');
777+
}
778+
});
779+
});

0 commit comments

Comments
 (0)