From cf717d926672042dbf74e73e8aac6bfc6eef00e7 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 11:54:13 +0530
Subject: [PATCH 01/17] fix: streak user parameter validation and error SVGs
---
app/api/streak/route.test.ts | 18 ++++++++++++++++++
app/api/streak/route.ts | 17 ++++++++++++++++-
lib/validations.streakParamsSchema.test.ts | 15 ++++++++++++---
lib/validations.ts | 11 ++++++++++-
4 files changed, 56 insertions(+), 5 deletions(-)
diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts
index 84197e9bc..883efb828 100644
--- a/app/api/streak/route.test.ts
+++ b/app/api/streak/route.test.ts
@@ -197,6 +197,24 @@ describe('GET /api/streak', () => {
expect(response.headers.get('Content-Type')).toContain('image/svg+xml');
});
+ it('returns JSON 400 when the user parameter is missing and format=json', async () => {
+ const response = await GET(makeRequest({ format: 'json' }));
+ expect(response.status).toBe(400);
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
+ expect(body.error).toContain('Missing user parameter');
+ expect(response.headers.get('Content-Type')).toContain('application/json');
+ });
+
+ it('returns 404 JSON for a nonexistent user when format=json', async () => {
+ vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('Could not resolve to a User with the login of \'nonexistentuser\''));
+ const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' }));
+ expect(response.status).toBe(404);
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
+ expect(response.headers.get('Content-Type')).toContain('application/json');
+ });
+
it('returns 400 when org parameter contains spaces and invalid characters', async () => {
const response = await GET(
makeRequest({ user: 'octocat', org: 'invalid_org_name_with_spaces' })
diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts
index e2159978f..0e7e7d1fd 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -99,6 +99,21 @@ export async function GET(request: Request) {
Object.values(fieldErrors.fieldErrors).flat()[0] ??
fieldErrors.formErrors[0] ??
'Invalid parameters';
+
+ if (searchParams.get('format') === 'json') {
+ return NextResponse.json(
+ { error: firstError },
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'X-Request-ID': requestId,
+ },
+ }
+ );
+ }
+
const errTheme = resolveErrorTheme(searchParams);
const errorSvg = buildInlineErrorSVG(firstError, {
bg: errTheme.bg,
@@ -975,7 +990,7 @@ function buildErrorResponse(
}
if (isNotFound) {
- const match = message.match(/"([^"]+)"|login of '([^']+)'/);
+ const match = rawMessage.match(/"([^"]+)"|login of '([^']+)'/);
const fallbackTarget = parseResult.success
? parseResult.data.org || parseResult.data.user
: 'unknown';
diff --git a/lib/validations.streakParamsSchema.test.ts b/lib/validations.streakParamsSchema.test.ts
index 75c8fb777..85d645fda 100644
--- a/lib/validations.streakParamsSchema.test.ts
+++ b/lib/validations.streakParamsSchema.test.ts
@@ -65,15 +65,24 @@ describe('streakParamsSchema', () => {
expect(result.success).toBe(true);
});
+ it('accepts input with org but missing user', () => {
+ const result = streakParamsSchema.safeParse({ org: 'github' });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.org).toBe('github');
+ expect(result.data.user).toBe(''); // default empty string
+ }
+ });
+
// ── Invalid / negative cases ──────────────────────────────────────────────
- it('fails when user is missing', () => {
+ it('fails when both user and org are missing', () => {
const result = streakParamsSchema.safeParse({});
expect(result.success).toBe(false);
});
- it('fails when user is an empty string', () => {
- const result = streakParamsSchema.safeParse({ user: '' });
+ it('fails when user and org are empty strings', () => {
+ const result = streakParamsSchema.safeParse({ user: '', org: '' });
expect(result.success).toBe(false);
});
diff --git a/lib/validations.ts b/lib/validations.ts
index 0034e0aa3..bf62674c5 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -231,8 +231,9 @@ const baseStreakParamsSchema = z.object({
// Required — missing user surfaces as "Missing" to match existing tests
user: z
.string({ error: 'Missing user parameter' })
- .min(1, { message: 'Missing user parameter' })
+ .default('')
.superRefine((val, ctx) => {
+ if (val === '') return;
const users = val.split(',').map((u) => u.trim());
if (users.length === 0) {
ctx.addIssue({
@@ -656,6 +657,10 @@ const baseStreakParamsSchema = z.object({
const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000;
export const streakParamsSchema = baseStreakParamsSchema
+ .refine(
+ (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
+ { message: 'Missing user parameter', path: ['user'] }
+ )
.refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), {
message: '"to" date must be after or equal to "from" date',
path: ['to'],
@@ -1203,6 +1208,10 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema
.optional()
.transform((val) => val || 'rise'),
})
+ .refine(
+ (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
+ { message: 'Missing user parameter', path: ['user'] }
+ )
.refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), {
message: '"to" date must be after or equal to "from" date',
path: ['to'],
From d91612e623fda945b83ad86bc3feac40c32ff531 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 12:10:31 +0530
Subject: [PATCH 02/17] style: fix prettier formatting
---
app/api/streak/route.test.ts | 4 +++-
lib/validations.ts | 6 ++++--
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts
index 5d3063b14..728b52321 100644
--- a/app/api/streak/route.test.ts
+++ b/app/api/streak/route.test.ts
@@ -207,7 +207,9 @@ describe('GET /api/streak', () => {
});
it('returns 404 JSON for a nonexistent user when format=json', async () => {
- vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('Could not resolve to a User with the login of \'nonexistentuser\''));
+ vi.mocked(fetchGitHubContributions).mockRejectedValue(
+ new Error("Could not resolve to a User with the login of 'nonexistentuser'")
+ );
const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' }));
expect(response.status).toBe(404);
const body = await response.json();
diff --git a/lib/validations.ts b/lib/validations.ts
index 56997f2c3..e83dbb8b9 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -684,7 +684,8 @@ const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000;
export const streakParamsSchema = baseStreakParamsSchema
.refine(
- (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
+ (data) =>
+ (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
{ message: 'Missing user parameter', path: ['user'] }
)
.refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), {
@@ -1235,7 +1236,8 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema
.transform((val) => val || 'rise'),
})
.refine(
- (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
+ (data) =>
+ (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
{ message: 'Missing user parameter', path: ['user'] }
)
.refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), {
From 01a219f13711548cadbd0db35f514ef522dd977c Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 13:02:28 +0530
Subject: [PATCH 03/17] feat: stream dashboard components with React Suspense
(#71)
- Refactored dashboard data fetching to pass unawaited promises to client
- Updated DashboardClient to use React.use() for suspense boundary integration
- Removed blocking 3.5s opacity animation from DashboardPageWrapper
- Leveraged Next.js layout streaming to instantly render dashboard shell
---
app/(root)/dashboard/DashboardPageWrapper.tsx | 40 ++---------------
app/(root)/dashboard/[username]/page.tsx | 43 ++++++++-----------
components/dashboard/DashboardClient.tsx | 14 ++++--
3 files changed, 31 insertions(+), 66 deletions(-)
diff --git a/app/(root)/dashboard/DashboardPageWrapper.tsx b/app/(root)/dashboard/DashboardPageWrapper.tsx
index db4246562..31003e3f5 100644
--- a/app/(root)/dashboard/DashboardPageWrapper.tsx
+++ b/app/(root)/dashboard/DashboardPageWrapper.tsx
@@ -1,47 +1,13 @@
'use client';
-import { useState, useSyncExternalStore } from 'react';
-import { createPortal } from 'react-dom';
-import LoadingScreen from './LoadingScreen';
-
interface DashboardPageWrapperProps {
children: React.ReactNode;
}
/**
- * Wraps dashboard page content and guarantees LoadingScreen plays its full
- * 3500ms animation regardless of how fast Next.js receives API data.
- *
- * The overlay is rendered via a React Portal directly into document.body —
- * this means it escapes ALL stacking contexts (navbar, layout wrappers, etc.)
- * and is unconditionally on top of everything on the page.
+ * Wraps dashboard page content.
+ * Now renders instantly to support React Suspense streaming.
*/
export default function DashboardPageWrapper({ children }: DashboardPageWrapperProps) {
- const [ready, setReady] = useState(false);
-
- const mounted = useSyncExternalStore(
- () => () => {},
- () => true,
- () => false
- );
-
- return (
- <>
- {/* Real page — renders immediately but stays invisible until animation ends */}
-
- {children}
-
-
- {/* Overlay portalled into document.body — escapes every stacking context */}
- {mounted &&
- !ready &&
- createPortal( setReady(true)} />, document.body)}
- >
- );
+ return <>{children}>;
}
diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx
index 7063302b7..0235a7ab5 100644
--- a/app/(root)/dashboard/[username]/page.tsx
+++ b/app/(root)/dashboard/[username]/page.tsx
@@ -136,35 +136,28 @@ async function DashboardContent({
const session = await auth();
const sessionUsername = (session?.user as { username?: string })?.username ?? null;
- let data;
-
+ let fallbackProfile;
try {
- data = await getFullDashboardData(username, {
+ fallbackProfile = await fetchUserProfile(username, {
bypassCache,
- from: period.from,
- to: period.to,
- rangeLabel: period.label,
token: userToken,
- excludeBots,
});
- } catch (error) {
- if (error instanceof Error && error.message.includes('not found')) {
- let fallbackProfile;
- try {
- fallbackProfile = await fetchUserProfile(username, {
- bypassCache,
- token: userToken,
- });
- } catch {
- return notFound();
- }
- if (fallbackProfile.type === 'Organization') {
- redirect(`/dashboard/org/${username}`);
- }
- return notFound();
- }
- throw error;
+ } catch {
+ return notFound();
}
+
+ if (fallbackProfile.type === 'Organization') {
+ redirect(`/dashboard/org/${username}`);
+ }
+
+ let data = getFullDashboardData(username, {
+ bypassCache,
+ from: period.from,
+ to: period.to,
+ rangeLabel: period.label,
+ token: userToken,
+ excludeBots,
+ });
let allRepos: RepoActivityInfo[] = [];
try {
@@ -196,7 +189,7 @@ async function DashboardContent({
;
+ initialData?: DashboardData;
+ allRepoActivity: RepoActivityInfo[];
username: string;
compareData?: DashboardData | null;
period: DashboardPeriod;
@@ -330,7 +331,8 @@ function getPersonalityTags(
}
export default function DashboardClient({
- initialData,
+ initialDataPromise,
+ initialData: initialDataProp,
allRepoActivity = [],
username,
compareData = null,
@@ -342,6 +344,10 @@ export default function DashboardClient({
() => false,
() => (process.env.NODE_ENV === 'test' ? false : true)
);
+
+ // Use React.use() if a promise is provided, otherwise use the direct object (for tests)
+ const initialData = initialDataProp || use(initialDataPromise!);
+
const [secondUserData, setSecondUserData] = useState(compareData);
const [activeTab, setActiveTab] = useState<'overview' | 'pr-insights' | 'ci-analytics'>(
'overview'
From 71b429a68a03282ceb1c5189cbd9141b16cf4c1f Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 13:10:25 +0530
Subject: [PATCH 04/17] style: fix prettier formatting issues
---
app/(root)/dashboard/[username]/page.tsx | 2 +-
components/dashboard/DashboardClient.tsx | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx
index 0235a7ab5..dadd76de9 100644
--- a/app/(root)/dashboard/[username]/page.tsx
+++ b/app/(root)/dashboard/[username]/page.tsx
@@ -145,7 +145,7 @@ async function DashboardContent({
} catch {
return notFound();
}
-
+
if (fallbackProfile.type === 'Organization') {
redirect(`/dashboard/org/${username}`);
}
diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx
index c5aee4819..14e312379 100644
--- a/components/dashboard/DashboardClient.tsx
+++ b/components/dashboard/DashboardClient.tsx
@@ -344,10 +344,10 @@ export default function DashboardClient({
() => false,
() => (process.env.NODE_ENV === 'test' ? false : true)
);
-
+
// Use React.use() if a promise is provided, otherwise use the direct object (for tests)
const initialData = initialDataProp || use(initialDataPromise!);
-
+
const [secondUserData, setSecondUserData] = useState(compareData);
const [activeTab, setActiveTab] = useState<'overview' | 'pr-insights' | 'ci-analytics'>(
'overview'
From acf6139f61a2bc6fe30d238b1d93fe2fbb63828c Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 13:20:25 +0530
Subject: [PATCH 05/17] fix: prefer-const in page.tsx
---
app/(root)/dashboard/[username]/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx
index dadd76de9..a1eda1ecc 100644
--- a/app/(root)/dashboard/[username]/page.tsx
+++ b/app/(root)/dashboard/[username]/page.tsx
@@ -150,7 +150,7 @@ async function DashboardContent({
redirect(`/dashboard/org/${username}`);
}
- let data = getFullDashboardData(username, {
+ const data = getFullDashboardData(username, {
bypassCache,
from: period.from,
to: period.to,
From e9297696872b11004fc3654a69ece096776f68e0 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 13:32:35 +0530
Subject: [PATCH 06/17] fix: resolve TypeScript type check errors in
DashboardClient tests
---
components/dashboard/DashboardClient.tsx | 4 +--
.../DashboardClient.type-compiler.test.tsx | 28 +++++++++++--------
2 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx
index 14e312379..77e07b68b 100644
--- a/components/dashboard/DashboardClient.tsx
+++ b/components/dashboard/DashboardClient.tsx
@@ -106,10 +106,10 @@ export interface DashboardData {
rawCommits?: string[];
}
-interface DashboardClientProps {
+export interface DashboardClientProps {
initialDataPromise?: Promise;
initialData?: DashboardData;
- allRepoActivity: RepoActivityInfo[];
+ allRepoActivity?: RepoActivityInfo[];
username: string;
compareData?: DashboardData | null;
period: DashboardPeriod;
diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx
index a9d0bcb87..806e789fb 100644
--- a/components/dashboard/DashboardClient.type-compiler.test.tsx
+++ b/components/dashboard/DashboardClient.type-compiler.test.tsx
@@ -1,6 +1,6 @@
import { describe, it, expectTypeOf } from 'vitest';
import React, { ComponentProps } from 'react';
-import DashboardClient, { ProfileMetrics, CoderProfile } from './DashboardClient';
+import DashboardClient, { ProfileMetrics, CoderProfile, DashboardClientProps } from './DashboardClient';
import type { DashboardPeriod } from '@/utils/dashboardPeriod';
describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints Stability (Variation 10)', () => {
@@ -16,29 +16,29 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
});
it('Use type-testing assertions (expectTypeOf) to enforce field property configurations: Infer unexported props', () => {
- // Extracting the props from the component
- type Props = ComponentProps;
+ type Props = DashboardClientProps;
// Assert the required fields
expectTypeOf().toHaveProperty('username').toBeString();
expectTypeOf().toHaveProperty('period').toEqualTypeOf();
// Test the internal initialData structure
- expectTypeOf().toHaveProperty('profile').toBeObject();
- expectTypeOf().toHaveProperty('username').toBeString();
+ expectTypeOf>().toHaveProperty('profile').toBeObject();
+ expectTypeOf['profile']>().toHaveProperty('username').toBeString();
});
it('Assert that invalid prop parameters are blocked during static type checking: Rejects missing props', () => {
- type Props = ComponentProps;
+ type Props = DashboardClientProps;
// Missing 'username', 'initialData', 'period'
expectTypeOf>().not.toMatchTypeOf();
- // 'username' must be a string, not a number
+ // 'initialData' must not be missing critical nested keys
expectTypeOf<{
- username: number;
- initialData: Props['initialData'];
+ username: string;
+ initialData: Omit, 'profile'>;
period: DashboardPeriod;
+ allRepoActivity: Props['allRepoActivity'];
}>().not.toMatchTypeOf();
});
@@ -58,6 +58,11 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
/>
);
void validComponent;
+
+ // Testing the extraction logic in edge cases
+ expectTypeOf['activity']>().toMatchTypeOf<
+ Array<{ date: string; count: number; intensity: 0 | 1 | 2 | 3 | 4 }>
+ >();
});
it('Verify schema validation constraints return strict validation reports: CoderProfile strict unions', () => {
@@ -66,9 +71,8 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
'Early Builder ☀' | 'Weekend Warrior 🚀' | 'Consistent Runner 🏃♂️'
>();
- // intensity in activity array has strict union 0 | 1 | 2 | 3 | 4
- type Props = ComponentProps;
- type ActivityIntensity = Props['initialData']['activity'][0]['intensity'];
+ type Props = DashboardClientProps;
+ type ActivityIntensity = NonNullable['activity'][0]['intensity'];
expectTypeOf().toEqualTypeOf<0 | 1 | 2 | 3 | 4>();
});
});
From fd6b6bd65f86b277f37a5146abc2346bd8d3d6b1 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 13:34:34 +0530
Subject: [PATCH 07/17] style: fix prettier formatting in
DashboardClient.type-compiler.test.tsx
---
.../dashboard/DashboardClient.type-compiler.test.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx
index 806e789fb..249911acd 100644
--- a/components/dashboard/DashboardClient.type-compiler.test.tsx
+++ b/components/dashboard/DashboardClient.type-compiler.test.tsx
@@ -1,6 +1,10 @@
import { describe, it, expectTypeOf } from 'vitest';
import React, { ComponentProps } from 'react';
-import DashboardClient, { ProfileMetrics, CoderProfile, DashboardClientProps } from './DashboardClient';
+import DashboardClient, {
+ ProfileMetrics,
+ CoderProfile,
+ DashboardClientProps,
+} from './DashboardClient';
import type { DashboardPeriod } from '@/utils/dashboardPeriod';
describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints Stability (Variation 10)', () => {
@@ -24,7 +28,9 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
// Test the internal initialData structure
expectTypeOf>().toHaveProperty('profile').toBeObject();
- expectTypeOf['profile']>().toHaveProperty('username').toBeString();
+ expectTypeOf['profile']>()
+ .toHaveProperty('username')
+ .toBeString();
});
it('Assert that invalid prop parameters are blocked during static type checking: Rejects missing props', () => {
From 505eb769cec08e9858bb98f8d317477edd47d5c0 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 18:54:58 +0530
Subject: [PATCH 08/17] test: fix React suspense and mock warnings in test
suite
---
app/(root)/dashboard/[username]/page.test.tsx | 63 ++++++++++++++++---
app/burnout-analyzer/page.test.tsx | 33 +++++-----
2 files changed, 71 insertions(+), 25 deletions(-)
diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx
index 4ed6853c0..d28652544 100644
--- a/app/(root)/dashboard/[username]/page.test.tsx
+++ b/app/(root)/dashboard/[username]/page.test.tsx
@@ -1,8 +1,9 @@
import type { Metadata } from 'next';
-import { render, screen } from '@testing-library/react';
+import { Suspense } from 'react';
+import { render, screen, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import DashboardPage, { generateMetadata } from './page';
-import { getFullDashboardData } from '@/lib/github';
+import { getFullDashboardData, fetchUserProfile } from '@/lib/github';
const { mockNotFound } = vi.hoisted(() => ({
mockNotFound: vi.fn(),
@@ -21,7 +22,39 @@ vi.mock('next/navigation', () => ({
}));
vi.mock('@/lib/github', () => ({
- getFullDashboardData: vi.fn(),
+ getFullDashboardData: vi.fn().mockReturnValue(Promise.resolve({
+ profile: {
+ login: 'octocat',
+ avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
+ html_url: 'https://github.com/octocat',
+ name: 'The Octocat',
+ bio: null,
+ company: '@github',
+ blog: 'https://github.blog',
+ location: 'San Francisco',
+ email: null,
+ hireable: null,
+ twitter_username: null,
+ public_repos: 8,
+ public_gists: 8,
+ followers: 3938,
+ following: 9,
+ created_at: '2011-01-25T18:44:36Z',
+ updated_at: '2023-01-22T12:13:14Z',
+ },
+ stats: {
+ currentStreak: 5,
+ peakStreak: 15,
+ totalContributions: 500,
+ },
+ activity: [],
+ languages: [],
+ commitTimes: [],
+ achievements: [],
+ recommendations: [],
+ })),
+ fetchUserProfile: vi.fn().mockResolvedValue({ type: 'User', name: '' }),
+ fetchUserRepos: vi.fn().mockResolvedValue([]),
}));
// --- Mocking Core UI Blocks ---
@@ -222,7 +255,10 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
@@ -258,7 +294,10 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
@@ -279,7 +318,10 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
@@ -300,14 +342,17 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
const trendView = screen.getByTestId('historical-trend-view');
expect(JSON.parse(trendView.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity);
});
- it('calls notFound when dashboard data fetch throws an error', async () => {
- vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('User not found'));
+ it('calls notFound when fetchUserProfile throws an error', async () => {
+ vi.mocked(fetchUserProfile).mockRejectedValueOnce(new Error('Fetch failed'));
const SuspenseTree = await DashboardPage({
params: Promise.resolve({ username: 'missing-user' }),
diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx
index 6ee7ee08c..21d97249f 100644
--- a/app/burnout-analyzer/page.test.tsx
+++ b/app/burnout-analyzer/page.test.tsx
@@ -20,8 +20,17 @@ vi.mock('next/navigation', () => ({
}),
}));
+vi.mock('framer-motion', async () => {
+ const actual = await vi.importActual('framer-motion');
+ return {
+ ...actual as any,
+ AnimatePresence: ({ children }: any) => <>{children}>,
+ };
+});
+
describe('BurnoutAnalyzerPage repository input handling', () => {
beforeEach(() => {
+ vi.restoreAllMocks();
vi.clearAllMocks();
mockHistoryBack.mockReset();
mockRouterPush.mockReset();
@@ -115,14 +124,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
- Object.defineProperty(window, 'history', {
- value: { length: 2, back: mockHistoryBack },
- configurable: true,
- });
- Object.defineProperty(document, 'referrer', {
- value: 'http://localhost/burnout-analyzer',
- configurable: true,
- });
+ vi.spyOn(window.history, 'length', 'get').mockReturnValue(2);
+ vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any);
+ vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn());
+ vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/burnout-analyzer');
render();
fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), {
@@ -154,14 +159,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
- Object.defineProperty(window, 'history', {
- value: { length: 1, back: mockHistoryBack },
- configurable: true,
- });
- Object.defineProperty(document, 'referrer', {
- value: 'http://localhost/another-page',
- configurable: true,
- });
+ vi.spyOn(window.history, 'length', 'get').mockReturnValue(1);
+ vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any);
+ vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn());
+ vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/another-page');
render();
fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), {
From ee2e601fcb937de7a8e131e67cb51236aae68b4c Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 19:13:26 +0530
Subject: [PATCH 09/17] style: fix prettier formatting issues in test files
---
app/(root)/dashboard/[username]/page.test.tsx | 64 ++++++++++---------
app/burnout-analyzer/page.test.tsx | 2 +-
2 files changed, 34 insertions(+), 32 deletions(-)
diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx
index d28652544..4ab49e919 100644
--- a/app/(root)/dashboard/[username]/page.test.tsx
+++ b/app/(root)/dashboard/[username]/page.test.tsx
@@ -22,37 +22,39 @@ vi.mock('next/navigation', () => ({
}));
vi.mock('@/lib/github', () => ({
- getFullDashboardData: vi.fn().mockReturnValue(Promise.resolve({
- profile: {
- login: 'octocat',
- avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
- html_url: 'https://github.com/octocat',
- name: 'The Octocat',
- bio: null,
- company: '@github',
- blog: 'https://github.blog',
- location: 'San Francisco',
- email: null,
- hireable: null,
- twitter_username: null,
- public_repos: 8,
- public_gists: 8,
- followers: 3938,
- following: 9,
- created_at: '2011-01-25T18:44:36Z',
- updated_at: '2023-01-22T12:13:14Z',
- },
- stats: {
- currentStreak: 5,
- peakStreak: 15,
- totalContributions: 500,
- },
- activity: [],
- languages: [],
- commitTimes: [],
- achievements: [],
- recommendations: [],
- })),
+ getFullDashboardData: vi.fn().mockReturnValue(
+ Promise.resolve({
+ profile: {
+ login: 'octocat',
+ avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
+ html_url: 'https://github.com/octocat',
+ name: 'The Octocat',
+ bio: null,
+ company: '@github',
+ blog: 'https://github.blog',
+ location: 'San Francisco',
+ email: null,
+ hireable: null,
+ twitter_username: null,
+ public_repos: 8,
+ public_gists: 8,
+ followers: 3938,
+ following: 9,
+ created_at: '2011-01-25T18:44:36Z',
+ updated_at: '2023-01-22T12:13:14Z',
+ },
+ stats: {
+ currentStreak: 5,
+ peakStreak: 15,
+ totalContributions: 500,
+ },
+ activity: [],
+ languages: [],
+ commitTimes: [],
+ achievements: [],
+ recommendations: [],
+ })
+ ),
fetchUserProfile: vi.fn().mockResolvedValue({ type: 'User', name: '' }),
fetchUserRepos: vi.fn().mockResolvedValue([]),
}));
diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx
index 21d97249f..14cc27812 100644
--- a/app/burnout-analyzer/page.test.tsx
+++ b/app/burnout-analyzer/page.test.tsx
@@ -23,7 +23,7 @@ vi.mock('next/navigation', () => ({
vi.mock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion');
return {
- ...actual as any,
+ ...(actual as any),
AnimatePresence: ({ children }: any) => <>{children}>,
};
});
From e4251705ee2ce9a04aff3e83252a6214d6371906 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 19:49:00 +0530
Subject: [PATCH 10/17] fix: resolve ESLint errors and specific warnings
---
app/burnout-analyzer/page.test.tsx | 8 ++++----
services/github/webhook-handler.ts | 4 ++--
test-utils/timezone-mock.ts | 1 -
utils/copyToClipboard.test.ts | 2 +-
4 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx
index 14cc27812..eb7b7c089 100644
--- a/app/burnout-analyzer/page.test.tsx
+++ b/app/burnout-analyzer/page.test.tsx
@@ -23,8 +23,8 @@ vi.mock('next/navigation', () => ({
vi.mock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion');
return {
- ...(actual as any),
- AnimatePresence: ({ children }: any) => <>{children}>,
+ ...(actual as object),
+ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}>,
};
});
@@ -125,7 +125,7 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(window.history, 'length', 'get').mockReturnValue(2);
- vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any);
+ vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack);
vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn());
vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/burnout-analyzer');
@@ -160,7 +160,7 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(window.history, 'length', 'get').mockReturnValue(1);
- vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any);
+ vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack);
vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn());
vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/another-page');
diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts
index ef4760bee..28eb1db40 100644
--- a/services/github/webhook-handler.ts
+++ b/services/github/webhook-handler.ts
@@ -1,6 +1,6 @@
import { DistributedCache } from '@/lib/cache';
import { redactSecrets } from '@/lib/secretScanner';
-import type { CIWorkflowRun, CIInsights } from '@/types/ci-analytics';
+import type { Repository } from '@/types/ci-analytics';
interface WebhookPayload {
action?: string;
@@ -226,7 +226,7 @@ ${Object.entries(event.details)
.join('\n')}
`;
- console.log(`Email alert would be sent to ${email}:`, { subject, body });
+ console.info(`Email alert would be sent to ${email}:`, { subject, body });
} catch (error) {
console.error('Failed to send email alert:', error);
}
diff --git a/test-utils/timezone-mock.ts b/test-utils/timezone-mock.ts
index e0279d289..d3caebf9c 100644
--- a/test-utils/timezone-mock.ts
+++ b/test-utils/timezone-mock.ts
@@ -4,7 +4,6 @@
// Ensures that timezone-sensitive tests produce consistent results
// regardless of the system timezone (e.g., UTC on CI vs local dev timezone).
-import { vi } from 'vitest';
/**
* A map of IANA timezone identifiers to their UTC offsets in minutes.
diff --git a/utils/copyToClipboard.test.ts b/utils/copyToClipboard.test.ts
index ba4ec054a..baca7f3f1 100644
--- a/utils/copyToClipboard.test.ts
+++ b/utils/copyToClipboard.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
import { copyToClipboard } from './clipboard';
// ---------------------------------------------------------------------------
From 6ea91e71998ee9be91872aaf97b236cd1cb99bf9 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 21:02:50 +0530
Subject: [PATCH 11/17] fix: remove non-existent Repository type import
---
services/github/webhook-handler.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts
index 28eb1db40..0973faaf6 100644
--- a/services/github/webhook-handler.ts
+++ b/services/github/webhook-handler.ts
@@ -1,6 +1,5 @@
import { DistributedCache } from '@/lib/cache';
import { redactSecrets } from '@/lib/secretScanner';
-import type { Repository } from '@/types/ci-analytics';
interface WebhookPayload {
action?: string;
From b5d511bcf08e179d6251adb98c895e7c353e5d01 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Fri, 14 Aug 2026 21:23:13 +0530
Subject: [PATCH 12/17] style: run prettier on timezone-mock.ts
---
test-utils/timezone-mock.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/test-utils/timezone-mock.ts b/test-utils/timezone-mock.ts
index d3caebf9c..ba8313006 100644
--- a/test-utils/timezone-mock.ts
+++ b/test-utils/timezone-mock.ts
@@ -4,7 +4,6 @@
// Ensures that timezone-sensitive tests produce consistent results
// regardless of the system timezone (e.g., UTC on CI vs local dev timezone).
-
/**
* A map of IANA timezone identifiers to their UTC offsets in minutes.
* Positive values are east of UTC (ahead), negative values are west (behind).
From e4d3ce2a5be496c58710e46a8b3c5fb6666eeb6f Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Sat, 15 Aug 2026 00:11:44 +0530
Subject: [PATCH 13/17] feat: add multi-stage Docker builds (#876)
Resolves #876 by implementing multi-stage Docker build process to optimize image size and streamline deployment. Adds validation tests.
---
.dockerignore | 48 ++++++++++++++++++++++----
Dockerfile | 22 +++++++++---
README.md | 40 +++++++++++-----------
docker-compose.yml | 1 +
docs/self_hosting.md | 33 ++++++++++++++++++
tests/dockerfile.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++
6 files changed, 187 insertions(+), 30 deletions(-)
create mode 100644 tests/dockerfile.test.ts
diff --git a/.dockerignore b/.dockerignore
index 318bd7d39..5baf6c890 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,20 +1,54 @@
+# Dependencies & build outputs
node_modules
.next
-.github
+out
+build
+dist
+coverage
+.vitest
+
+# Source control & CI
.git
+.github
.gitignore
+.gitattributes
+.husky
-coverage
+# IDE & Editor settings
+.vscode
+.idea
+*.swp
+*.swo
+.DS_Store
+
+# Logs & debugging
*.log
npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+eslint_report.json
+eslint_errors.txt
+tsc_report.txt
-.env.local
+# Environment files
.env
+.env*.local
+.env.production
+.env.development
-Dockerfile
+# Local Docker & documentation
+Dockerfile*
+docker-compose*.yml
+.dockerignore
README.md
-
-.vscode
-.idea
+docs
+CHANGELOG.md
+CODE_OF_CONDUCT.md
+CONTRIBUTING.md
+LICENSE
+SECURITY.md
+THEMES.md
+THEME_DEVELOPMENT.md
!.env.local.example
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 379bb3082..ac5983045 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,33 +1,47 @@
-#base image
+# Base stage for common dependencies and environment setup
FROM node:22-alpine AS base
+# Install libc6-compat for compatibility with native libraries on Alpine Linux
+RUN apk add --no-cache libc6-compat
WORKDIR /app
+# Dependencies stage: Install dependencies cleanly based on package-lock.json
FROM base AS deps
COPY package*.json ./
RUN npm ci
+# Builder stage: Build Next.js application in standalone mode
FROM base AS builder
+WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
+
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV=production
+
RUN npm run build
-#production image
+# Runner stage: Production image containing only runtime dependencies
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
+COPY --from=builder /app/public ./public
+
+# Set up runtime permissions for Next.js cache directory
+RUN mkdir .next && chown nextjs:nodejs .next
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
-CMD [ "node", "server.js" ]
\ No newline at end of file
+CMD ["node", "server.js"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 5ec644faf..1a9cf8139 100644
--- a/README.md
+++ b/README.md
@@ -239,16 +239,23 @@ npm run dev
Then visit: `http://localhost:3000/api/streak?user=YOUR_USERNAME`
-## 🐳 Docker
+## 🐳 Docker Multi-Stage Deployment
-CommitPulse includes Docker support for consistent local development and production deployments.
+CommitPulse features lightweight, optimized multi-stage Docker builds to streamline deployment and minimize container image size.
+
+### Build Stages Architecture
+
+- **`base`**: Node 22 Alpine base image with `libc6-compat` native library support.
+- **`deps`**: Installs application dependencies cleanly via `npm ci`.
+- **`builder`**: Compiles Next.js into a production standalone bundle (`.next/standalone`).
+- **`runner`**: Minimal production environment running as an unprivileged non-root (`nextjs`) user listening on `0.0.0.0:3000`.
### Prerequisites
-- Docker
-- Docker Compose
+- Docker Engine 20.10+
+- Docker Compose v2+
-### Local Development
+### Local Development with Docker Compose
1. Copy the example environment file:
@@ -256,7 +263,7 @@ CommitPulse includes Docker support for consistent local development and product
cp .env.local.example .env.local
```
-2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and any optional integrations you plan to use).
+2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and optional integrations).
3. Start the application and MongoDB:
@@ -264,29 +271,24 @@ cp .env.local.example .env.local
docker compose up --build
```
-The application will be available at:
-
-```text
-http://localhost:3000
-```
-
-MongoDB is automatically provisioned through Docker Compose. The `MONGODB_URI` is overridden to use the local MongoDB container, so no additional database configuration is required.
+The application will be available at `http://localhost:3000`. MongoDB is automatically provisioned and linked (`MONGODB_URI=mongodb://mongodb:27017/commitpulse`).
-### Production
+### Multi-Stage Production Build
-Build the production image:
+Build the optimized production image using the `runner` target:
```bash
-docker build -t commitpulse .
+docker build --target runner -t commitpulse:latest .
```
-Run the container:
+Run the production container:
```bash
-docker run \
+docker run -d \
+ --name commitpulse \
--env-file .env.local \
-p 3000:3000 \
- commitpulse
+ commitpulse:latest
```
### 🌐 Deploy to Vercel
diff --git a/docker-compose.yml b/docker-compose.yml
index 60098dadb..fd814af01 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,6 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
+ target: runner
container_name: commitpulse-app
ports:
- '3000:3000'
diff --git a/docs/self_hosting.md b/docs/self_hosting.md
index 79ac8bfcd..2fd412e79 100644
--- a/docs/self_hosting.md
+++ b/docs/self_hosting.md
@@ -79,6 +79,39 @@ Badge/SVG contribution data is cached and refreshes automatically once the cache
This step is entirely optional — without it, badges still update on their own once the cache expires.
+## 🐳 Containerized Self-Hosting (Docker Multi-Stage)
+
+CommitPulse includes a production-grade multi-stage `Dockerfile` (`base` → `deps` → `builder` → `runner`) to deliver a lightweight container footprint and secure execution as an unprivileged user (`nextjs`).
+
+### Option 1: Docker Compose (Recommended)
+
+1. Ensure `.env.local` exists with your `GITHUB_TOKEN`.
+2. Start the full stack (CommitPulse application + MongoDB):
+
+```bash
+docker compose up -d --build
+```
+
+3. Access the application at `http://localhost:3000`.
+
+### Option 2: Standalone Multi-Stage Docker Image
+
+1. Build the production image targeting the `runner` stage:
+
+```bash
+docker build --target runner -t commitpulse:latest .
+```
+
+2. Run the container:
+
+```bash
+docker run -d \
+ --name commitpulse \
+ --env-file .env.local \
+ -p 3000:3000 \
+ commitpulse:latest
+```
+
---
## 🌐 Deploy Your Own
diff --git a/tests/dockerfile.test.ts b/tests/dockerfile.test.ts
new file mode 100644
index 000000000..c31cd4286
--- /dev/null
+++ b/tests/dockerfile.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+
+describe('Docker Multi-Stage Containerization Setup', () => {
+ const rootDir = path.resolve(__dirname, '..');
+ const dockerfilePath = path.join(rootDir, 'Dockerfile');
+ const dockerignorePath = path.join(rootDir, '.dockerignore');
+ const dockerComposePath = path.join(rootDir, 'docker-compose.yml');
+ const nextConfigPath = path.join(rootDir, 'next.config.ts');
+
+ it('should have Dockerfile configured with multi-stage build stages', () => {
+ expect(fs.existsSync(dockerfilePath)).toBe(true);
+ const content = fs.readFileSync(dockerfilePath, 'utf-8');
+
+ // Multi-stage stages
+ expect(content).toMatch(/FROM\s+node:22-alpine\s+AS\s+base/i);
+ expect(content).toMatch(/FROM\s+base\s+AS\s+deps/i);
+ expect(content).toMatch(/FROM\s+base\s+AS\s+builder/i);
+ expect(content).toMatch(/FROM\s+node:22-alpine\s+AS\s+runner/i);
+ });
+
+ it('should include necessary Alpine compatibility libraries and environment variables', () => {
+ const content = fs.readFileSync(dockerfilePath, 'utf-8');
+
+ expect(content).toContain('libc6-compat');
+ expect(content).toContain('ENV HOSTNAME="0.0.0.0"');
+ expect(content).toContain('ENV NODE_ENV=production');
+ expect(content).toContain('ENV NEXT_TELEMETRY_DISABLED=1');
+ });
+
+ it('should run as non-root user for security compliance', () => {
+ const content = fs.readFileSync(dockerfilePath, 'utf-8');
+
+ expect(content).toContain('addgroup');
+ expect(content).toContain('adduser');
+ expect(content).toContain('USER nextjs');
+ });
+
+ it('should leverage Next.js standalone build artifacts', () => {
+ const content = fs.readFileSync(dockerfilePath, 'utf-8');
+
+ expect(content).toContain('.next/standalone');
+ expect(content).toContain('.next/static');
+ expect(content).toContain('public');
+ });
+
+ it('should exclude unnecessary build files and secrets in .dockerignore', () => {
+ expect(fs.existsSync(dockerignorePath)).toBe(true);
+ const content = fs.readFileSync(dockerignorePath, 'utf-8');
+
+ expect(content).toContain('node_modules');
+ expect(content).toContain('.next');
+ expect(content).toContain('.git');
+ expect(content).toContain('.env*.local');
+ expect(content).toContain('coverage');
+ });
+
+ it('should target the runner stage in docker-compose.yml', () => {
+ expect(fs.existsSync(dockerComposePath)).toBe(true);
+ const content = fs.readFileSync(dockerComposePath, 'utf-8');
+
+ expect(content).toContain('dockerfile: Dockerfile');
+ expect(content).toContain('target: runner');
+ });
+
+ it('should have next.config.ts set to standalone output mode', () => {
+ expect(fs.existsSync(nextConfigPath)).toBe(true);
+ const content = fs.readFileSync(nextConfigPath, 'utf-8');
+
+ expect(content).toContain("output: 'standalone'");
+ });
+});
From 4678e4a0a406dee56753b3344ed3b011cc11bde1 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Sat, 15 Aug 2026 21:00:08 +0530
Subject: [PATCH 14/17] fix(api): validate invalid user query in /api/streak
endpoint
---
app/api/streak/route.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts
index d0d3df85c..c9f0b57aa 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -102,6 +102,24 @@ export async function GET(request: Request) {
fieldErrors.formErrors[0] ??
'Invalid parameters';
+ if (
+ firstError === 'Missing user parameter' ||
+ firstError === 'Invalid GitHub username' ||
+ firstError === 'GitHub username cannot exceed 39 characters'
+ ) {
+ return NextResponse.json(
+ { success: false, message: 'Invalid or missing user parameter' },
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'X-Request-ID': requestId,
+ },
+ }
+ );
+ }
+
if (searchParams.get('format') === 'json') {
return NextResponse.json(
{ error: firstError },
From bcb764348387c616e9b77381c38d5fcf29693bd8 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Sun, 16 Aug 2026 00:57:20 +0530
Subject: [PATCH 15/17] fix: address false-positive sqli on login/signup routes
(#554)
---
app/api/auth/login/route.ts | 12 ++++++++++++
app/api/auth/signup/route.ts | 14 ++++++++++++++
2 files changed, 26 insertions(+)
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 3c1031007..46da830db 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -19,6 +19,18 @@ export async function POST(request: Request): Promise {
);
}
+ // Sanitize and validate input to prevent SQL/NoSQL injection payloads
+ const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
+ const isUsername = /^[a-zA-Z0-9_-]{3,39}$/.test(identifier);
+
+ if (!isEmail && !isUsername) {
+ return NextResponse.json({ error: 'Invalid Email or Username format.' }, { status: 400 });
+ }
+
+ if (password.length < 1 || password.length > 255) {
+ return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 });
+ }
+
return NextResponse.json(
{
success: true,
diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts
index a763e85d9..6d292355b 100644
--- a/app/api/auth/signup/route.ts
+++ b/app/api/auth/signup/route.ts
@@ -20,6 +20,20 @@ export async function POST(request: Request): Promise {
);
}
+ // Sanitize and validate input to prevent SQL/NoSQL injection payloads
+ const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+ if (!isEmail) {
+ return NextResponse.json({ error: 'Invalid Email format.' }, { status: 400 });
+ }
+
+ if (fullName.length < 2 || fullName.length > 100) {
+ return NextResponse.json({ error: 'Invalid Full Name format.' }, { status: 400 });
+ }
+
+ if (password.length < 1 || password.length > 255) {
+ return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 });
+ }
+
return NextResponse.json(
{
success: true,
From b7fbe27f0dd9ccf12a921fd1747caa07dd1ce3ca Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Sun, 16 Aug 2026 02:15:13 +0530
Subject: [PATCH 16/17] fix: resolve CodeQL alerts with Zod validation
---
app/api/auth/login/route.ts | 21 +++++++++++----------
app/api/auth/signup/route.ts | 27 ++++++++++++---------------
2 files changed, 23 insertions(+), 25 deletions(-)
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 46da830db..2c8e83fe4 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,4 +1,10 @@
import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+const loginSchema = z.object({
+ identifier: z.string().min(3).max(255),
+ password: z.string().min(1).max(255),
+});
/**
* Handles credentials authentication requests for frontend login.
@@ -7,18 +13,17 @@ import { NextResponse } from 'next/server';
export async function POST(request: Request): Promise {
try {
const body = await request.json();
- const { identifier, password } = body as {
- identifier?: string;
- password?: string;
- };
+ const result = loginSchema.safeParse(body);
- if (!identifier || !password) {
+ if (!result.success) {
return NextResponse.json(
- { error: 'Email or Username and Password are required.' },
+ { error: 'Email or Username and Password are required and must be valid.' },
{ status: 400 }
);
}
+ const { identifier } = result.data;
+
// Sanitize and validate input to prevent SQL/NoSQL injection payloads
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
const isUsername = /^[a-zA-Z0-9_-]{3,39}$/.test(identifier);
@@ -27,10 +32,6 @@ export async function POST(request: Request): Promise {
return NextResponse.json({ error: 'Invalid Email or Username format.' }, { status: 400 });
}
- if (password.length < 1 || password.length > 255) {
- return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 });
- }
-
return NextResponse.json(
{
success: true,
diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts
index 6d292355b..723db85e3 100644
--- a/app/api/auth/signup/route.ts
+++ b/app/api/auth/signup/route.ts
@@ -1,4 +1,11 @@
import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+const signupSchema = z.object({
+ fullName: z.string().min(2).max(100),
+ email: z.string().email(),
+ password: z.string().min(1).max(255),
+});
/**
* Handles user registration requests for frontend signup.
@@ -7,33 +14,23 @@ import { NextResponse } from 'next/server';
export async function POST(request: Request): Promise {
try {
const body = await request.json();
- const { fullName, email, password } = body as {
- fullName?: string;
- email?: string;
- password?: string;
- };
+ const result = signupSchema.safeParse(body);
- if (!fullName || !email || !password) {
+ if (!result.success) {
return NextResponse.json(
- { error: 'Full Name, Email, and Password are required.' },
+ { error: 'Full Name, Email, and Password are required and must be valid.' },
{ status: 400 }
);
}
+ const { fullName, email } = result.data;
+
// Sanitize and validate input to prevent SQL/NoSQL injection payloads
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
if (!isEmail) {
return NextResponse.json({ error: 'Invalid Email format.' }, { status: 400 });
}
- if (fullName.length < 2 || fullName.length > 100) {
- return NextResponse.json({ error: 'Invalid Full Name format.' }, { status: 400 });
- }
-
- if (password.length < 1 || password.length > 255) {
- return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 });
- }
-
return NextResponse.json(
{
success: true,
From 64a0eb9f1fbbe35f3189c3da533f42f0c7839449 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Sun, 16 Aug 2026 11:04:53 +0530
Subject: [PATCH 17/17] fix(api): restore SVG error rendering for invalid user
query
---
app/api/streak/route.ts | 18 ------------------
1 file changed, 18 deletions(-)
diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts
index c9f0b57aa..d0d3df85c 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -102,24 +102,6 @@ export async function GET(request: Request) {
fieldErrors.formErrors[0] ??
'Invalid parameters';
- if (
- firstError === 'Missing user parameter' ||
- firstError === 'Invalid GitHub username' ||
- firstError === 'GitHub username cannot exceed 39 characters'
- ) {
- return NextResponse.json(
- { success: false, message: 'Invalid or missing user parameter' },
- {
- status: 400,
- headers: {
- 'Content-Type': 'application/json',
- 'Cache-Control': 'no-store',
- 'X-Request-ID': requestId,
- },
- }
- );
- }
-
if (searchParams.get('format') === 'json') {
return NextResponse.json(
{ error: firstError },