Skip to content

Commit 731d178

Browse files
Merge branch 'main' into fix/og-bypass-cache
2 parents 9bd4ff4 + d0b9dba commit 731d178

49 files changed

Lines changed: 2789 additions & 2399 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ describe('DashboardPage', () => {
104104
commitClock: [],
105105
graphData: { nodes: [], links: [] },
106106
lastSyncedAt: undefined,
107+
popularRepos: [],
108+
pinnedRepos: [],
107109
};
108110

109111
beforeEach(() => {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from '../route';
3+
4+
vi.mock('@/lib/github', () => ({
5+
getFullDashboardData: vi.fn(),
6+
}));
7+
8+
import { getFullDashboardData } from '@/lib/github';
9+
10+
const makeRequest = (search: string) => new Request(`http://localhost:3000/api/compare?${search}`);
11+
12+
describe('GET /api/compare - Validation', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
16+
vi.mocked(getFullDashboardData).mockResolvedValue({
17+
calendar: {
18+
totalContributions: 50,
19+
weeks: [],
20+
},
21+
} as never);
22+
});
23+
24+
it('returns 400 when both usernames are missing', async () => {
25+
const res = await GET(makeRequest(''));
26+
27+
expect(res.status).toBe(400);
28+
29+
const data = await res.json();
30+
expect(data.error).toBeDefined();
31+
});
32+
33+
it('returns 400 when user1 exceeds the maximum length', async () => {
34+
const res = await GET(makeRequest(`user1=${'a'.repeat(40)}&user2=octocat`));
35+
36+
expect(res.status).toBe(400);
37+
38+
const data = await res.json();
39+
expect(data.error).toBeDefined();
40+
});
41+
42+
it('returns 400 for invalid username format', async () => {
43+
const res = await GET(makeRequest('user1=-invalid&user2=octocat'));
44+
45+
expect(res.status).toBe(400);
46+
47+
const data = await res.json();
48+
expect(data.details.fieldErrors.user1).toBeDefined();
49+
});
50+
51+
it('returns 400 when comparing identical usernames', async () => {
52+
const res = await GET(makeRequest('user1=octocat&user2=octocat'));
53+
54+
expect(res.status).toBe(400);
55+
56+
const data = await res.json();
57+
58+
expect(data.details.fieldErrors.user2).toContain('Cannot compare a user with themselves.');
59+
});
60+
61+
it('returns 400 for self-comparison regardless of case', async () => {
62+
const res = await GET(makeRequest('user1=OctoCat&user2=octocat'));
63+
64+
expect(res.status).toBe(400);
65+
});
66+
});

app/api/streak/route.test.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,46 @@ describe('GET /api/streak', () => {
11461146
expect(body).toContain('COMMITS THIS MONTH');
11471147
});
11481148

