Skip to content

Commit 235c378

Browse files
Merge branch 'main' into feat/grace-range-validation
2 parents 2b951db + c6e25fa commit 235c378

4 files changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// components/dashboard/VisualizationTooltip.test.tsx
2+
import { render, screen } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import type { HTMLAttributes, ReactNode } from 'react';
5+
import VisualizationTooltip from './VisualizationTooltip';
6+
import '@testing-library/jest-dom/vitest';
7+
8+
// framer-motion mock
9+
vi.mock('framer-motion', () => ({
10+
motion: {
11+
div: ({ children, ...props }: HTMLAttributes<HTMLDivElement> & { children?: ReactNode }) => (
12+
<div {...props}>{children}</div>
13+
),
14+
},
15+
}));
16+
17+
describe('VisualizationTooltip', () => {
18+
it('renders tooltip role', () => {
19+
render(
20+
<VisualizationTooltip title="Tooltip Title" x={100} y={200}>
21+
Content
22+
</VisualizationTooltip>
23+
);
24+
25+
expect(screen.getByRole('tooltip')).toBeInTheDocument();
26+
});
27+
28+
it('renders title correctly', () => {
29+
render(
30+
<VisualizationTooltip title="Commits" x={100} y={200}>
31+
Content
32+
</VisualizationTooltip>
33+
);
34+
35+
expect(screen.getByText('Commits')).toBeInTheDocument();
36+
});
37+
38+
it('renders children content', () => {
39+
render(
40+
<VisualizationTooltip title="Title" x={100} y={200}>
41+
<span>5 contributions</span>
42+
</VisualizationTooltip>
43+
);
44+
45+
expect(screen.getByText('5 contributions')).toBeInTheDocument();
46+
});
47+
48+
it('applies x and y coordinates to style', () => {
49+
render(
50+
<VisualizationTooltip title="Title" x={150} y={250}>
51+
Content
52+
</VisualizationTooltip>
53+
);
54+
55+
const tooltip = screen.getByRole('tooltip');
56+
57+
expect(tooltip).toHaveStyle({
58+
left: '150px',
59+
top: '250px',
60+
});
61+
});
62+
63+
it('renders nested React elements passed as children', () => {
64+
render(
65+
<VisualizationTooltip title="Stats" x={10} y={20}>
66+
<>
67+
<div>Total: 10</div>
68+
<div>Today: 2</div>
69+
</>
70+
</VisualizationTooltip>
71+
);
72+
73+
expect(screen.getByText('Total: 10')).toBeInTheDocument();
74+
expect(screen.getByText('Today: 2')).toBeInTheDocument();
75+
});
76+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { RefreshPolicy } from './refresh-policy';
3+
4+
describe('RefreshPolicy - Mouse Interactivity & Click Handling', () => {
5+
it('prevents double-clicks from triggering redundant refresh calls using cooldown cooldownMs validation', () => {
6+
const policy = RefreshPolicy.getInstance();
7+
policy.reset();
8+
policy.setCooldown(1000);
9+
10+
const firstCheck = policy.isRefreshAllowed('double-clicker');
11+
expect(firstCheck).toBe(true);
12+
13+
policy.recordRefresh('double-clicker');
14+
15+
// Simulate instant second click (mouse double click)
16+
const secondCheck = policy.isRefreshAllowed('double-clicker');
17+
expect(secondCheck).toBe(false);
18+
});
19+
20+
it('computes layout offset dimensions for hover tooltip displays correctly', () => {
21+
const hoverEvent = { clientX: 100, clientY: 200 };
22+
const getTooltipPosition = (e: { clientX: number; clientY: number }) => ({
23+
top: e.clientY + 10,
24+
left: e.clientX + 10,
25+
});
26+
27+
const pos = getTooltipPosition(hoverEvent);
28+
expect(pos.top).toBe(210);
29+
expect(pos.left).toBe(110);
30+
});
31+
32+
it('verifies click handlers call refresh record actions exactly once', () => {
33+
const policy = RefreshPolicy.getInstance();
34+
policy.reset();
35+
const mockRecord = vi.spyOn(policy, 'recordRefresh');
36+
37+
const handleRefreshClick = (username: string) => {
38+
if (policy.isRefreshAllowed(username)) {
39+
policy.recordRefresh(username);
40+
}
41+
};
42+
43+
handleRefreshClick('click-tester');
44+
expect(mockRecord).toHaveBeenCalledTimes(1);
45+
expect(mockRecord).toHaveBeenCalledWith('click-tester');
46+
47+
// Subsequent click is blocked, so record is not called again
48+
handleRefreshClick('click-tester');
49+
expect(mockRecord).toHaveBeenCalledTimes(1);
50+
51+
vi.restoreAllMocks();
52+
});
53+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// services/github/refresh-rate-limiter.error-resilience.test.ts
2+
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import { refreshRateLimiter } from './refresh-rate-limiter';
5+
6+
describe('RefreshRateLimiter Error Resilience', () => {
7+
const originalEnv = process.env.MAX_REFRESHES_PER_HOUR;
8+
9+
beforeEach(() => {
10+
refreshRateLimiter.reset();
11+
delete process.env.MAX_REFRESHES_PER_HOUR;
12+
});
13+
14+
afterEach(() => {
15+
refreshRateLimiter.reset();
16+
17+
if (originalEnv) {
18+
process.env.MAX_REFRESHES_PER_HOUR = originalEnv;
19+
} else {
20+
delete process.env.MAX_REFRESHES_PER_HOUR;
21+
}
22+
});
23+
24+
it('falls back to default limit when environment value is invalid', () => {
25+
process.env.MAX_REFRESHES_PER_HOUR = 'invalid';
26+
27+
const result = refreshRateLimiter.checkLimit('127.0.0.1');
28+
29+
expect(result.success).toBe(true);
30+
expect(result.limit).toBe(3);
31+
expect(result.remaining).toBe(2);
32+
});
33+
34+
it('falls back to default limit when environment value is zero', () => {
35+
process.env.MAX_REFRESHES_PER_HOUR = '0';
36+
37+
const result = refreshRateLimiter.checkLimit('127.0.0.1');
38+
39+
expect(result.success).toBe(true);
40+
expect(result.limit).toBe(3);
41+
});
42+
43+
it('handles malformed or whitespace-only client identifiers safely', () => {
44+
const result = refreshRateLimiter.checkLimit(' ');
45+
46+
expect(result.success).toBe(true);
47+
expect(result.limit).toBe(3);
48+
expect(result.remaining).toBe(2);
49+
});
50+
51+
it('recovers correctly after reset is called', () => {
52+
refreshRateLimiter.setLimit(1);
53+
54+
expect(refreshRateLimiter.checkLimit('192.168.1.1').success).toBe(true);
55+
56+
expect(refreshRateLimiter.checkLimit('192.168.1.1').success).toBe(false);
57+
58+
refreshRateLimiter.reset();
59+
60+
const result = refreshRateLimiter.checkLimit('192.168.1.1');
61+
62+
expect(result.success).toBe(true);
63+
expect(result.limit).toBe(3);
64+
expect(result.remaining).toBe(2);
65+
});
66+
67+
it('maintains stable behavior across repeated checks after limit exhaustion', () => {
68+
refreshRateLimiter.setLimit(1);
69+
70+
expect(refreshRateLimiter.checkLimit('10.0.0.1').success).toBe(true);
71+
72+
const blocked1 = refreshRateLimiter.checkLimit('10.0.0.1');
73+
const blocked2 = refreshRateLimiter.checkLimit('10.0.0.1');
74+
75+
expect(blocked1.success).toBe(false);
76+
expect(blocked2.success).toBe(false);
77+
78+
expect(blocked1.remaining).toBe(0);
79+
expect(blocked2.remaining).toBe(0);
80+
});
81+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// services/github/validate-user.empty-fallback.test.ts
2+
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
vi.mock('../../lib/github', () => ({
6+
fetchUserProfile: vi.fn(),
7+
}));
8+
9+
import { fetchUserProfile } from '../../lib/github';
10+
import { gitHubUserValidator } from './validate-user';
11+
12+
describe('GitHubUserValidator Empty & Missing Input Fallbacks', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
gitHubUserValidator.reset();
16+
});
17+
18+
it('throws for empty usernames', async () => {
19+
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found'));
20+
21+
await expect(gitHubUserValidator.validateUser('')).rejects.toThrow('Cache key cannot be empty');
22+
});
23+
24+
it('throws for whitespace-only usernames', async () => {
25+
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found'));
26+
27+
await expect(gitHubUserValidator.validateUser(' ')).rejects.toThrow(
28+
'Cache key cannot be empty'
29+
);
30+
});
31+
32+
it('caches negative results for missing users', async () => {
33+
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found'));
34+
35+
const first = await gitHubUserValidator.validateUser('missing-user');
36+
const second = await gitHubUserValidator.validateUser('missing-user');
37+
38+
expect(first).toBe(false);
39+
expect(second).toBe(false);
40+
41+
expect(fetchUserProfile).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it('returns cached result for usernames with different casing and spacing', async () => {
45+
vi.mocked(fetchUserProfile).mockResolvedValue({} as never);
46+
47+
const first = await gitHubUserValidator.validateUser('Ganesh');
48+
const second = await gitHubUserValidator.validateUser(' ganesh ');
49+
50+
expect(first).toBe(true);
51+
expect(second).toBe(true);
52+
53+
expect(fetchUserProfile).toHaveBeenCalledTimes(1);
54+
});
55+
56+
it('rethrows unexpected service errors instead of masking them', async () => {
57+
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('GitHub API unavailable'));
58+
59+
await expect(gitHubUserValidator.validateUser('ganesh')).rejects.toThrow(
60+
'GitHub API unavailable'
61+
);
62+
});
63+
});

0 commit comments

Comments
 (0)