Skip to content

Commit 74dbfab

Browse files
authored
Merge branch 'main' into test/verify-cache-for-empty-string-keys-2
2 parents ece13f0 + 941a172 commit 74dbfab

9 files changed

Lines changed: 202 additions & 40 deletions

File tree

app/api/notify/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { NextRequest } from 'next/server';
33
import { GET, POST } from './route';
4+
import dbConnect from '@/lib/mongodb';
45

56
// Mock dependencies
67
vi.mock('@/lib/mongodb', () => ({ default: vi.fn() }));
@@ -88,6 +89,7 @@ describe('POST /api/notify', () => {
8889
expect(res.status).toBe(400);
8990
const data = await res.json();
9091
expect(data.message).toContain('Malformed JSON');
92+
expect(dbConnect).not.toHaveBeenCalled();
9193
});
9294

9395
// ── Rate limiting ────────────────────────────────────────────────────────

app/api/track-user/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import { User } from '@/models/User';
44
import { trackUserRateLimiter } from '@/lib/rate-limit';
55

66
export async function POST(req: Request) {
7-
// Get IP for rate limiting
8-
const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';
7+
// Get IP for rate limiting.
8+
// x-real-ip is provided by Vercel/Nginx as the true client IP.
9+
// We fall back to the LAST IP in the x-forwarded-for chain, which is appended by the Vercel proxy.
10+
const forwardedFor = req.headers.get('x-forwarded-for');
11+
const fallbackIp = forwardedFor ? forwardedFor.split(',').pop()?.trim() : 'unknown';
12+
const ip = req.headers.get('x-real-ip') || fallbackIp || 'unknown';
913

1014
if (ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) {
1115
return NextResponse.json(

lib/cache.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,15 @@ describe('TTLCache', () => {
409409
const matrix = [
410410
[1, 2],
411411
[3, 4],
412+
[5, 6],
412413
];
413414

414415
cache.set('matrix', matrix, 60_000);
415416

416-
expect(cache.get('matrix')).toEqual(matrix);
417+
const cached = cache.get('matrix');
418+
419+
expect(cached).toEqual(matrix);
420+
expect(cached?.[2]?.[1]).toBe(6);
417421

418422
cache.destroy();
419423
});

lib/github.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,83 @@ describe('GitHub API cache behavior', () => {
15271527

15281528
expect(fetch).toHaveBeenCalledTimes(1);
15291529
});
1530+
1531+
it('cache hit: second fetchContributedRepos call uses cached value', async () => {
1532+
const mockNodes = [{ name: 'cached-repo' }];
1533+
vi.mocked(fetch).mockResolvedValue(
1534+
mockResponse({
1535+
data: {
1536+
user: {
1537+
repositoriesContributedTo: {
1538+
nodes: mockNodes,
1539+
},
1540+
},
1541+
},
1542+
})
1543+
);
1544+
1545+
await fetchContributedRepos('octocat');
1546+
await fetchContributedRepos('octocat');
1547+
1548+
expect(fetch).toHaveBeenCalledTimes(1);
1549+
});
1550+
1551+
it('dedupes concurrent fetchContributedRepos requests for the same cold cache key', async () => {
1552+
let resolveFetch!: (response: Response) => void;
1553+
vi.mocked(fetch).mockImplementation(
1554+
() =>
1555+
new Promise<Response>((resolve) => {
1556+
resolveFetch = resolve;
1557+
})
1558+
);
1559+
1560+
const requests = Promise.all([
1561+
fetchContributedRepos('octocat'),
1562+
fetchContributedRepos('octocat'),
1563+
fetchContributedRepos('octocat'),
1564+
]);
1565+
1566+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
1567+
1568+
resolveFetch(
1569+
mockResponse({
1570+
data: {
1571+
user: {
1572+
repositoriesContributedTo: {
1573+
nodes: [{ name: 'deduped-repo' }],
1574+
},
1575+
},
1576+
},
1577+
})
1578+
);
1579+
1580+
const results = await requests;
1581+
expect(results.map((repos) => repos[0]?.name)).toEqual([
1582+
'deduped-repo',
1583+
'deduped-repo',
1584+
'deduped-repo',
1585+
]);
1586+
});
1587+
1588+
it('refresh bypass: bypassCache=true forces fresh fetchContributedRepos fetch', async () => {
1589+
const mockNodes = [{ name: 'repo' }];
1590+
vi.mocked(fetch).mockImplementation(async () =>
1591+
mockResponse({
1592+
data: {
1593+
user: {
1594+
repositoriesContributedTo: {
1595+
nodes: mockNodes,
1596+
},
1597+
},
1598+
},
1599+
})
1600+
);
1601+
1602+
await fetchContributedRepos('octocat');
1603+
await fetchContributedRepos('octocat', { bypassCache: true });
1604+
1605+
expect(fetch).toHaveBeenCalledTimes(2);
1606+
});
15301607
});
15311608

15321609
describe('generateAchievements', () => {

lib/github.ts

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,11 @@ export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
250250
const contributionsCache = new DistributedCache<ExtendedContributionData>(1000);
251251
const profileCache = new DistributedCache<GitHubUserProfile>(1000);
252252
const reposCache = new DistributedCache<GitHubRepo[]>(500);
253+
const contributedReposCache = new DistributedCache<Record<string, unknown>[]>(500);
253254
const pendingContributions = new Map<string, Promise<ExtendedContributionData>>();
254255
const pendingProfiles = new Map<string, Promise<GitHubUserProfile>>();
255256
const pendingRepos = new Map<string, Promise<GitHubRepo[]>>();
257+
const pendingContributedRepos = new Map<string, Promise<Record<string, unknown>[]>>();
256258

257259
interface GitHubUserProfile {
258260
login: string;
@@ -269,18 +271,18 @@ interface GitHubUserProfile {
269271
}
270272

271273
export function cacheKey(
272-
kind: 'contributions' | 'profile' | 'repos',
274+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
273275
username: string,
274276
year?: string
275277
): string;
276278
export function cacheKey(
277-
kind: 'contributions' | 'profile' | 'repos',
279+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
278280
username: string,
279281
from?: string,
280282
to?: string
281283
): string;
282284
export function cacheKey(
283-
kind: 'contributions' | 'profile' | 'repos',
285+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
284286
username: string,
285287
yearOrFrom?: string,
286288
to?: string
@@ -297,9 +299,11 @@ export function clearGitHubApiCacheForTests(): void {
297299
contributionsCache.clear();
298300
profileCache.clear();
299301
reposCache.clear();
302+
contributedReposCache.clear();
300303
pendingContributions.clear();
301304
pendingProfiles.clear();
302305
pendingRepos.clear();
306+
pendingContributedRepos.clear();
303307
}
304308

305309
function dedupeRequest<T>(
@@ -940,38 +944,54 @@ export async function fetchContributedRepos(
940944
username: string,
941945
options: FetchOptions = {}
942946
): Promise<Record<string, unknown>[]> {
943-
const query = `
944-
query($login: String!) {
945-
user(login: $login) {
946-
repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], orderBy: {field: UPDATED_AT, direction: DESC}) {
947-
nodes {
948-
name
949-
nameWithOwner
950-
owner { login }
951-
stargazerCount
952-
forkCount
953-
primaryLanguage { name }
954-
updatedAt
947+
const key = cacheKey('repos:contributed', username);
948+
if (!options.bypassCache) {
949+
const cached = await contributedReposCache.get(key);
950+
if (cached) return cached;
951+
}
952+
953+
const load = async () => {
954+
const query = `
955+
query($login: String!) {
956+
user(login: $login) {
957+
repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], orderBy: {field: UPDATED_AT, direction: DESC}) {
958+
nodes {
959+
name
960+
nameWithOwner
961+
owner { login }
962+
stargazerCount
963+
forkCount
964+
primaryLanguage { name }
965+
updatedAt
966+
}
955967
}
956968
}
957969
}
958-
}
959-
`;
970+
`;
960971

961-
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
962-
method: 'POST',
963-
headers: getHeaders(),
964-
body: JSON.stringify({
965-
query,
966-
variables: { login: username },
967-
}),
968-
cache: 'no-store',
969-
signal: options.signal,
970-
});
972+
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
973+
method: 'POST',
974+
headers: getHeaders(),
975+
body: JSON.stringify({
976+
query,
977+
variables: { login: username },
978+
}),
979+
cache: 'no-store',
980+
signal: options.signal,
981+
});
982+
983+
if (!res.ok) return [];
984+
const data = await res.json();
985+
const result = data?.data?.user?.repositoriesContributedTo?.nodes || [];
986+
987+
if (!options.bypassCache) {
988+
await contributedReposCache.set(key, result, GITHUB_CACHE_TTL_MS);
989+
}
990+
return result;
991+
};
971992

