gameglue 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/babel.config.js +5 -0
- package/coverage/auth.js.html +526 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +176 -0
- package/coverage/index.js.html +310 -0
- package/coverage/lcov-report/auth.js.html +526 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +176 -0
- package/coverage/lcov-report/index.js.html +310 -0
- package/coverage/lcov-report/listener.js.html +529 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/user.js.html +118 -0
- package/coverage/lcov-report/utils.js.html +118 -0
- package/coverage/lcov.info +391 -0
- package/coverage/listener.js.html +529 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/user.js.html +118 -0
- package/coverage/utils.js.html +118 -0
- package/jest.config.js +30 -0
- package/package.json +11 -3
- package/src/auth.spec.js +391 -0
- package/src/listener.spec.js +299 -0
- package/src/test/fixtures.js +106 -0
- package/src/test/setup.js +50 -0
- package/src/utils.spec.js +64 -0
package/src/auth.spec.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
const { GameGlueAuth } = require('./auth');
|
|
2
|
+
const { storage } = require('./utils');
|
|
3
|
+
const {
|
|
4
|
+
mockConfig,
|
|
5
|
+
validAccessToken,
|
|
6
|
+
expiredAccessToken,
|
|
7
|
+
validRefreshToken,
|
|
8
|
+
createMockJwt
|
|
9
|
+
} = require('./test/fixtures');
|
|
10
|
+
|
|
11
|
+
// Mock the oidc-client-ts module
|
|
12
|
+
jest.mock('oidc-client-ts', () => ({
|
|
13
|
+
OidcClient: jest.fn().mockImplementation(() => ({
|
|
14
|
+
createSigninRequest: jest.fn().mockResolvedValue({ url: 'https://auth.example.com/login' }),
|
|
15
|
+
processSigninResponse: jest.fn().mockResolvedValue({
|
|
16
|
+
access_token: 'new-access-token',
|
|
17
|
+
refresh_token: 'new-refresh-token'
|
|
18
|
+
})
|
|
19
|
+
}))
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock fetch for token refresh
|
|
23
|
+
global.fetch = jest.fn();
|
|
24
|
+
|
|
25
|
+
// Suppress console output during tests
|
|
26
|
+
const originalConsoleLog = console.log;
|
|
27
|
+
const originalConsoleError = console.error;
|
|
28
|
+
|
|
29
|
+
describe('GameGlueAuth', () => {
|
|
30
|
+
let auth;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
auth = new GameGlueAuth(mockConfig);
|
|
34
|
+
jest.clearAllMocks();
|
|
35
|
+
jest.useFakeTimers();
|
|
36
|
+
// Suppress console output
|
|
37
|
+
console.log = jest.fn();
|
|
38
|
+
console.error = jest.fn();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
// Restore console
|
|
43
|
+
console.log = originalConsoleLog;
|
|
44
|
+
console.error = originalConsoleError;
|
|
45
|
+
jest.useRealTimers();
|
|
46
|
+
// Clear storage by setting to undefined (mimics clearing)
|
|
47
|
+
storage.set('gg-auth-token', undefined);
|
|
48
|
+
storage.set('gg-refresh-token', undefined);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('constructor', () => {
|
|
52
|
+
it('should initialize with correct OIDC settings', () => {
|
|
53
|
+
expect(auth._oidcSettings).toMatchObject({
|
|
54
|
+
authority: 'https://auth.gameglue.gg/realms/GameGlue',
|
|
55
|
+
client_id: 'test-client-id',
|
|
56
|
+
redirect_uri: 'http://localhost:3000/callback',
|
|
57
|
+
response_type: 'code',
|
|
58
|
+
scope: 'openid msfs:read gg:broadcast',
|
|
59
|
+
response_mode: 'fragment',
|
|
60
|
+
filterProtocolClaims: true
|
|
61
|
+
});
|
|
62
|
+
// post_logout_redirect_uri is based on window.location.href which varies by environment
|
|
63
|
+
expect(auth._oidcSettings.post_logout_redirect_uri).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should use window.location.href as default redirect_uri', () => {
|
|
67
|
+
const configNoRedirect = { clientId: 'test', scopes: [] };
|
|
68
|
+
const authNoRedirect = new GameGlueAuth(configNoRedirect);
|
|
69
|
+
|
|
70
|
+
// The redirect_uri should be based on window.location.href (varies by environment)
|
|
71
|
+
expect(authNoRedirect._oidcSettings.redirect_uri).toMatch(/^http:\/\/localhost/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle empty scopes array', () => {
|
|
75
|
+
const configEmptyScopes = { clientId: 'test', scopes: [] };
|
|
76
|
+
const authEmptyScopes = new GameGlueAuth(configEmptyScopes);
|
|
77
|
+
|
|
78
|
+
expect(authEmptyScopes._oidcSettings.scope).toBe('openid ');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle undefined scopes', () => {
|
|
82
|
+
const configNoScopes = { clientId: 'test' };
|
|
83
|
+
const authNoScopes = new GameGlueAuth(configNoScopes);
|
|
84
|
+
|
|
85
|
+
expect(authNoScopes._oidcSettings.scope).toBe('openid ');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should remove trailing slashes from redirect_uri', () => {
|
|
89
|
+
const configWithSlash = {
|
|
90
|
+
clientId: 'test',
|
|
91
|
+
redirect_uri: 'http://localhost:3000/callback/'
|
|
92
|
+
};
|
|
93
|
+
const authWithSlash = new GameGlueAuth(configWithSlash);
|
|
94
|
+
|
|
95
|
+
expect(authWithSlash._oidcSettings.redirect_uri).toBe('http://localhost:3000/callback');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('setAccessToken / getAccessToken', () => {
|
|
100
|
+
it('should store and retrieve access token', () => {
|
|
101
|
+
auth.setAccessToken(validAccessToken);
|
|
102
|
+
const retrieved = auth.getAccessToken();
|
|
103
|
+
|
|
104
|
+
expect(retrieved).toBe(validAccessToken);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should set up token refresh timeout when setting token', () => {
|
|
108
|
+
auth.setAccessToken(validAccessToken);
|
|
109
|
+
|
|
110
|
+
// Check that a timeout was scheduled
|
|
111
|
+
expect(jest.getTimerCount()).toBeGreaterThan(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should not set timeout for null token', () => {
|
|
115
|
+
auth.setAccessToken(null);
|
|
116
|
+
|
|
117
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('setRefreshToken / getRefreshToken', () => {
|
|
122
|
+
it('should store and retrieve refresh token', () => {
|
|
123
|
+
auth.setRefreshToken(validRefreshToken);
|
|
124
|
+
const retrieved = auth.getRefreshToken();
|
|
125
|
+
|
|
126
|
+
expect(retrieved).toBe(validRefreshToken);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('getUserId', () => {
|
|
131
|
+
it('should extract user ID from JWT', () => {
|
|
132
|
+
auth.setAccessToken(validAccessToken);
|
|
133
|
+
|
|
134
|
+
const userId = auth.getUserId();
|
|
135
|
+
|
|
136
|
+
expect(userId).toBe('user-123');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('isTokenExpired', () => {
|
|
141
|
+
it('should return false for valid token', () => {
|
|
142
|
+
const result = auth.isTokenExpired(validAccessToken);
|
|
143
|
+
|
|
144
|
+
expect(result).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should return true for expired token', () => {
|
|
148
|
+
const result = auth.isTokenExpired(expiredAccessToken);
|
|
149
|
+
|
|
150
|
+
expect(result).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('isAuthenticated', () => {
|
|
155
|
+
it('should return false when no token exists', async () => {
|
|
156
|
+
const result = await auth.isAuthenticated();
|
|
157
|
+
|
|
158
|
+
expect(result).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return true for valid non-expired token', async () => {
|
|
162
|
+
auth.setAccessToken(validAccessToken);
|
|
163
|
+
|
|
164
|
+
const result = await auth.isAuthenticated();
|
|
165
|
+
|
|
166
|
+
expect(result).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should attempt refresh for expired token', async () => {
|
|
170
|
+
auth.setAccessToken(expiredAccessToken);
|
|
171
|
+
auth.setRefreshToken(validRefreshToken);
|
|
172
|
+
|
|
173
|
+
// Mock successful refresh
|
|
174
|
+
global.fetch.mockResolvedValueOnce({
|
|
175
|
+
status: 200,
|
|
176
|
+
json: () =>
|
|
177
|
+
Promise.resolve({
|
|
178
|
+
access_token: validAccessToken,
|
|
179
|
+
refresh_token: validRefreshToken
|
|
180
|
+
})
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const result = await auth.isAuthenticated();
|
|
184
|
+
|
|
185
|
+
expect(global.fetch).toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should return false when refresh fails and token is still expired', async () => {
|
|
189
|
+
auth.setAccessToken(expiredAccessToken);
|
|
190
|
+
auth.setRefreshToken(validRefreshToken);
|
|
191
|
+
|
|
192
|
+
// Mock failed refresh
|
|
193
|
+
global.fetch.mockResolvedValueOnce({
|
|
194
|
+
status: 401,
|
|
195
|
+
json: () => Promise.resolve({ error: 'invalid_grant' })
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await auth.isAuthenticated();
|
|
199
|
+
|
|
200
|
+
expect(result).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('attemptRefresh', () => {
|
|
205
|
+
it('should call token endpoint with correct parameters', async () => {
|
|
206
|
+
auth.setRefreshToken(validRefreshToken);
|
|
207
|
+
|
|
208
|
+
global.fetch.mockResolvedValueOnce({
|
|
209
|
+
status: 200,
|
|
210
|
+
json: () =>
|
|
211
|
+
Promise.resolve({
|
|
212
|
+
access_token: validAccessToken,
|
|
213
|
+
refresh_token: validRefreshToken
|
|
214
|
+
})
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await auth.attemptRefresh();
|
|
218
|
+
|
|
219
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
220
|
+
'https://auth.gameglue.gg/realms/GameGlue/protocol/openid-connect/token',
|
|
221
|
+
expect.objectContaining({
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: {
|
|
224
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should update tokens on successful refresh', async () => {
|
|
231
|
+
auth.setRefreshToken(validRefreshToken);
|
|
232
|
+
|
|
233
|
+
const newAccessToken = createMockJwt({ sub: 'user-456' }, 3600);
|
|
234
|
+
const newRefreshToken = createMockJwt({ sub: 'user-456', type: 'refresh' }, 86400);
|
|
235
|
+
|
|
236
|
+
global.fetch.mockResolvedValueOnce({
|
|
237
|
+
status: 200,
|
|
238
|
+
json: () =>
|
|
239
|
+
Promise.resolve({
|
|
240
|
+
access_token: newAccessToken,
|
|
241
|
+
refresh_token: newRefreshToken
|
|
242
|
+
})
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await auth.attemptRefresh();
|
|
246
|
+
|
|
247
|
+
expect(auth.getAccessToken()).toBe(newAccessToken);
|
|
248
|
+
expect(auth.getRefreshToken()).toBe(newRefreshToken);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should call refresh callback on successful refresh', async () => {
|
|
252
|
+
const callback = jest.fn();
|
|
253
|
+
auth.onTokenRefreshed(callback);
|
|
254
|
+
auth.setRefreshToken(validRefreshToken);
|
|
255
|
+
|
|
256
|
+
const responseData = {
|
|
257
|
+
access_token: validAccessToken,
|
|
258
|
+
refresh_token: validRefreshToken
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
global.fetch.mockResolvedValueOnce({
|
|
262
|
+
status: 200,
|
|
263
|
+
json: () => Promise.resolve(responseData)
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await auth.attemptRefresh();
|
|
267
|
+
|
|
268
|
+
expect(callback).toHaveBeenCalledWith(responseData);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should handle network errors gracefully', async () => {
|
|
272
|
+
auth.setRefreshToken(validRefreshToken);
|
|
273
|
+
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
|
274
|
+
|
|
275
|
+
// Should not throw
|
|
276
|
+
await expect(auth.attemptRefresh()).resolves.toBeUndefined();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should not update tokens on non-200 response', async () => {
|
|
280
|
+
auth.setRefreshToken(validRefreshToken);
|
|
281
|
+
const originalAccessToken = auth.getAccessToken();
|
|
282
|
+
|
|
283
|
+
global.fetch.mockResolvedValueOnce({
|
|
284
|
+
status: 400,
|
|
285
|
+
json: () => Promise.resolve({ error: 'invalid_grant' })
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await auth.attemptRefresh();
|
|
289
|
+
|
|
290
|
+
// Access token should remain unchanged (undefined in this case)
|
|
291
|
+
expect(auth.getAccessToken()).toBe(originalAccessToken);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('onTokenRefreshed', () => {
|
|
296
|
+
it('should register callback for token refresh', () => {
|
|
297
|
+
const callback = jest.fn();
|
|
298
|
+
auth.onTokenRefreshed(callback);
|
|
299
|
+
|
|
300
|
+
expect(auth._refreshCallback).toBe(callback);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('setTokenRefreshTimeout', () => {
|
|
305
|
+
it('should schedule refresh before token expiration', () => {
|
|
306
|
+
// Token expires in 1 hour (3600 seconds)
|
|
307
|
+
auth.setTokenRefreshTimeout(validAccessToken);
|
|
308
|
+
|
|
309
|
+
// Should have scheduled a timeout
|
|
310
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should clear previous timeout when setting new one', () => {
|
|
314
|
+
auth.setTokenRefreshTimeout(validAccessToken);
|
|
315
|
+
auth.setTokenRefreshTimeout(validAccessToken);
|
|
316
|
+
|
|
317
|
+
// Should only have one active timeout
|
|
318
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should not schedule timeout for expired token', () => {
|
|
322
|
+
auth.setTokenRefreshTimeout(expiredAccessToken);
|
|
323
|
+
|
|
324
|
+
// Timer count may be 0 since the timeUntilExp is negative
|
|
325
|
+
// The important thing is it doesn't throw
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('_shouldHandleRedirectResponse', () => {
|
|
330
|
+
it('should return true when hash contains state and code', () => {
|
|
331
|
+
window.location.hash = '#state=abc123&code=xyz789';
|
|
332
|
+
|
|
333
|
+
expect(auth._shouldHandleRedirectResponse()).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should return true when hash contains state and error', () => {
|
|
337
|
+
window.location.hash = '#state=abc123&error=access_denied';
|
|
338
|
+
|
|
339
|
+
expect(auth._shouldHandleRedirectResponse()).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should return false when hash does not contain state', () => {
|
|
343
|
+
window.location.hash = '#code=xyz789';
|
|
344
|
+
|
|
345
|
+
expect(auth._shouldHandleRedirectResponse()).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should return false when hash is empty', () => {
|
|
349
|
+
window.location.hash = '';
|
|
350
|
+
|
|
351
|
+
expect(auth._shouldHandleRedirectResponse()).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('handleRedirectResponse', () => {
|
|
356
|
+
it('should process signin response and store tokens', async () => {
|
|
357
|
+
const { OidcClient } = require('oidc-client-ts');
|
|
358
|
+
OidcClient.mockImplementation(() => ({
|
|
359
|
+
processSigninResponse: jest.fn().mockResolvedValue({
|
|
360
|
+
access_token: validAccessToken,
|
|
361
|
+
refresh_token: validRefreshToken
|
|
362
|
+
})
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
const authNew = new GameGlueAuth(mockConfig);
|
|
366
|
+
await authNew.handleRedirectResponse();
|
|
367
|
+
|
|
368
|
+
expect(authNew.getAccessToken()).toBe(validAccessToken);
|
|
369
|
+
expect(authNew.getRefreshToken()).toBe(validRefreshToken);
|
|
370
|
+
expect(window.history.pushState).toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should handle error response gracefully', async () => {
|
|
374
|
+
const { OidcClient } = require('oidc-client-ts');
|
|
375
|
+
OidcClient.mockImplementation(() => ({
|
|
376
|
+
processSigninResponse: jest.fn().mockResolvedValue({
|
|
377
|
+
error: 'access_denied',
|
|
378
|
+
access_token: null
|
|
379
|
+
})
|
|
380
|
+
}));
|
|
381
|
+
|
|
382
|
+
const authNew = new GameGlueAuth(mockConfig);
|
|
383
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
384
|
+
|
|
385
|
+
await authNew.handleRedirectResponse();
|
|
386
|
+
|
|
387
|
+
expect(consoleSpy).toHaveBeenCalledWith('access_denied');
|
|
388
|
+
consoleSpy.mockRestore();
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
const { Listener } = require('./listener');
|
|
2
|
+
const { createMockSocket, mockListenerConfig } = require('./test/fixtures');
|
|
3
|
+
|
|
4
|
+
describe('Listener', () => {
|
|
5
|
+
let mockSocket;
|
|
6
|
+
let listener;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockSocket = createMockSocket();
|
|
10
|
+
listener = new Listener(mockSocket, mockListenerConfig);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('constructor', () => {
|
|
14
|
+
it('should initialize with config and socket', () => {
|
|
15
|
+
expect(listener._config).toEqual(mockListenerConfig);
|
|
16
|
+
expect(listener._socket).toBe(mockSocket);
|
|
17
|
+
expect(listener._callbacks).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should copy fields array from config', () => {
|
|
21
|
+
expect(listener._fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
22
|
+
expect(listener._fields).not.toBe(mockListenerConfig.fields); // Should be a copy
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should handle config without fields', () => {
|
|
26
|
+
const configWithoutFields = { gameId: 'msfs', userId: 'user-123' };
|
|
27
|
+
const listenerNoFields = new Listener(mockSocket, configWithoutFields);
|
|
28
|
+
|
|
29
|
+
expect(listenerNoFields._fields).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('establishConnection', () => {
|
|
34
|
+
it('should emit listen event with object payload when fields are specified', async () => {
|
|
35
|
+
const result = await listener.establishConnection();
|
|
36
|
+
|
|
37
|
+
expect(mockSocket.timeout).toHaveBeenCalledWith(5000);
|
|
38
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
39
|
+
'listen',
|
|
40
|
+
{
|
|
41
|
+
userId: 'user-123',
|
|
42
|
+
gameId: 'msfs',
|
|
43
|
+
fields: ['altitude', 'airspeed', 'heading']
|
|
44
|
+
},
|
|
45
|
+
expect.any(Function)
|
|
46
|
+
);
|
|
47
|
+
expect(result).toEqual({ status: 'success' });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should emit listen event with legacy string format when no fields', async () => {
|
|
51
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
52
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
53
|
+
|
|
54
|
+
await listenerNoFields.establishConnection();
|
|
55
|
+
|
|
56
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
57
|
+
'listen',
|
|
58
|
+
'user-123:msfs',
|
|
59
|
+
expect.any(Function)
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should throw error when socket is missing', async () => {
|
|
64
|
+
const invalidListener = new Listener(null, mockListenerConfig);
|
|
65
|
+
|
|
66
|
+
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
67
|
+
'Missing arguments in establishConnection'
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should throw error when userId is missing', async () => {
|
|
72
|
+
const invalidConfig = { gameId: 'msfs' };
|
|
73
|
+
const invalidListener = new Listener(mockSocket, invalidConfig);
|
|
74
|
+
|
|
75
|
+
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
76
|
+
'Missing arguments in establishConnection'
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should throw error when gameId is missing', async () => {
|
|
81
|
+
const invalidConfig = { userId: 'user-123' };
|
|
82
|
+
const invalidListener = new Listener(mockSocket, invalidConfig);
|
|
83
|
+
|
|
84
|
+
await expect(invalidListener.establishConnection()).rejects.toThrow(
|
|
85
|
+
'Missing arguments in establishConnection'
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should handle timeout error', async () => {
|
|
90
|
+
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
91
|
+
callback(new Error('timeout'), null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await listener.establishConnection();
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual({
|
|
97
|
+
status: 'failed',
|
|
98
|
+
reason: 'Listen request timed out.'
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle failure response from server', async () => {
|
|
103
|
+
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
104
|
+
callback(null, { status: 'failed', reason: 'Unauthorized' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await listener.establishConnection();
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual({
|
|
110
|
+
status: 'failed',
|
|
111
|
+
reason: 'Unauthorized'
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('setupEventListener', () => {
|
|
117
|
+
it('should register update event handler and return self', () => {
|
|
118
|
+
const result = listener.setupEventListener();
|
|
119
|
+
|
|
120
|
+
expect(mockSocket.on).toHaveBeenCalledWith('update', expect.any(Function));
|
|
121
|
+
expect(result).toBe(listener);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('subscribe', () => {
|
|
126
|
+
it('should add new fields to subscription', async () => {
|
|
127
|
+
await listener.subscribe(['fuel', 'temperature']);
|
|
128
|
+
|
|
129
|
+
expect(listener._fields).toContain('altitude');
|
|
130
|
+
expect(listener._fields).toContain('fuel');
|
|
131
|
+
expect(listener._fields).toContain('temperature');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should not add duplicate fields', async () => {
|
|
135
|
+
await listener.subscribe(['altitude', 'fuel']);
|
|
136
|
+
|
|
137
|
+
const altitudeCount = listener._fields.filter(f => f === 'altitude').length;
|
|
138
|
+
expect(altitudeCount).toBe(1);
|
|
139
|
+
expect(listener._fields).toContain('fuel');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should emit listen-update event', async () => {
|
|
143
|
+
await listener.subscribe(['fuel']);
|
|
144
|
+
|
|
145
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
146
|
+
'listen-update',
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
userId: 'user-123',
|
|
149
|
+
gameId: 'msfs',
|
|
150
|
+
fields: expect.arrayContaining(['altitude', 'airspeed', 'heading', 'fuel'])
|
|
151
|
+
}),
|
|
152
|
+
expect.any(Function)
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should initialize fields array if none existed', async () => {
|
|
157
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
158
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
159
|
+
|
|
160
|
+
await listenerNoFields.subscribe(['fuel', 'temperature']);
|
|
161
|
+
|
|
162
|
+
expect(listenerNoFields._fields).toEqual(['fuel', 'temperature']);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should throw error for non-array input', async () => {
|
|
166
|
+
await expect(listener.subscribe('fuel')).rejects.toThrow(
|
|
167
|
+
'fields must be a non-empty array'
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should throw error for empty array', async () => {
|
|
172
|
+
await expect(listener.subscribe([])).rejects.toThrow(
|
|
173
|
+
'fields must be a non-empty array'
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('unsubscribe', () => {
|
|
179
|
+
it('should remove fields from subscription', async () => {
|
|
180
|
+
await listener.unsubscribe(['altitude']);
|
|
181
|
+
|
|
182
|
+
expect(listener._fields).not.toContain('altitude');
|
|
183
|
+
expect(listener._fields).toContain('airspeed');
|
|
184
|
+
expect(listener._fields).toContain('heading');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should emit listen-update event with updated fields', async () => {
|
|
188
|
+
await listener.unsubscribe(['altitude', 'airspeed']);
|
|
189
|
+
|
|
190
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
191
|
+
'listen-update',
|
|
192
|
+
{
|
|
193
|
+
userId: 'user-123',
|
|
194
|
+
gameId: 'msfs',
|
|
195
|
+
fields: ['heading']
|
|
196
|
+
},
|
|
197
|
+
expect.any(Function)
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should throw error when no explicit fields exist', async () => {
|
|
202
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
203
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
204
|
+
|
|
205
|
+
await expect(listenerNoFields.unsubscribe(['fuel'])).rejects.toThrow(
|
|
206
|
+
'Cannot unsubscribe when receiving all fields'
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should throw error for non-array input', async () => {
|
|
211
|
+
await expect(listener.unsubscribe('altitude')).rejects.toThrow(
|
|
212
|
+
'fields must be a non-empty array'
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should throw error for empty array', async () => {
|
|
217
|
+
await expect(listener.unsubscribe([])).rejects.toThrow(
|
|
218
|
+
'fields must be a non-empty array'
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should handle unsubscribing non-existent field gracefully', async () => {
|
|
223
|
+
await listener.unsubscribe(['nonexistent']);
|
|
224
|
+
|
|
225
|
+
expect(listener._fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('getFields', () => {
|
|
230
|
+
it('should return copy of fields array', () => {
|
|
231
|
+
const fields = listener.getFields();
|
|
232
|
+
|
|
233
|
+
expect(fields).toEqual(['altitude', 'airspeed', 'heading']);
|
|
234
|
+
expect(fields).not.toBe(listener._fields); // Should be a copy
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should return null when no explicit fields', () => {
|
|
238
|
+
const configNoFields = { gameId: 'msfs', userId: 'user-123' };
|
|
239
|
+
const listenerNoFields = new Listener(mockSocket, configNoFields);
|
|
240
|
+
|
|
241
|
+
expect(listenerNoFields.getFields()).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('sendCommand', () => {
|
|
246
|
+
it('should emit set event with correct payload', async () => {
|
|
247
|
+
const result = await listener.sendCommand('autopilot', true);
|
|
248
|
+
|
|
249
|
+
expect(mockSocket.timeout).toHaveBeenCalledWith(5000);
|
|
250
|
+
expect(mockSocket.emit).toHaveBeenCalledWith(
|
|
251
|
+
'set',
|
|
252
|
+
{
|
|
253
|
+
userId: 'user-123',
|
|
254
|
+
gameId: 'msfs',
|
|
255
|
+
data: {
|
|
256
|
+
fieldName: 'autopilot',
|
|
257
|
+
value: true
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
expect.any(Function)
|
|
261
|
+
);
|
|
262
|
+
expect(result).toEqual({ status: 'success' });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should handle various value types', async () => {
|
|
266
|
+
await listener.sendCommand('altitude', 35000);
|
|
267
|
+
await listener.sendCommand('flaps', 0.5);
|
|
268
|
+
await listener.sendCommand('status', 'active');
|
|
269
|
+
await listener.sendCommand('config', { mode: 'auto' });
|
|
270
|
+
|
|
271
|
+
expect(mockSocket.emit).toHaveBeenCalledTimes(4);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should throw error for invalid field', async () => {
|
|
275
|
+
await expect(listener.sendCommand('', true)).rejects.toThrow(
|
|
276
|
+
'field must be a non-empty string'
|
|
277
|
+
);
|
|
278
|
+
await expect(listener.sendCommand(null, true)).rejects.toThrow(
|
|
279
|
+
'field must be a non-empty string'
|
|
280
|
+
);
|
|
281
|
+
await expect(listener.sendCommand(123, true)).rejects.toThrow(
|
|
282
|
+
'field must be a non-empty string'
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should handle timeout error', async () => {
|
|
287
|
+
mockSocket.emit = jest.fn((event, data, callback) => {
|
|
288
|
+
callback(new Error('timeout'), null);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const result = await listener.sendCommand('autopilot', true);
|
|
292
|
+
|
|
293
|
+
expect(result).toEqual({
|
|
294
|
+
status: 'failed',
|
|
295
|
+
reason: 'Command request timed out.'
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|