Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1653,7 +1653,7 @@ describe('GET /api/streak', () => {
expect(fetchGitHubContributions).not.toHaveBeenCalled();

const body = await response.text();
expect(body).toContain('strictly accepts a maximum of 2 usernames');
expect(body).toContain('a maximum of 2 usernames');
});
});

Expand Down
42 changes: 29 additions & 13 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
calculateStreak,
calculateMonthlyStats,
aggregateCalendars,
convertLocalToUtc,

Check warning on line 15 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'convertLocalToUtc' is defined but never used
chunkDaysIntoWeeks,
normalizeCalendarToTimezone,
isLeapYear,

Check warning on line 18 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'isLeapYear' is defined but never used
daysInYear,
} from '@/lib/calculate';
import {
Expand Down Expand Up @@ -712,7 +712,23 @@
if (message.includes('schema') || message.includes('Schema')) {
return 'Invalid request parameters';
}
return message;
// Preserve user-facing validation messages — these are intentional,
// safe error strings thrown by route-level validation and do not
// expose internal implementation details.
const lower = message.toLowerCase();
if (lower.includes('strictly for organizations')) {
return 'This endpoint is strictly for organizations.';
}
if (lower.includes('strictly accepts a maximum of 2')) {
return 'The streak comparison generator strictly accepts a maximum of 2 usernames.';
}
if (lower.includes('quota is low')) {
return 'API rate limit quota is low. Please try again later.';
}
// Issue #7263: Return a generic message for all other errors to
// prevent leaking internal implementation details (auth state, cache
// servers, token rotation info, etc.) to the client.
return 'Something went wrong. Please try again later.';
}

function buildErrorResponse(error: unknown, parseResult: ParseResult): NextResponse {
Expand All @@ -721,14 +737,14 @@

if (parseResult.success && parseResult.data.format === 'json') {
const isNotFound =
message.toLowerCase().includes('not found') ||
message.toLowerCase().includes('could not resolve');
const isRateLimit = message.toLowerCase().includes('rate limit');
rawMessage.toLowerCase().includes('not found') ||
rawMessage.toLowerCase().includes('could not resolve');
const isRateLimit = rawMessage.toLowerCase().includes('rate limit');
const isValidationError =
(error instanceof Error && error.name === 'ValidationError') ||
message.toLowerCase().includes('invalid') ||
message.toLowerCase().includes('validation') ||
message.toLowerCase().includes('strictly for organizations');
rawMessage.toLowerCase().includes('invalid') ||
rawMessage.toLowerCase().includes('validation') ||
rawMessage.toLowerCase().includes('strictly for organizations');

const status = isRateLimit ? 429 : isNotFound ? 404 : isValidationError ? 400 : 500;
const jsonErrorHeaders: Record<string, string> = {
Expand All @@ -748,16 +764,16 @@
}

const isNotFound =
message.toLowerCase().includes('not found') ||
message.toLowerCase().includes('could not resolve');
const isRateLimit = message.toLowerCase().includes('rate limit');
rawMessage.toLowerCase().includes('not found') ||
rawMessage.toLowerCase().includes('could not resolve');
const isRateLimit = rawMessage.toLowerCase().includes('rate limit');

// 2. Safely detect if the error was a validation/client error
const isValidationError =
(error instanceof Error && error.name === 'ValidationError') ||
message.toLowerCase().includes('invalid') ||
message.toLowerCase().includes('validation') ||
message.toLowerCase().includes('strictly for organizations');
rawMessage.toLowerCase().includes('invalid') ||
rawMessage.toLowerCase().includes('validation') ||
rawMessage.toLowerCase().includes('strictly for organizations');

const errBg = `#${sanitizeHexColor(parseResult.success ? parseResult.data.bg : undefined, '0d1117')}`;
const errAccentRaw =
Expand Down
49 changes: 37 additions & 12 deletions app/compare/CompareClient.mouse-interactivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import CompareClient from './CompareClient';
import React, { type ReactNode } from 'react';

const { mockRouter, mockSearchParams } = vi.hoisted(() => ({
mockRouter: { replace: vi.fn() },
mockSearchParams: { get: vi.fn(() => null) },
}));
// Mirror Next.js: router.replace updates the URL, and useSearchParams reflects it.
// Without this, the auto-compare effect (which calls setData(null) when the URL has
// no user params) races with the manual compare's setData(json) and wipes the
// just-rendered result, making the heatmap/habit assertions flaky.
const { mockRouter, mockSearchParams } = vi.hoisted(() => {
const params = new Map<string, string>();
return {
mockRouter: {
replace: vi.fn((url: string) => {
params.clear();
const query = String(url).split('?')[1] ?? '';
for (const [key, value] of new URLSearchParams(query)) {
params.set(key, value);
}
}),
},
mockSearchParams: { get: vi.fn((key: string) => params.get(key) ?? null) },
};
});

vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
Expand Down Expand Up @@ -119,6 +134,14 @@ describe('CompareClient Interactive Tooltips, Cursor Hovers & Touch Event Propag
vi.clearAllMocks();
window.localStorage.clear();

// Prevent the Cache API from interfering with test isolation
// (readCompareCache consults window.caches which may hold stale data)
Object.defineProperty(window, 'caches', {
value: undefined,
writable: true,
configurable: true,
});

global.fetch = vi.fn(
async () =>
({
Expand Down Expand Up @@ -186,20 +209,22 @@ describe('CompareClient Interactive Tooltips, Cursor Hovers & Touch Event Propag

fireEvent.click(screen.getByRole('button', { name: /compare/i }));

await waitFor(() => {
expect(screen.getByText(/coding habits/i)).toBeInTheDocument();
});
await waitFor(
() => {
expect(screen.getByText(/coding habits/i)).toBeInTheDocument();
},
{ timeout: 5000 }
);

const habitCards = screen.getAllByRole('heading', { level: 3 });
const userAHabit = habitCards.find((c) => c.textContent === 'Night Owl');
const userBHabit = habitCards.find((c) => c.textContent === 'Early Bird');
const userAHabit = await screen.findByText('Night Owl', {}, { timeout: 5000 });
const userBHabit = await screen.findByText('Early Bird', {}, { timeout: 5000 });

expect(userAHabit).toBeInTheDocument();
expect(userBHabit).toBeInTheDocument();

// Trigger hover events to verify standard scale and glow hover properties
const containerA = userAHabit!.closest('div');
const containerB = userBHabit!.closest('div');
const containerA = userAHabit.closest('div');
const containerB = userBHabit.closest('div');

expect(containerA).toHaveClass('transition-all');
expect(containerB).toHaveClass('transition-all');
Expand Down
8 changes: 8 additions & 0 deletions app/compare/page.mouse-interactivity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ describe('CompareClient Mouse Interactivity & Touch Events', () => {
resetSearchParams();
window.localStorage.clear();

// Prevent the Cache API from interfering with test isolation
// (readCompareCache consults window.caches which may hold stale data)
Object.defineProperty(window, 'caches', {
value: undefined,
writable: true,
configurable: true,
});

global.fetch = vi.fn(
async () =>
({
Expand Down
Loading