Skip to content

Commit 811947a

Browse files
authored
Merge branch 'main' into test/verify-cache-for-date-instance-values-1
2 parents a6a1f65 + 401f4ca commit 811947a

11 files changed

Lines changed: 2025 additions & 324 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(

app/layout.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,26 @@ export const metadata: Metadata = {
6666

6767
export default function RootLayout({ children }: { children: React.ReactNode }) {
6868
return (
69-
<html lang="en">
70-
<body className={`${inter.className} bg-black`}>
69+
<html lang="en" suppressHydrationWarning>
70+
<head>
71+
<script
72+
dangerouslySetInnerHTML={{
73+
__html: `
74+
try {
75+
const storedTheme = window.localStorage.getItem('theme');
76+
if (storedTheme === 'light') {
77+
document.documentElement.classList.remove('dark');
78+
document.documentElement.style.colorScheme = 'light';
79+
} else {
80+
document.documentElement.classList.add('dark');
81+
document.documentElement.style.colorScheme = 'dark';
82+
}
83+
} catch (_) {}
84+
`,
85+
}}
86+
/>
87+
</head>
88+
<body className={inter.className}>
7189
<ScrollRestoration />
7290
<BrandParticles />
7391
<Navbar />

lib/cache.test.ts

Lines changed: 33 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
});
@@ -545,6 +549,34 @@ describe('TTLCache', () => {
545549
cache.destroy();
546550
});
547551

552+
it('verify TTLCache behavior for empty string keys (Variation 2)', () => {
553+
const cache = new TTLCache<string>();
554+
555+
// Assert that setting a value with empty string key throws error
556+
expect(() => {
557+
cache.set('', 'test-value', 60_000);
558+
}).toThrow(Error);
559+
560+
// Verify the error message is correct
561+
expect(() => {
562+
cache.set('', 'test-value', 60_000);
563+
}).toThrow('Cache key cannot be empty');
564+
565+
// Verify that cache remains empty (no entry for empty key)
566+
expect(cache.has('')).toBe(false);
567+
expect(cache.get('')).toBeNull();
568+
569+
// Verify cache size is still 0
570+
expect(cache.size()).toBe(0);
571+
572+
// Verify that normal operations still work after failed attempt
573+
cache.set('valid-key', 'value', 60_000);
574+
expect(cache.get('valid-key')).toBe('value');
575+
expect(cache.size()).toBe(1);
576+
577+
cache.destroy();
578+
});
579+
548580
it('handles rapid get/set/delete cycles', () => {
549581
const cache = new TTLCache<number>();
550582
for (let i = 0; i < 100; i++) {

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);

0 commit comments

Comments
 (0)