Skip to content

Commit 3a0d2ab

Browse files
committed
test: add API network function tests with axios mock, socket.io test, /add not-found test
- Unit test all 7 remaining API.js functions (getContributorAvatar, getContributorInfo, getRepositories, getOpenPRsNumber, getMergedPRsNumber, getIssuesNumber, checkRateLimit) with axios mocked at the correct module path - Add error handling tests: Bad credentials, ECONNABORTED timeout, rate limit, network errors - Test getRepositories pagination (100+ repos across pages) - Test getContributorInfo multi-repo URL construction and chore label exclusion - Add socket.io e2e: verify 'refresh table' event with contributor data, disconnect cleanup - Add /add 'Not found' e2e test for non-existent GitHub user
1 parent 2039bf9 commit 3a0d2ab

3 files changed

Lines changed: 340 additions & 0 deletions

File tree

tests/e2e/server.e2e.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,14 @@ describe('Server E2E Tests', () => {
510510
})
511511
assert.deepStrictEqual(res.body, { message: 'dhairyashiil aready exists' })
512512
})
513+
514+
test('returns "Not found" for non-existent GitHub user', async () => {
515+
const res = await request(TEST_PORT, '/add', {
516+
method: 'POST',
517+
body: { token: TEST_ADMIN_PASSWORD, username: 'this-user-definitely-does-not-exist-99999' },
518+
})
519+
assert.deepStrictEqual(res.body, { message: 'Not found' })
520+
})
513521
})
514522

