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.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx index 4ed6853c0..4ab49e919 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,41 @@ 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 +257,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 +296,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 +320,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 +344,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/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx index 7063302b7..a1eda1ecc 100644 --- a/app/(root)/dashboard/[username]/page.tsx +++ b/app/(root)/dashboard/[username]/page.tsx @@ -136,36 +136,29 @@ 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}`); + } + + const data = getFullDashboardData(username, { + bypassCache, + from: period.from, + to: period.to, + rangeLabel: period.label, + token: userToken, + excludeBots, + }); + let allRepos: RepoActivityInfo[] = []; try { const reposData = await fetchUserRepos(username, { bypassCache, token: userToken }); @@ -196,7 +189,7 @@ async function DashboardContent({ { 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 44e5e205c..cb45c205c 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -101,6 +101,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, @@ -1035,7 +1050,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/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx index 6ee7ee08c..eb7b7c089 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 object), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{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); + 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); + 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), { diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx index 0b60ee194..77e07b68b 100644 --- a/components/dashboard/DashboardClient.tsx +++ b/components/dashboard/DashboardClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { copyToClipboard } from '@/utils/clipboard'; -import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from 'react'; +import { useState, useRef, useEffect, useCallback, useSyncExternalStore, use } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'framer-motion'; import DashboardSkeleton from './DashboardSkeleton'; @@ -106,8 +106,9 @@ export interface DashboardData { rawCommits?: string[]; } -interface DashboardClientProps { - initialData: DashboardData; +export interface DashboardClientProps { + initialDataPromise?: Promise; + initialData?: DashboardData; allRepoActivity?: RepoActivityInfo[]; username: string; compareData?: DashboardData | null; @@ -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' diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx index a9d0bcb87..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 } 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 +20,31 @@ 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 +64,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 +77,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>(); }); }); 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 b1834c1d1..32734bcbb 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -233,8 +233,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({ @@ -693,6 +694,11 @@ 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'], @@ -1240,6 +1246,11 @@ 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'], diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts index ef4760bee..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 { CIWorkflowRun, CIInsights } from '@/types/ci-analytics'; interface WebhookPayload { action?: string; @@ -226,7 +225,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..ba8313006 100644 --- a/test-utils/timezone-mock.ts +++ b/test-utils/timezone-mock.ts @@ -4,8 +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. * Positive values are east of UTC (ahead), negative values are west (behind). 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'; // ---------------------------------------------------------------------------