1149+
it('automatically overrides or widens the query bounds to encompass the start of the previous month when view=monthly is requested with custom from/to params', async () => {
1150+
vi.useFakeTimers();
1151+
vi.setSystemTime(new Date('2026-06-02T12:00:00Z'));
1152+
1153+
vi.mocked(fetchGitHubContributions).mockResolvedValueOnce({
1154+
calendar: {
1155+
totalContributions: 25,
1156+
weeks: [
1157+
{
1158+
contributionDays: [
1159+
{ date: '2026-05-01', contributionCount: 0 },
1160+
{ date: '2026-05-15', contributionCount: 10 },
1161+
{ date: '2026-06-01', contributionCount: 15 },
1162+
{ date: '2026-06-02', contributionCount: 0 },
1163+
],
1164+
},
1165+
],
1166+
} as ContributionCalendar,
1167+
repoContributions: [],
1168+
} as unknown as ExtendedContributionData);
1169+
1170+
try {
1171+
const response = await GET(
1172+
makeRequest({ user: 'octocat', view: 'monthly', from: '2026-06-01', to: '2026-06-02' })
1173+
);
1174+
1175+
expect(response.status).toBe(200);
1176+
// The expected prev month (May 2026) start is 2026-05-01.
1177+
// So 'from' should be widened to 2026-05-01T00:00:00Z.
1178+
// 'to' should be today's date in ISO: 2026-06-02T12:00:00.000Z.
1179+
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', {
1180+
bypassCache: false,
1181+
from: '2026-05-01T00:00:00Z',
1182+
to: '2026-06-02T12:00:00.000Z',
1183+
});
1184+
} finally {
1185+
vi.useRealTimers();
1186+
}
1187+
});
1188+
11491189
it('uses the selected year when generating archived monthly stats', async () => {
11501190
vi.useFakeTimers();
11511191
vi.setSystemTime(new Date('2026-05-20T12:00:00Z'));
@@ -1154,8 +1194,14 @@ describe('GET /api/streak', () => {
11541194
calendar: {
11551195
totalContributions: 25,
11561196
weeks: [
1157-
{ contributionDays: [{ date: '2024-11-15', contributionCount: 10 }] },
1158-
{ contributionDays: [{ date: '2024-12-15', contributionCount: 15 }] },
1197+
{
1198+
contributionDays: [
1199+
{ date: '2024-11-01', contributionCount: 0 },
1200+
{ date: '2024-11-15', contributionCount: 10 },
1201+
{ date: '2024-12-15', contributionCount: 15 },
1202+
{ date: '2024-12-31', contributionCount: 0 },
1203+
],
1204+
},
11591205
],
11601206
} as ContributionCalendar,
11611207
repoContributions: [],
@@ -1248,8 +1294,14 @@ describe('GET /api/streak', () => {
12481294
calendar: {
12491295
totalContributions: 150,
12501296
weeks: [
1251-
{ contributionDays: [{ date: '2026-04-15', contributionCount: 10 }] },
1252-
{ contributionDays: [{ date: '2026-05-15', contributionCount: 15 }] },
1297+
{
1298+
contributionDays: [
1299+
{ date: '2026-04-01', contributionCount: 0 },
1300+
{ date: '2026-04-15', contributionCount: 10 },
1301+
{ date: '2026-05-15', contributionCount: 15 },
1302+
{ date: '2026-05-20', contributionCount: 0 },
1303+
],
1304+
},
12531305
],
12541306
} as ContributionCalendar,
12551307
repoContributions: [],
@@ -1275,8 +1327,14 @@ describe('GET /api/streak', () => {
12751327
calendar: {
12761328
totalContributions: 150,
12771329
weeks: [
1278-
{ contributionDays: [{ date: '2026-04-15', contributionCount: 10 }] },
1279-
{ contributionDays: [{ date: '2026-05-15', contributionCount: 15 }] },
1330+
{
1331+
contributionDays: [
1332+
{ date: '2026-04-01', contributionCount: 0 },
1333+
{ date: '2026-04-15', contributionCount: 10 },
1334+
{ date: '2026-05-15', contributionCount: 15 },
1335+
{ date: '2026-05-20', contributionCount: 0 },
1336+
],
1337+
},
12801338
],
12811339
} as ContributionCalendar,
12821340
repoContributions: [],

app/api/streak/route.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,25 +103,54 @@ export async function GET(request: Request) {
103103
} = parseResult.data;
104104
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
105105
const themeName = theme || 'dark';
106-
const from = customFrom
106+
107+
let timezone = 'UTC';
108+
if (tzParam) {
109+
timezone = new Intl.DateTimeFormat(undefined, { timeZone: tzParam }).resolvedOptions()
110+
.timeZone;
111+
}
112+
113+
let from = customFrom
107114
? new Date(customFrom).toISOString()
108115
: year
109116
? `${year}-01-01T00:00:00Z`
110117
: undefined;
111-
const to = customTo
118+
let to = customTo
112119
? new Date(customTo).toISOString()
113120
: year
114121
? `${year}-12-31T23:59:59Z`
115122
: undefined;
116-
const currentYear = new Date().getUTCFullYear();
117-
const isHistoricalYear = !!year && Number(year) < currentYear;
118123

119-
let timezone = 'UTC';
120-
if (tzParam) {
121-
timezone = new Intl.DateTimeFormat(undefined, { timeZone: tzParam }).resolvedOptions()
122-
.timeZone;
124+
if (normalizedView === 'monthly') {
125+
const referenceDate = getMonthlyReferenceDate(year, timezone) || new Date();
126+
const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(
127+
referenceDate
128+
);
129+
const [currentYearStr, currentMonthStr] = localTodayStr.split('-');
130+
const currentYearNum = parseInt(currentYearStr, 10);
131+
const currentMonthNum = parseInt(currentMonthStr, 10);
132+
133+
let prevMonth = currentMonthNum - 1;
134+
let prevYear = currentYearNum;
135+
if (prevMonth === 0) {
136+
prevMonth = 12;
137+
prevYear -= 1;
138+
}
139+
140+
const calculatedFromStr = `${prevYear}-${prevMonth.toString().padStart(2, '0')}-01T00:00:00Z`;
141+
if (!from || new Date(from) > new Date(calculatedFromStr)) {
142+
from = calculatedFromStr;
143+
}
144+
145+
const referenceISO = referenceDate.toISOString();
146+
if (!to || new Date(to) < new Date(referenceISO)) {
147+
to = referenceISO;
148+
}
123149
}
124150

151+
const currentYear = new Date().getUTCFullYear();
152+
const isHistoricalYear = !!year && Number(year) < currentYear;
153+
125154
const isAutoTheme = themeName === 'auto';
126155
const isRandomTheme = themeName === 'random';
127156
const selectedTheme = (() => {
@@ -256,7 +285,7 @@ export async function GET(request: Request) {
256285
}
257286
}
258287

259-
if (days) {
288+
if (days && normalizedView !== 'monthly') {
260289
const allDays = calendar.weeks.flatMap((w) => w.contributionDays);
261290

262291
const filteredDays = allDays.slice(-days);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { render, screen } from '@testing-library/react';
2+
import '@testing-library/jest-dom/vitest';
3+
import React, { type ReactNode } from 'react';
4+
import { describe, expect, it, vi } from 'vitest';
5+
6+
vi.mock('next/navigation', () => ({
7+
useRouter: () => ({
8+
replace: vi.fn(),
9+
}),
10+
useSearchParams: () => ({
11+
get: vi.fn(() => null),
12+
}),
13+
}));
14+
15+
vi.mock('framer-motion', () => ({
16+
motion: new Proxy(
17+
{},
18+
{
19+
get: (_, tag) => {
20+
return ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) =>
21+
React.createElement(tag as string, props, children);
22+
},
23+
}
24+
),
25+
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
26+
}));
27+
28+
vi.doMock('recharts', () => ({
29+
ResponsiveContainer: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
30+
RadarChart: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
31+
Radar: () => <div />,
32+
PolarGrid: () => <div />,
33+
PolarAngleAxis: () => <div />,
34+
PolarRadiusAxis: () => <div />,
35+
Tooltip: () => <div />,
36+
}));
37+
38+
async function renderCompareClient() {
39+
const { default: CompareClient } = await import('./CompareClient');
40+
41+
return render(<CompareClient />);
42+
}
43+
44+
describe('CompareClient responsive breakpoints', () => {
45+
it('renders mobile friendly input controls', async () => {
46+
await renderCompareClient();
47+
48+
expect(screen.getByPlaceholderText(/github username #1/i)).toBeInTheDocument();
49+
expect(screen.getByPlaceholderText(/github username #2/i)).toBeInTheDocument();
50+
});
51+
52+
it('renders compare button for small viewport interactions', async () => {
53+
await renderCompareClient();
54+
55+
expect(screen.getByRole('button', { name: /compare/i })).toBeInTheDocument();
56+
});
57+
58+
it('uses responsive container spacing classes', async () => {
59+
const { container } = await renderCompareClient();
60+
61+
expect(container.querySelector('.px-4.sm\\:px-6')).toBeTruthy();
62+
});
63+
64+
it('uses responsive layout grid classes', async () => {
65+
const { container } = await renderCompareClient();
66+
67+
expect(container.querySelector('.flex-col.sm\\:flex-row')).toBeTruthy();
68+
});
69+
70+
it('contains responsive typography classes for mobile scaling', async () => {
71+
const { container } = await renderCompareClient();
72+
73+
expect(container.querySelector('.text-3xl.sm\\:text-4xl')).toBeTruthy();
74+
});
75+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import ComparePage, { metadata } from './page';
5+
6+
vi.mock('./CompareClient', () => ({
7+
default: () => <div data-testid="compare-client">Compare Client</div>,
8+
}));
9+
10+
vi.mock('../components/Footer', () => ({
11+
Footer: () => <div data-testid="footer">Footer</div>,
12+
}));
13+
14+
describe('ComparePage empty fallback behavior', () => {
15+
it('renders safely without crashing', () => {
16+
expect(() => render(<ComparePage />)).not.toThrow();
17+
});
18+
19+
it('renders CompareClient content', () => {
20+
render(<ComparePage />);
21+
22+
expect(screen.getByTestId('compare-client')).toBeDefined();
23+
});
24+
25+
it('renders Footer component', () => {
26+
render(<ComparePage />);
27+
28+
expect(screen.getByTestId('footer')).toBeDefined();
29+
});
30+
31+
it('exports valid metadata', () => {
32+
expect(metadata.title).toBe('Compare | CommitPulse');
33+
});
34+
35+
it('contains openGraph fallback metadata', () => {
36+
expect(metadata.openGraph?.title).toBe('Compare Developers | CommitPulse');
37+
});
38+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expectTypeOf, it } from 'vitest';
2+
import CopyRepoButton from './CopyRepoButton';
3+
4+
describe('CopyRepoButton Type Compiler Validation', () => {
5+
it('exports CopyRepoButton as a function component', () => {
6+
expectTypeOf(CopyRepoButton).toBeFunction();
7+
});
8+
9+
it('accepts no parameters', () => {
10+
expectTypeOf(CopyRepoButton).parameters.toEqualTypeOf<[]>();
11+
});
12+
13+
it('returns a valid component type', () => {
14+
expectTypeOf<ReturnType<typeof CopyRepoButton>>().toBeObject();
15+
});
16+
17+
it('maintains a stable callable signature', () => {
18+
expectTypeOf(CopyRepoButton).toEqualTypeOf<typeof CopyRepoButton>();
19+
});
20+
21+
it('preserves return type consistency', () => {
22+
expectTypeOf<ReturnType<typeof CopyRepoButton>>().toEqualTypeOf<
23+
ReturnType<typeof CopyRepoButton>
24+
>();
25+
});
26+
});

0 commit comments

Comments
 (0)