Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
71 changes: 34 additions & 37 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1391,53 +1391,50 @@ describe('GET /api/streak', () => {
});
});

describe('JSON output mode (format=json)', () => {
it('returns JSON with correct Content-Type when format=json is set', async () => {
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
describe('multi-user skyline merges', () => {
it('fetches calendars concurrently, aggregates them, and overrides the title in the SVG', async () => {
vi.mocked(fetchGitHubContributions)
.mockResolvedValueOnce({
calendar: mockCalendar,
repoContributions: [],
} as unknown as ExtendedContributionData)
.mockResolvedValueOnce({
calendar: mockCalendar,
repoContributions: [],
} as unknown as ExtendedContributionData);

const response = await GET(makeRequest({ user: 'a, b' }));
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toContain('application/json');
});

it('returns stats, monthlyStats, and calendar in JSON response', async () => {
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
const data = await response.json();
expect(fetchGitHubContributions).toHaveBeenCalledWith('a', expect.any(Object));
expect(fetchGitHubContributions).toHaveBeenCalledWith('b', expect.any(Object));

expect(data.user).toBe('octocat');
expect(data.stats).toBeDefined();
expect(data.stats.currentStreak).toBeDefined();
expect(data.stats.longestStreak).toBeDefined();
expect(data.stats.totalContributions).toBeDefined();
expect(data.monthlyStats).toBeDefined();
expect(data.monthlyStats.currentMonthTotal).toBeDefined();
expect(data.calendar).toBeDefined();
expect(data.calendar.totalContributions).toBe(10);
expect(data.calendar.weeks).toHaveLength(2);
const body = await response.text();
expect(body).toContain('A + B');
});

it('includes Cache-Control header in JSON response', async () => {
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
expect(response.headers.get('Cache-Control')).toContain('s-maxage=');
});
it('gracefully handles partial fetch failures by filtering out failed calendars', async () => {
vi.mocked(fetchGitHubContributions)
.mockResolvedValueOnce({
calendar: mockCalendar,
repoContributions: [],
} as unknown as ExtendedContributionData)
.mockRejectedValueOnce(new Error('GitHub user "b" not found'));

it('includes X-Cache-Status header in JSON response', async () => {
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
});
const response = await GET(makeRequest({ user: 'a, b' }));
expect(response.status).toBe(200);

it('returns SVG when format is not set (default)', async () => {
const response = await GET(makeRequest({ user: 'octocat' }));
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
const body = await response.text();
expect(body).toContain('A + B');
});

it('falls back to SVG for invalid format values', async () => {
const response = await GET(makeRequest({ user: 'octocat', format: 'xml' }));
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
});
it('returns a 404/error response when all users in the list fail to load', async () => {
vi.mocked(fetchGitHubContributions)
.mockRejectedValueOnce(new Error('GitHub user "a" not found'))
.mockRejectedValueOnce(new Error('GitHub user "b" not found'));

it('uses org name as user field when org parameter is provided', async () => {
const response = await GET(makeRequest({ user: 'octocat', org: 'github', format: 'json' }));
const data = await response.json();
expect(data.user).toBe('github');
const response = await GET(makeRequest({ user: 'a, b' }));
expect(response.status).toBe(404);
});
});
});
42 changes: 39 additions & 3 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { NextResponse } from 'next/server';
import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github';
import { calculateStreak, calculateMonthlyStats } from '@/lib/calculate';
import { calculateStreak, calculateMonthlyStats, aggregateCalendars } from '@/lib/calculate';
import {
generateNotFoundSVG,
generateRateLimitSVG,
Expand All @@ -13,7 +13,7 @@ import {
generatePulseSVG,
} from '@/lib/svg/generator';
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
import type { BadgeParams } from '@/types';
import type { BadgeParams, ContributionCalendar } from '@/types';
import { themes } from '@/lib/svg/themes';
import { streakParamsSchema } from '@/lib/validations';
import { sanitizeHexColor } from '@/lib/svg/sanitizer';
Expand Down Expand Up @@ -132,7 +132,15 @@ export async function GET(request: Request) {
})();

// If 'org' is provided, we use it as the display user
const targetEntity = org || user;
const targetEntity =
org ||
(user.includes(',')
? user
.split(',')
.map((u) => u.trim())
.slice(0, 2)
.join(' + ')
: user);
const borderParam = searchParams.get('border');
const sanitizedBorder = borderParam ? borderParam.replace(/[^a-fA-F0-9]/g, '') : undefined;
const animate = searchParams.get('animate') !== 'false';
Expand Down Expand Up @@ -182,6 +190,34 @@ export async function GET(request: Request) {
to,
});
calendar = orgData.calendar;
} else if (user.includes(',')) {
const users = user
.split(',')
.map((u) => u.trim())
.filter(Boolean);
let lastError: unknown = null;
const fetchedCalendars = await Promise.all(
users.map(async (u) => {
try {
const userData = await fetchGitHubContributions(u, {
bypassCache: refresh,
from,
to,
});
return userData.calendar;
} catch (err) {
lastError = err;
return null;
}
})
);
const successfulCalendars = fetchedCalendars.filter(
(c): c is ContributionCalendar => c !== null
);
if (successfulCalendars.length === 0) {
throw lastError || new Error('No successful calendars fetched');
}
calendar = aggregateCalendars(successfulCalendars);
} else {
const userData = await fetchGitHubContributions(user, {
bypassCache: refresh,
Expand Down
37 changes: 33 additions & 4 deletions lib/validations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,44 @@ describe('streakParamsSchema user validation', () => {
expect(result.success).toBe(true);
});

it('should enforce a maximum length constraint of 39 characters for the user parameter', () => {
const invalidUser = 'a'.repeat(40);
it('should pass when user is a comma-separated list of valid usernames', () => {
const result = streakParamsSchema.safeParse({
user: invalidUser,
user: 'octocat, JhaSourav07, nishtha-agarwal-211',
});

expect(result.success).toBe(true);
});

it('should fail when one of the usernames in the list is invalid', () => {
const result = streakParamsSchema.safeParse({
user: 'octocat, invalid_name_with_spaces',
});

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toMatch(/cannot exceed 39 characters/);
expect(result.error.issues[0]?.message).toBe('Invalid GitHub username');
}
});

it('should fail when one of the usernames in the list exceeds 39 characters', () => {
const result = streakParamsSchema.safeParse({
user: `octocat, ${'a'.repeat(40)}`,
});

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe('GitHub username cannot exceed 39 characters');
}
});

it('should fail when list has empty usernames due to consecutive or trailing commas', () => {
const result = streakParamsSchema.safeParse({
user: 'octocat, , JhaSourav07',
});

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe('Invalid GitHub username');
}
});
});
Expand Down
35 changes: 32 additions & 3 deletions lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,38 @@ const baseStreakParamsSchema = z.object({
user: z
.string({ error: 'Missing user parameter' })
.min(1, { message: 'Missing user parameter' })
.max(39, { message: 'GitHub username cannot exceed 39 characters' })
.regex(GITHUB_USERNAME_REGEX, {
message: 'Invalid GitHub username',
.superRefine((val, ctx) => {
const users = val.split(',').map((u) => u.trim());
if (users.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Missing user parameter',
});
return;
}
for (const u of users) {
if (u.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid GitHub username',
});
return;
}
if (u.length > 39) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'GitHub username cannot exceed 39 characters',
});
return;
}
if (!GITHUB_USERNAME_REGEX.test(u)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid GitHub username',
});
return;
}
}
}),

theme: z
Expand Down
Loading