Skip to content

Commit 9ad4527

Browse files
authored
Merge branch 'main' into fix/distributed-cache-stampede
2 parents c0b7941 + 61f67f3 commit 9ad4527

15 files changed

Lines changed: 2062 additions & 319 deletions

app/api/notify/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
import { NextRequest } from 'next/server';
33
import { GET, POST } from './route';
4+
import dbConnect from '@/lib/mongodb';
45

56
// Mock dependencies
67
vi.mock('@/lib/mongodb', () => ({ default: vi.fn() }));
@@ -88,6 +89,7 @@ describe('POST /api/notify', () => {
8889
expect(res.status).toBe(400);
8990
const data = await res.json();
9091
expect(data.message).toContain('Malformed JSON');
92+
expect(dbConnect).not.toHaveBeenCalled();
9193
});
9294

9395
// ── Rate limiting ────────────────────────────────────────────────────────

app/api/streak/route.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ describe('GET /api/streak', () => {
103103
});
104104

105105
describe('parameter validation', () => {
106+
it('returns 400 when grace=-1 is provided', async () => {
107+
const response = await GET(
108+
makeRequest({
109+
user: 'octocat',
110+
grace: '-1',
111+
})
112+
);
113+
expect(response.status).toBe(400);
114+
const body = await response.json();
115+
expect(body.details.fieldErrors.grace[0]).toBe('grace must be an integer between 0 and 7');
116+
});
117+
106118
it('returns 400 Bad Request when ?layout= is set to an unsupported format', async () => {
107119
const response = await GET(
108120
makeRequest({
@@ -305,18 +317,15 @@ describe('GET /api/streak', () => {
305317
expect(fetchGitHubContributions).toHaveBeenCalled();
306318
});
307319

308-
it('returns valid SVG when grace exceeds max value', async () => {
320+
it('returns 400 when grace exceeds max value', async () => {
309321
const response = await GET(
310322
makeRequest({
311323
user: 'octocat',
312324
grace: '999',
313325
})
314326
);
315327

316-
expect(response.status).toBe(200);
317-
318-
const body = await response.text();
319-
expect(body).toContain('<svg');
328+
expect(response.status).toBe(400);
320329
});
321330

322331
it('embeds the username (uppercased) in the SVG title', async () => {

app/customize/components/ControlsPanel.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,35 @@ export function ControlsPanel({
277277

278278
<ControlRow label="Font">
279279
<div className="relative">
280-
<StyledSelect id="font-select" value={font} onChange={(v) => onFontChange(v as Font)}>
280+
<StyledSelect
281+
id="font-select"
282+
value={FONTS.some((f) => f.value === font) ? font : 'custom'}
283+
onChange={(v) => {
284+
if (v === 'custom') {
285+
onFontChange('' as Font);
286+
} else {
287+
onFontChange(v as Font);
288+
}
289+
}}
290+
>
281291
{FONTS.map((fontOption) => (
282292
<option key={fontOption.value} value={fontOption.value}>
283293
{fontOption.label}
284294
</option>
285295
))}
296+
<option value="custom">Custom Google Font...</option>
286297
</StyledSelect>
287298
</div>
299+
{!FONTS.some((f) => f.value === font) && (
300+
<input
301+
id="font-custom-input"
302+
type="text"
303+
value={font}
304+
onChange={(e) => onFontChange(e.target.value as Font)}
305+
placeholder="e.g. Orbitron, Space Mono, Inter"
306+
className="w-full bg-gray-100/80 backdrop-blur-md border border-black/10 dark:bg-white/[0.03] dark:border-white/10 rounded-xl px-4 py-2.5 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/60 outline-none focus:border-emerald-500/50 transition-colors mt-2"
307+
/>
308+
)}
288309
</ControlRow>
289310

290311
<ControlRow label="Border Radius">

app/customize/components/ThemeQuickPresets.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,28 @@
9292
border-radius: 50%;
9393
background: rgba(255, 255, 255, 0.85);
9494
}
95+
.theme-quick-presets {
96+
display: flex;
97+
flex-wrap: wrap;
98+
gap: 6px;
99+
width: 100%;
100+
max-width: 100%;
101+
}
102+
103+
@media (max-width: 640px) {
104+
.theme-quick-presets {
105+
flex-wrap: nowrap;
106+
overflow-x: auto;
107+
overflow-y: hidden;
108+
width: 100%;
109+
max-width: calc(100vw - 96px);
110+
padding-bottom: 8px;
111+
scroll-snap-type: x proximity;
112+
-webkit-overflow-scrolling: touch;
113+
}
114+
115+
.theme-quick-presets .tqp-btn {
116+
flex: 0 0 auto;
117+
scroll-snap-align: start;
118+
}
119+
}

app/customize/components/ThemeQuickPresets.test.tsx

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,31 +86,76 @@ describe('ThemeQuickPresets', () => {
8686
});
8787
});
8888

89-
describe('ThemeQuickPresets responsive rendering', () => {
89+
describe('ThemeQuickPresets responsive rendering & high-contrast', () => {
9090
const onThemeChange = vi.fn();
9191

9292
beforeEach(() => {
9393
onThemeChange.mockClear();
9494
});
9595

96-
it('renders every preset with accessible labels, high-contrast styling, and wrap layout', () => {
96+
it('checks rendering of preset buttons on sm and lg viewports', () => {
97+
window.innerWidth = 375;
98+
window.dispatchEvent(new Event('resize'));
99+
100+
const { rerender } = render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
101+
102+
expect(screen.getAllByRole('button', { name: /apply .+ theme/i })).toHaveLength(
103+
validKeys.length
104+
);
105+
106+
window.innerWidth = 1280;
107+
window.dispatchEvent(new Event('resize'));
108+
109+
rerender(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
110+
111+
expect(screen.getAllByRole('button', { name: /apply .+ theme/i })).toHaveLength(
112+
validKeys.length
113+
);
114+
});
115+
116+
it('checks rendering of all preset buttons with accessible labels', () => {
97117
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
98118

99119
const presetButtons = screen.getAllByRole('button', { name: /apply .+ theme/i });
100120
expect(presetButtons).toHaveLength(validKeys.length);
121+
});
101122

123+
it('active preset is announced to screen readers via aria-pressed', () => {
124+
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
102125
expect(
103126
screen.getByRole('button', { name: /apply dark theme/i }).getAttribute('aria-pressed')
104127
).toBe('true');
128+
});
105129

106-
const highContrastButton = screen.getByRole('button', { name: /apply highcontrast theme/i });
107-
expect(highContrastButton).toBeDefined();
108-
expect(highContrastButton.getAttribute('title')).toBe('Highcontrast');
109-
expect(highContrastButton.getAttribute('style')).toContain('rgb(10, 10, 10)');
130+
it('check if wrapper div uses flex and wrap so buttons reflow on narrow viewports', () => {
131+
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
132+
const buttons = screen.getAllByRole('button');
133+
const grid = buttons.at(0)?.parentElement;
110134

111-
const grid = highContrastButton.closest('div');
112135
expect(grid).not.toBeNull();
113-
expect(grid?.style.display).toBe('flex');
114-
expect(grid?.style.flexWrap).toBe('wrap');
136+
expect(grid?.className).toContain('theme-quick-presets');
137+
});
138+
139+
it('check if each preset button has theme bg colour(inline)', () => {
140+
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
141+
const buttons = screen.getAllByRole('button');
142+
143+
buttons.forEach((btn) => {
144+
expect(btn.getAttribute('style')).not.toBeNull();
145+
expect(btn.getAttribute('style')).toMatch(/background/i);
146+
});
147+
});
148+
149+
it('checking if highcontrast is inactive when different theme is active', () => {
150+
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
151+
const hcBtn = screen.getByRole('button', { name: /apply highcontrast theme/i });
152+
153+
expect(hcBtn.getAttribute('aria-pressed')).toBe('false');
154+
});
155+
156+
it('check if highcontrast button becomes active when selected', () => {
157+
render(<ThemeQuickPresets theme="highcontrast" onThemeChange={onThemeChange} />);
158+
const hcBtn = screen.getByRole('button', { name: /apply highcontrast theme/i });
159+
expect(hcBtn.getAttribute('aria-pressed')).toBe('true');
115160
});
116161
});

app/customize/components/ThemeQuickPresets.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ const ICON_MAP: Record<string, (c: IC) => ReactElement> = {
519519
export function ThemeQuickPresets({ theme, onThemeChange }: ThemeQuickPresetsProps): ReactElement {
520520
return (
521521
<>
522-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
522+
<div className="theme-quick-presets">
523523
{THEME_KEYS.filter((key) => key !== 'auto' && key !== 'random').map((key) => {
524524
const t = themes[key as ThemeKey];
525525
if (!t) return null;

app/customize/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const FONTS = [
3434
{ value: 'roboto', label: 'Roboto' },
3535
] as const satisfies readonly { value: string; label: string }[];
3636

37-
export type Font = (typeof FONTS)[number]['value'];
37+
export type Font = (typeof FONTS)[number]['value'] | string;
3838

3939
export const VIEW_MODES = [
4040
{ value: 'default', label: 'Default' },

app/layout.tsx

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

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

components/dashboard/RadarChart.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ describe('RadarChart', () => {
7777
expect(screen.getAllByText('Python')).toBeDefined();
7878
});
7979

80+
it('deduplicates shared languages so TypeScript appears as a single axis label', () => {
81+
const langsA = [{ name: 'TypeScript', percentage: 70, color: '#3178c6' }];
82+
const langsB = [{ name: 'TypeScript', percentage: 50, color: '#3178c6' }];
83+
84+
render(<RadarChart languagesA={langsA} languagesB={langsB} labelA="User A" labelB="User B" />);
85+
86+
// TypeScript should appear exactly once as an axis label (SVG <text>) and once
87+
// in the bottom stats table — 2 total, not 4 (which would indicate two separate axes)
88+
expect(screen.getAllByText('TypeScript')).toHaveLength(2);
89+
});
90+
8091
it('scales axis points dynamically based on max score in data', () => {
8192
const highScoreLangs = [
8293
{ name: 'TypeScript', percentage: 100, color: '#3178c6' },

lib/cache.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,51 @@ describe('TTLCache', () => {
447447
cache.destroy();
448448
});
449449

450+
it('preserves Date instance with current timestamp (new Date())', () => {
451+
const cache = new TTLCache<Date>();
452+
453+
const now = new Date();
454+
455+
cache.set('current-date', now, 60_000);
456+
457+
const cached = cache.get('current-date');
458+
459+
expect(cached).toBeInstanceOf(Date);
460+
expect(cached?.getTime()).toBe(now.getTime());
461+
expect(cached?.toISOString()).toBe(now.toISOString());
462+
463+
cache.destroy();
464+
});
465+
466+
it('preserves Date instance nested in object with mixed types', () => {
467+
const cache = new TTLCache<{
468+
id: number;
469+
name: string;
470+
created: Date;
471+
isActive: boolean;
472+
}>();
473+
474+
const created = new Date('2024-03-15T10:30:45.123Z');
475+
const data = {
476+
id: 42,
477+
name: 'Test Event',
478+
created: created,
479+
isActive: true,
480+
};
481+
482+
cache.set('event', data, 60_000);
483+
484+
const cached = cache.get('event');
485+
486+
expect(cached?.id).toBe(42);
487+
expect(cached?.name).toBe('Test Event');
488+
expect(cached?.isActive).toBe(true);
489+
expect(cached?.created).toBeInstanceOf(Date);
490+
expect(cached?.created.toISOString()).toBe(created.toISOString());
491+
492+
cache.destroy();
493+
});
494+
450495
it('stores and retrieves nested object values', () => {
451496
const cache = new TTLCache<{
452497
user: { id: number; name: string };
@@ -504,6 +549,34 @@ describe('TTLCache', () => {
504549
cache.destroy();
505550
});
506551

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

0 commit comments

Comments
 (0)