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.
@@ -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
+ });