972-
if (!res.ok) return [];
973-
const data = await res.json();
974-
return data?.data?.user?.repositoriesContributedTo?.nodes || [];
993+
if (options.bypassCache) return load();
994+
return dedupeRequest(pendingContributedRepos, key, load);
975995
}
976996

977997
export interface DeveloperScoreInput {

lib/svg/generator.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from './generator';
1515
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
1616
import { hexColor } from './sanitizer';
17+
import { themes } from './themes';
1718

1819
describe('generateSVG', () => {
1920
const mockStats: StreakStats = {
@@ -868,6 +869,29 @@ describe('generateSVG', () => {
868869
expect(svg).not.toContain('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
869870
});
870871
});
872+
873+
describe('verify all supported themes produce valid SVG output', () => {
874+
it('generates a valid SVG and contains the theme accent color for each supported theme', () => {
875+
for (const theme of Object.values(themes)) {
876+
const svg = generateSVG(
877+
mockStats,
878+
{
879+
user: 'octocat',
880+
bg: theme.bg,
881+
text: theme.text,
882+
accent: theme.accent,
883+
speed: '8s',
884+
scale: 'linear',
885+
} as unknown as BadgeParams,
886+
mockCalendar
887+
);
888+
889+
expect(svg).toContain('<svg');
890+
expect(svg).toContain('</svg>');
891+
expect(svg.toLowerCase()).toContain(theme.accent.toLowerCase());
892+
}
893+
});
894+
});
871895
});
872896

873897
describe('generateMonthlySVG', () => {

lib/svg/layout.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,22 +223,22 @@ it('assigns correct row and col values based on week/day position', () => {
223223
weeks: [
224224
{
225225
contributionDays: [
226+
{ contributionCount: 1, date: '2024-06-09' },
226227
{ contributionCount: 1, date: '2024-06-10' },
227228
{ contributionCount: 1, date: '2024-06-11' },
228-
{ contributionCount: 1, date: '2024-06-12' },
229229
],
230230
},
231231
{
232232
contributionDays: [
233-
{ contributionCount: 1, date: '2024-06-13' },
234-
{ contributionCount: 1, date: '2024-06-14' },
235-
{ contributionCount: 1, date: '2024-06-15' },
233+
{ contributionCount: 1, date: '2024-06-16' },
234+
{ contributionCount: 1, date: '2024-06-17' },
235+
{ contributionCount: 1, date: '2024-06-18' },
236236
],
237237
},
238238
],
239239
} as unknown as ContributionCalendar;
240240

241-
const towers = computeTowers(calendar, 'linear', '2024-06-15');
241+
const towers = computeTowers(calendar, 'linear', '2024-06-18');
242242

243243
expect(towers[0].row).toBe(0);
244244
expect(towers[0].col).toBe(0);

lib/svg/layout.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export function computeTowers(
123123
? `TODAY: ${day.date}: ${count} ${unit}`
124124
: `${day.date}: ${count} ${unit}`;
125125

126-
const coords = projectIsometric(i, j);
126+
const dayOfWeekIndex = new Date(day.date).getUTCDay();
127+
const coords = projectIsometric(i, dayOfWeekIndex);
127128

128129
let intensityLevel = 0;
129130
if (hasCommits) {
@@ -153,7 +154,7 @@ export function computeTowers(
153154
strokeOpacity: isGhost ? 0.3 : 0,
154155
strokeWidth: isGhost ? 0.5 : 0,
155156
row: i,
156-
col: j,
157+
col: dayOfWeekIndex,
157158
intensityLevel,
158159
});
159160
});

lib/validations.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,3 +1031,33 @@ describe('streakParamsSchema — layout query validation boundaries (Variation 2
10311031
}
10321032
});
10331033
});
1034+
1035+
/* ==========================================================================
1036+
* USER PARAMETER — QUERY VALIDATION BOUNDARIES (VARIATION 3)
1037+
* ========================================================================== */
1038+
1039+
describe('streakParamsSchema user maxLength validation boundaries (Variation 3)', () => {
1040+
it('rejects a GitHub username that exceeds the 39 character length threshold', () => {
1041+
const invalidPayload = {
1042+
user: 'a'.repeat(40),
1043+
};
1044+
1045+
const parseResult = streakParamsSchema.safeParse(invalidPayload);
1046+
1047+
expect(parseResult.success).toBe(false);
1048+
if (!parseResult.success) {
1049+
const fieldErrors = parseResult.error.flatten().fieldErrors;
1050+
expect(fieldErrors.user).toBeDefined();
1051+
expect(fieldErrors.user?.[0]).toContain('cannot exceed 39 characters');
1052+
}
1053+
});
1054+
1055+
it('accepts a username exactly at the upper limit of 39 characters', () => {
1056+
const validPayload = {
1057+
user: 'a'.repeat(39),
1058+
};
1059+
1060+
const parseResult = streakParamsSchema.safeParse(validPayload);
1061+
expect(parseResult.success).toBe(true);
1062+
});
1063+
});

0 commit comments

Comments
 (0)