515523
describe('GET /getRepositories', () => {

tests/e2e/socket.e2e.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const { describe, test, before, after } = require('node:test')
2+
const assert = require('node:assert/strict')
3+
const {
4+
startServer,
5+
stopServer,
6+
TEST_PORT,
7+
} = require('./setup')
8+
9+
describe('Socket.io E2E Tests', () => {
10+
let io
11+
12+
before(async () => {
13+
await startServer()
14+
// Dynamic import of socket.io-client (installed in root package.json)
15+
io = require('socket.io-client')
16+
})
17+
18+
after(async () => {
19+
await stopServer()
20+
})
21+
22+
test('client receives "refresh table" event with data object', async () => {
23+
const socket = io(`http://127.0.0.1:${TEST_PORT}`, {
24+
transports: ['websocket'],
25+
reconnection: false,
26+
})
27+
28+
try {
29+
const data = await new Promise((resolve, reject) => {
30+
const timeout = setTimeout(() => {
31+
reject(new Error('Timed out waiting for "refresh table" event (20s)'))
32+
}, 20000)
33+
34+
socket.on('refresh table', (obj) => {
35+
clearTimeout(timeout)
36+
resolve(obj)
37+
})
38+
39+
socket.on('connect_error', (err) => {
40+
clearTimeout(timeout)
41+
reject(new Error('Socket connect error: ' + err.message))
42+
})
43+
})
44+
45+
assert.strictEqual(typeof data, 'object')
46+
assert.ok(Object.keys(data).length > 0, 'refresh table data should not be empty')
47+
// Verify it looks like contributor data
48+
const firstKey = Object.keys(data)[0]
49+
assert.ok('openPRsNumber' in data[firstKey], 'data should contain contributor objects')
50+
} finally {
51+
socket.disconnect()
52+
}
53+
})
54+
55+
test('server cleans up interval on disconnect', async () => {
56+
const socket = io(`http://127.0.0.1:${TEST_PORT}`, {
57+
transports: ['websocket'],
58+
reconnection: false,
59+
})
60+
61+
await new Promise((resolve, reject) => {
62+
socket.on('connect', resolve)
63+
socket.on('connect_error', reject)
64+
})
65+
66+
// Disconnect and verify no errors
67+
socket.disconnect()
68+
69+
// Give the server a moment to clean up
70+
await new Promise((resolve) => setTimeout(resolve, 200))
71+
72+
// If disconnection cleanup failed, the server would crash on next interval tick
73+
// Verify the server is still responsive
74+
const { request } = require('./helpers')
75+
const res = await request(TEST_PORT, '/stats')
76+
assert.strictEqual(res.status, 200)
77+
})
78+
})
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
const { describe, test, afterEach } = require('node:test')
2+
const assert = require('node:assert/strict')
3+
const path = require('path')
4+
5+
// Load API module with a dummy config
6+
process.env.LEADERBOARD_CONFIG_PATH = path.resolve(
7+
__dirname,
8+
'../e2e/fixtures/config.json'
9+
)
10+
const API = require(path.resolve(__dirname, '../../src/server/util/API'))
11+
12+
// Resolve axios from the same location API.js uses (src/server/node_modules/axios)
13+
const axiosPath = require.resolve('axios', {
14+
paths: [path.resolve(__dirname, '../../src/server/util')]
15+
})
16+
const axios = require(axiosPath)
17+
18+
// Store original axios.get so we can restore it after each test
19+
const originalGet = axios.get
20+
21+
afterEach(() => {
22+
axios.get = originalGet
23+
})
24+
25+
// Helper: mock axios.get to return a given response for any URL
26+
function mockAxiosGet(response) {
27+
axios.get = async () => response
28+
}
29+
30+
// Helper: mock axios.get to reject with a given error
31+
function mockAxiosGetError(error) {
32+
axios.get = async () => { throw error }
33+
}
34+
35+
// Helper: mock axios.get to return different responses per URL pattern
36+
function mockAxiosGetByUrl(mapping) {
37+
axios.get = async (url) => {
38+
for (const [pattern, response] of Object.entries(mapping)) {
39+
if (url.includes(pattern)) return response
40+
}
41+
throw new Error(`Unmocked URL: ${url}`)
42+
}
43+
}
44+
45+
describe('API.checkRateLimit', () => {
46+
test('returns avatar_url on success', async () => {
47+
mockAxiosGet({ data: { avatar_url: 'https://example.com/avatar.png' } })
48+
const result = await API.checkRateLimit()
49+
assert.strictEqual(result, 'https://example.com/avatar.png')
50+
})
51+
52+
test('returns empty object when API fails', async () => {
53+
mockAxiosGetError({ code: 'ECONNABORTED' })
54+
const result = await API.checkRateLimit()
55+
assert.deepStrictEqual(result, {})
56+
})
57+
})
58+
59+
describe('API.getContributorAvatar', () => {
60+
test('returns avatar URL on success', async () => {
61+
mockAxiosGet({ data: { avatar_url: 'https://avatars.githubusercontent.com/u/123?v=4' } })
62+
const result = await API.getContributorAvatar('testuser')
63+
assert.strictEqual(result, 'https://avatars.githubusercontent.com/u/123?v=4')
64+
})
65+
66+
test('returns empty string when API fails', async () => {
67+
mockAxiosGetError({ response: { data: { message: 'Not Found' } } })
68+
const result = await API.getContributorAvatar('nonexistent')
69+
assert.strictEqual(result, '')
70+
})
71+
72+
test('returns empty string on timeout', async () => {
73+
mockAxiosGetError({ code: 'ECONNABORTED' })
74+
const result = await API.getContributorAvatar('testuser')
75+
assert.strictEqual(result, '')
76+
})
77+
})
78+
79+
describe('API.getOpenPRsNumber', () => {
80+
test('returns total_count on success', async () => {
81+
mockAxiosGet({ data: { total_count: 42 } })
82+
const result = await API.getOpenPRsNumber('/search/issues?q=test')
83+
assert.strictEqual(result, 42)
84+
})
85+
86+
test('returns 0 for zero open PRs', async () => {
87+
mockAxiosGet({ data: { total_count: 0 } })
88+
const result = await API.getOpenPRsNumber('/search/issues?q=test')
89+
assert.strictEqual(result, 0)
90+
})
91+
92+
test('returns -1 when API fails', async () => {
93+
mockAxiosGetError({ response: { data: { message: 'Bad credentials' } } })
94+
const result = await API.getOpenPRsNumber('/search/issues?q=test')
95+
assert.strictEqual(result, -1)
96+
})
97+
})
98+
99+
describe('API.getMergedPRsNumber', () => {
100+
test('returns total_count on success', async () => {
101+
mockAxiosGet({ data: { total_count: 15 } })
102+
const result = await API.getMergedPRsNumber('/search/issues?q=test')
103+
assert.strictEqual(result, 15)
104+
})
105+
106+
test('returns -1 when API fails', async () => {
107+
mockAxiosGetError({ code: 'ECONNABORTED' })
108+
const result = await API.getMergedPRsNumber('/search/issues?q=test')
109+
assert.strictEqual(result, -1)
110+
})
111+
})
112+
113+
describe('API.getIssuesNumber', () => {
114+
test('returns total_count on success', async () => {
115+
mockAxiosGet({ data: { total_count: 7 } })
116+
const result = await API.getIssuesNumber('/search/issues?q=test')
117+
assert.strictEqual(result, 7)
118+
})
119+
120+
test('returns -1 when API fails', async () => {
121+
mockAxiosGetError({ response: { data: { message: 'rate limit exceeded' } } })
122+
const result = await API.getIssuesNumber('/search/issues?q=test')
123+
assert.strictEqual(result, -1)
124+
})
125+
})
126+
127+
describe('API.getContributorInfo', () => {
128+
test('returns full contributor info on success', async () => {
129+
mockAxiosGetByUrl({
130+
'/users/alice': { data: { avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' } },
131+
'is:Open': { data: { total_count: 3 } },
132+
'is:Merged': { data: { total_count: 5 } },
133+
'is:issue': { data: { total_count: 2 } },
134+
})
135+
136+
const result = await API.getContributorInfo('TestOrg', 'alice', ['repo1'])
137+
138+
assert.strictEqual(result.home, 'https://github.com/alice')
139+
assert.strictEqual(result.avatarUrl, 'https://avatars.githubusercontent.com/u/1?v=4')
140+
assert.strictEqual(result.openPRsNumber, 3)
141+
assert.strictEqual(result.mergedPRsNumber, 5)
142+
assert.strictEqual(result.issuesNumber, 2)
143+
assert.ok(result.openPRsLink.includes('alice'))
144+
assert.ok(result.openPRsLink.includes('repo1'))
145+
assert.ok(result.mergedPRsLink.includes('alice'))
146+
assert.ok(result.issuesLink.includes('alice'))
147+
// Verify chore exclusion label is in links
148+
assert.ok(result.openPRsLink.includes('-label:chore'))
149+
assert.ok(result.mergedPRsLink.includes('-label:chore'))
150+
assert.ok(result.issuesLink.includes('-label:chore'))
151+
})
152+
153+
test('includes multiple repos in search URLs', async () => {
154+
mockAxiosGetByUrl({
155+
'/users/bob': { data: { avatar_url: 'https://example.com/bob.png' } },
156+
'is:Open': { data: { total_count: 1 } },
157+
'is:Merged': { data: { total_count: 2 } },
158+
'is:issue': { data: { total_count: 0 } },
159+
})
160+
161+
const result = await API.getContributorInfo('Org', 'bob', ['repo1', 'repo2'])
162+
163+
assert.ok(result.openPRsLink.includes('repo:Org/repo1'))
164+
assert.ok(result.openPRsLink.includes('repo:Org/repo2'))
165+
assert.ok(result.mergedPRsLink.includes('repo:Org/repo1'))
166+
assert.ok(result.mergedPRsLink.includes('repo:Org/repo2'))
167+
assert.ok(result.issuesLink.includes('repo:Org/repo1'))
168+
assert.ok(result.issuesLink.includes('repo:Org/repo2'))
169+
})
170+
171+
test('returns -1 counts when API calls fail', async () => {
172+
axios.get = async (url) => {
173+
if (url.includes('/users/')) {
174+
return { data: { avatar_url: '' } }
175+
}
176+
throw { response: { data: { message: 'API rate limit exceeded' } } }
177+
}
178+
179+
const result = await API.getContributorInfo('Org', 'failuser', ['repo1'])
180+
181+
assert.strictEqual(result.avatarUrl, '')
182+
assert.strictEqual(result.openPRsNumber, -1)
183+
assert.strictEqual(result.mergedPRsNumber, -1)
184+
assert.strictEqual(result.issuesNumber, -1)
185+
})
186+
})
187+
188+
describe('API.getRepositories', () => {
189+
test('returns repos from a single page (< 100 repos)', async () => {
190+
mockAxiosGet({
191+
data: [
192+
{ name: 'Rocket.Chat' },
193+
{ name: 'fuselage' },
194+
{ name: 'docs' },
195+
],
196+
})
197+
198+
const result = await API.getRepositories('RocketChat')
199+
// getRepositories returns results.push(repositories) so it's nested
200+
assert.deepStrictEqual(result, [['Rocket.Chat', 'fuselage', 'docs']])
201+
})
202+
203+
test('paginates when first page has 100+ repos', async () => {
204+
const page1 = Array.from({ length: 100 }, (_, i) => ({ name: `repo-${i}` }))
205+
const page2 = [{ name: 'repo-100' }, { name: 'repo-101' }]
206+
let callNum = 0
207+
208+
axios.get = async (_url) => {
209+
callNum++
210+
if (callNum === 1) return { data: page1 }
211+
return { data: page2 }
212+
}
213+
214+
const result = await API.getRepositories('TestOrg')
215+
assert.strictEqual(result.length, 2) // two pages
216+
assert.strictEqual(result[0].length, 100)
217+
assert.strictEqual(result[1].length, 2)
218+
assert.strictEqual(result[1][1], 'repo-101')
219+
})
220+
221+
test('returns empty string pages when API fails', async () => {
222+
mockAxiosGetError({ code: 'ECONNABORTED' })
223+
224+
const result = await API.getRepositories('FailOrg')
225+
// fetchRepositories returns '' on failure, length <= 99 stops pagination
226+
assert.deepStrictEqual(result, [''])
227+
})
228+
})
229+
230+
describe('API internal error handling', () => {
231+
test('handles Bad credentials error without crashing', async () => {
232+
mockAxiosGetError({ response: { data: { message: 'Bad credentials' } } })
233+
const result = await API.getContributorAvatar('testuser')
234+
assert.strictEqual(result, '')
235+
})
236+
237+
test('handles ECONNABORTED timeout', async () => {
238+
mockAxiosGetError({ code: 'ECONNABORTED' })
239+
const result = await API.getContributorAvatar('testuser')
240+
assert.strictEqual(result, '')
241+
})
242+
243+
test('handles generic error with response message', async () => {
244+
mockAxiosGetError({ response: { data: { message: 'API rate limit exceeded' } } })
245+
const result = await API.getOpenPRsNumber('/search/issues?q=test')
246+
assert.strictEqual(result, -1)
247+
})
248+
249+
test('handles error without response object', async () => {
250+
mockAxiosGetError(new Error('Network error'))
251+
const result = await API.getMergedPRsNumber('/search/issues?q=test')
252+
assert.strictEqual(result, -1)
253+
})
254+
})

0 commit comments

Comments
 (0)