Skip to content

Commit e6b086c

Browse files
Merge pull request #168 from CSSLab/dev
Update feature branch
2 parents 778fc80 + e7c206f commit e6b086c

36 files changed

+2015
-280
lines changed

__tests__/api/active-users.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createMocks } from 'node-mocks-http'
2+
import handler from 'src/pages/api/active-users'
3+
4+
global.fetch = jest.fn()
5+
6+
describe('/api/active-users', () => {
7+
beforeEach(() => {
8+
jest.clearAllMocks()
9+
delete process.env.POSTHOG_PROJECT_ID
10+
delete process.env.POSTHOG_API_KEY
11+
})
12+
13+
it('should return 405 for non-GET requests', async () => {
14+
const { req, res } = createMocks({
15+
method: 'POST',
16+
})
17+
18+
await handler(req, res)
19+
20+
expect(res._getStatusCode()).toBe(405)
21+
const data = JSON.parse(res._getData())
22+
expect(data.success).toBe(false)
23+
expect(data.error).toBe('Method not allowed')
24+
})
25+
})
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { getActiveUserCount } from 'src/api/home/activeUsers'
2+
3+
// Mock fetch for API calls
4+
global.fetch = jest.fn()
5+
6+
describe('getActiveUserCount', () => {
7+
beforeEach(() => {
8+
jest.clearAllMocks()
9+
})
10+
11+
it('should return a positive number', async () => {
12+
// Mock successful API response
13+
;(fetch as jest.Mock).mockResolvedValueOnce({
14+
ok: true,
15+
json: async () => ({
16+
activeUsers: 15,
17+
success: true,
18+
}),
19+
})
20+
21+
const count = await getActiveUserCount()
22+
expect(count).toBeGreaterThanOrEqual(0)
23+
expect(Number.isInteger(count)).toBe(true)
24+
})
25+
26+
it('should call the internal API endpoint', async () => {
27+
// Mock successful API response
28+
;(fetch as jest.Mock).mockResolvedValueOnce({
29+
ok: true,
30+
json: async () => ({
31+
activeUsers: 10,
32+
success: true,
33+
}),
34+
})
35+
36+
const count = await getActiveUserCount()
37+
38+
expect(fetch).toHaveBeenCalledWith('/api/active-users')
39+
expect(count).toBe(10)
40+
})
41+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { renderHook, waitFor } from '@testing-library/react'
2+
import { useLeaderboardStatus } from 'src/hooks/useLeaderboardStatus'
3+
import * as api from 'src/api'
4+
5+
// Mock the API
6+
jest.mock('src/api', () => ({
7+
getLeaderboard: jest.fn(),
8+
}))
9+
10+
const mockLeaderboardData = {
11+
play_leaders: [
12+
{ display_name: 'TestPlayer1', elo: 1800 },
13+
{ display_name: 'TestPlayer2', elo: 1750 },
14+
],
15+
puzzles_leaders: [
16+
{ display_name: 'TestPlayer1', elo: 1600 },
17+
{ display_name: 'TestPlayer3', elo: 1550 },
18+
],
19+
turing_leaders: [{ display_name: 'TestPlayer4', elo: 1400 }],
20+
hand_leaders: [{ display_name: 'TestPlayer1', elo: 1500 }],
21+
brain_leaders: [{ display_name: 'TestPlayer5', elo: 1300 }],
22+
last_updated: '2024-01-01T00:00:00',
23+
}
24+
25+
describe('useLeaderboardStatus', () => {
26+
beforeEach(() => {
27+
jest.clearAllMocks()
28+
;(api.getLeaderboard as jest.Mock).mockResolvedValue(mockLeaderboardData)
29+
})
30+
31+
it('should return correct status for player on multiple leaderboards', async () => {
32+
const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1'))
33+
34+
expect(result.current.loading).toBe(true)
35+
36+
await waitFor(() => {
37+
expect(result.current.loading).toBe(false)
38+
})
39+
40+
expect(result.current.status.isOnLeaderboard).toBe(true)
41+
expect(result.current.status.totalLeaderboards).toBe(3)
42+
expect(result.current.status.positions).toHaveLength(3)
43+
44+
// Check specific positions
45+
const regularPosition = result.current.status.positions.find(
46+
(p) => p.gameType === 'regular',
47+
)
48+
expect(regularPosition?.position).toBe(1)
49+
expect(regularPosition?.elo).toBe(1800)
50+
51+
const trainPosition = result.current.status.positions.find(
52+
(p) => p.gameType === 'train',
53+
)
54+
expect(trainPosition?.position).toBe(1)
55+
expect(trainPosition?.elo).toBe(1600)
56+
57+
const handPosition = result.current.status.positions.find(
58+
(p) => p.gameType === 'hand',
59+
)
60+
expect(handPosition?.position).toBe(1)
61+
expect(handPosition?.elo).toBe(1500)
62+
})
63+
64+
it('should return correct status for player not on leaderboard', async () => {
65+
const { result } = renderHook(() =>
66+
useLeaderboardStatus('NonExistentPlayer'),
67+
)
68+
69+
await waitFor(() => {
70+
expect(result.current.loading).toBe(false)
71+
})
72+
73+
expect(result.current.status.isOnLeaderboard).toBe(false)
74+
expect(result.current.status.totalLeaderboards).toBe(0)
75+
expect(result.current.status.positions).toHaveLength(0)
76+
})
77+
78+
it('should return empty status when no displayName provided', async () => {
79+
const { result } = renderHook(() => useLeaderboardStatus(undefined))
80+
81+
await waitFor(() => {
82+
expect(result.current.loading).toBe(false)
83+
})
84+
85+
expect(result.current.status.isOnLeaderboard).toBe(false)
86+
expect(result.current.status.totalLeaderboards).toBe(0)
87+
expect(result.current.status.positions).toHaveLength(0)
88+
expect(api.getLeaderboard).not.toHaveBeenCalled()
89+
})
90+
91+
it('should handle API errors gracefully', async () => {
92+
;(api.getLeaderboard as jest.Mock).mockRejectedValue(new Error('API Error'))
93+
94+
const { result } = renderHook(() => useLeaderboardStatus('TestPlayer1'))
95+
96+
await waitFor(() => {
97+
expect(result.current.loading).toBe(false)
98+
})
99+
100+
expect(result.current.status.isOnLeaderboard).toBe(false)
101+
expect(result.current.error).toBe('Failed to fetch leaderboard data')
102+
})
103+
})

__tests__/lib/ratingUtils.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { isValidRating, safeUpdateRating } from '../../src/lib/ratingUtils'
2+
3+
describe('ratingUtils', () => {
4+
describe('isValidRating', () => {
5+
it('should accept valid positive ratings', () => {
6+
expect(isValidRating(1500)).toBe(true)
7+
expect(isValidRating(1100)).toBe(true)
8+
expect(isValidRating(1900)).toBe(true)
9+
expect(isValidRating(800)).toBe(true)
10+
expect(isValidRating(2500)).toBe(true)
11+
expect(isValidRating(3000)).toBe(true)
12+
})
13+
14+
it('should reject zero and negative ratings', () => {
15+
expect(isValidRating(0)).toBe(false)
16+
expect(isValidRating(-100)).toBe(false)
17+
expect(isValidRating(-1500)).toBe(false)
18+
})
19+
20+
it('should reject non-numeric values', () => {
21+
expect(isValidRating(null)).toBe(false)
22+
expect(isValidRating(undefined)).toBe(false)
23+
expect(isValidRating('1500')).toBe(false)
24+
expect(isValidRating({})).toBe(false)
25+
expect(isValidRating([])).toBe(false)
26+
expect(isValidRating(true)).toBe(false)
27+
})
28+
29+
it('should reject infinite and NaN values', () => {
30+
expect(isValidRating(Number.POSITIVE_INFINITY)).toBe(false)
31+
expect(isValidRating(Number.NEGATIVE_INFINITY)).toBe(false)
32+
expect(isValidRating(Number.NaN)).toBe(false)
33+
})
34+
35+
it('should reject extremely high ratings', () => {
36+
expect(isValidRating(5000)).toBe(false)
37+
expect(isValidRating(10000)).toBe(false)
38+
})
39+
40+
it('should accept ratings at boundaries', () => {
41+
expect(isValidRating(1)).toBe(true)
42+
expect(isValidRating(4000)).toBe(true)
43+
})
44+
})
45+
46+
describe('safeUpdateRating', () => {
47+
let mockUpdateFunction: jest.Mock
48+
49+
beforeEach(() => {
50+
mockUpdateFunction = jest.fn()
51+
// Mock console.warn to avoid noise in test output
52+
jest.spyOn(console, 'warn').mockImplementation(() => {
53+
// Do nothing
54+
})
55+
})
56+
57+
afterEach(() => {
58+
jest.restoreAllMocks()
59+
})
60+
61+
it('should call update function with valid ratings', () => {
62+
expect(safeUpdateRating(1500, mockUpdateFunction)).toBe(true)
63+
expect(mockUpdateFunction).toHaveBeenCalledWith(1500)
64+
65+
expect(safeUpdateRating(2000, mockUpdateFunction)).toBe(true)
66+
expect(mockUpdateFunction).toHaveBeenCalledWith(2000)
67+
68+
expect(mockUpdateFunction).toHaveBeenCalledTimes(2)
69+
})
70+
71+
it('should not call update function with invalid ratings', () => {
72+
expect(safeUpdateRating(0, mockUpdateFunction)).toBe(false)
73+
expect(safeUpdateRating(null, mockUpdateFunction)).toBe(false)
74+
expect(safeUpdateRating(undefined, mockUpdateFunction)).toBe(false)
75+
expect(safeUpdateRating('1500', mockUpdateFunction)).toBe(false)
76+
expect(safeUpdateRating(-100, mockUpdateFunction)).toBe(false)
77+
78+
expect(mockUpdateFunction).not.toHaveBeenCalled()
79+
})
80+
81+
it('should log warnings for invalid ratings', () => {
82+
const consoleSpy = jest.spyOn(console, 'warn')
83+
84+
safeUpdateRating(0, mockUpdateFunction)
85+
safeUpdateRating(null, mockUpdateFunction)
86+
safeUpdateRating(undefined, mockUpdateFunction)
87+
88+
expect(consoleSpy).toHaveBeenCalledTimes(3)
89+
expect(consoleSpy).toHaveBeenCalledWith(
90+
'Attempted to update rating with invalid value:',
91+
0,
92+
)
93+
expect(consoleSpy).toHaveBeenCalledWith(
94+
'Attempted to update rating with invalid value:',
95+
null,
96+
)
97+
expect(consoleSpy).toHaveBeenCalledWith(
98+
'Attempted to update rating with invalid value:',
99+
undefined,
100+
)
101+
})
102+
103+
it('should handle edge cases that might come from API responses', () => {
104+
// Test common problematic API response values
105+
expect(safeUpdateRating('', mockUpdateFunction)).toBe(false)
106+
expect(safeUpdateRating(' ', mockUpdateFunction)).toBe(false)
107+
expect(safeUpdateRating(Number.NaN, mockUpdateFunction)).toBe(false)
108+
expect(safeUpdateRating({}, mockUpdateFunction)).toBe(false)
109+
expect(safeUpdateRating([], mockUpdateFunction)).toBe(false)
110+
111+
expect(mockUpdateFunction).not.toHaveBeenCalled()
112+
})
113+
})
114+
})

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"build": "next build",
77
"start": "next start",
88
"export": "next export",
9-
"lint": "eslint . --ext ts,tsx",
9+
"lint": "eslint . --ext ts,tsx --fix",
1010
"test": "jest"
1111
},
1212
"dependencies": {
@@ -61,6 +61,7 @@
6161
"eslint-plugin-react": "^7.28.0",
6262
"jest": "^30.0.4",
6363
"jest-environment-jsdom": "^30.0.4",
64+
"node-mocks-http": "^1.17.2",
6465
"postcss": "^8.4.45",
6566
"prettier": "^3.4.2",
6667
"prettier-plugin-tailwindcss": "^0.6.6",

src/api/home/activeUsers.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Get the count of active users in the last 30 minutes
3+
* Calls our secure server-side API endpoint that handles PostHog integration
4+
*/
5+
export const getActiveUserCount = async (): Promise<number> => {
6+
try {
7+
const response = await fetch('/api/active-users')
8+
9+
const data = await response.json()
10+
11+
if (data.success && typeof data.activeUsers === 'number') {
12+
return data.activeUsers
13+
}
14+
} catch (error) {
15+
console.error('Failed to fetch active user count:', error)
16+
}
17+
18+
return 0
19+
}

src/api/home/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './home'
2+
export * from './activeUsers'

src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './turing'
66
export * from './play'
77
export * from './profile'
88
export * from './opening'
9+
export { getActiveUserCount } from './home'

0 commit comments

Comments
 (0)