@anmiles/google-api-wrapper 9.0.0 → 11.0.0

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,206 @@
1
+ import { google } from 'googleapis';
2
+ import logger from '@anmiles/logger';
3
+ import sleep from '@anmiles/sleep';
4
+ import auth from '../auth';
5
+ import secrets from '../secrets';
6
+ import api from '../api';
7
+
8
+ const items: Array<{ data: string}> = [
9
+ { data : 'first' },
10
+ { data : 'second' },
11
+ { data : 'third' },
12
+ { data : 'forth' },
13
+ ];
14
+
15
+ const response = [
16
+ [ items[0], items[1] ],
17
+ null,
18
+ [ items[2], items[3] ],
19
+ ];
20
+
21
+ const pageTokens = [
22
+ undefined,
23
+ 'token1',
24
+ 'token2',
25
+ ];
26
+
27
+ const getAPI = <T>(items: Array<Array<T> | null>, pageTokens: Array<string | undefined>) => ({
28
+ list : jest.fn().mockImplementation(async ({ pageToken }: {pageToken?: string}) => {
29
+ const listException = getListException();
30
+
31
+ if (listException) {
32
+ throw listException;
33
+ }
34
+
35
+ const index = pageTokens.indexOf(pageToken);
36
+
37
+ return {
38
+ data : {
39
+ items : items[index],
40
+ nextPageToken : pageTokens[index + 1],
41
+ pageInfo : !items[index] ? null : {
42
+ totalResults : items.reduce((sum, list) => sum + (list?.length || 0), 0),
43
+ },
44
+ },
45
+ };
46
+ }),
47
+ update : jest.fn(),
48
+ });
49
+
50
+ const args = { key : 'value' };
51
+
52
+ const profile = 'username1';
53
+ const calendarApi = {
54
+ calendarList : getAPI(response, pageTokens),
55
+ };
56
+
57
+ const googleAuth = {
58
+ revokeCredentials : jest.fn(),
59
+ };
60
+
61
+ const scopes = [ 'scope1', 'scope2' ];
62
+
63
+ const getListException: jest.Mock<Error | undefined> = jest.fn();
64
+
65
+ beforeEach(() => {
66
+ getListException.mockReturnValue(undefined);
67
+ });
68
+
69
+ jest.mock('googleapis', () => ({
70
+ google : {
71
+ calendar : jest.fn().mockImplementation(() => calendarApi),
72
+ },
73
+ }));
74
+
75
+ jest.mock<Partial<typeof auth>>('../auth', () => ({
76
+ getAuth : jest.fn().mockImplementation(() => googleAuth),
77
+ }));
78
+
79
+ jest.mock<Partial<typeof secrets>>('../secrets', () => ({
80
+ deleteCredentials : jest.fn(),
81
+ }));
82
+
83
+ jest.mock<Partial<typeof logger>>('@anmiles/logger', () => ({
84
+ log : jest.fn(),
85
+ }));
86
+
87
+ jest.mock<Partial<typeof sleep>>('@anmiles/sleep', () => jest.fn());
88
+
89
+ describe('src/lib/api', () => {
90
+ describe('getApi', () => {
91
+ it('should call getAuth', async () => {
92
+ await api.getApi('calendar', profile);
93
+
94
+ expect(auth.getAuth).toHaveBeenCalledWith(profile, undefined);
95
+ });
96
+
97
+ it('should pass temporariness and scopes', async () => {
98
+ await api.getApi('calendar', profile, { scopes, temporary : true });
99
+
100
+ expect(auth.getAuth).toHaveBeenCalledWith(profile, { scopes, temporary : true });
101
+ });
102
+
103
+ it('should get google api', async () => {
104
+ await api.getApi('calendar', profile);
105
+
106
+ expect(google.calendar).toHaveBeenCalledWith({ version : 'v3', auth : googleAuth });
107
+ });
108
+
109
+ it('should return instance wrapper for google api', async () => {
110
+ const instance = await api.getApi('calendar', profile, { scopes, temporary : true });
111
+
112
+ expect(instance).toEqual({ apiName : 'calendar', profile, authOptions : { scopes, temporary : true }, api : calendarApi, auth : googleAuth });
113
+ });
114
+ });
115
+
116
+ describe('Api', () => {
117
+ let instance: InstanceType<typeof api.Api<'calendar'>>;
118
+
119
+ beforeEach(async () => {
120
+ instance = await api.getApi('calendar', profile);
121
+ });
122
+
123
+ describe('constructor', () => {
124
+ it('should return instance', async () => {
125
+ const instance = new api.Api('calendar', profile, { scopes, temporary : true });
126
+
127
+ expect(instance).toEqual({ apiName : 'calendar', profile, authOptions : { scopes, temporary : true } });
128
+ });
129
+ });
130
+
131
+ describe('getItems', () => {
132
+ it('should call API list method for each page', async () => {
133
+ await instance.getItems((api) => api.calendarList, args);
134
+
135
+ pageTokens.forEach((pageToken) => {
136
+ expect(calendarApi.calendarList.list).toHaveBeenCalledWith({ ...args, pageToken });
137
+ });
138
+ });
139
+
140
+ it('should output progress by default', async () => {
141
+ await instance.getItems((api) => api.calendarList, args);
142
+
143
+ expect(logger.log).toHaveBeenCalledTimes(response.length);
144
+ expect(logger.log).toHaveBeenCalledWith('Getting items (2 of 4)...');
145
+ expect(logger.log).toHaveBeenCalledWith('Getting items (2 of many)...');
146
+ expect(logger.log).toHaveBeenCalledWith('Getting items (4 of 4)...');
147
+ });
148
+
149
+ it('should not output progress if hidden', async () => {
150
+ await instance.getItems((api) => api.calendarList, args, { hideProgress : true });
151
+
152
+ expect(logger.log).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it('should sleep after reach request', async () => {
156
+ await instance.getItems((api) => api.calendarList, args);
157
+
158
+ expect(sleep).toHaveBeenCalledTimes(response.length);
159
+ expect(sleep).toHaveBeenCalledWith(300);
160
+ });
161
+
162
+ it('should be initialized and called once if no API error', async () => {
163
+ const getItemsSpy = jest.spyOn(instance, 'getItems');
164
+ await instance.getItems((api) => api.calendarList, args);
165
+ expect(auth.getAuth).toHaveBeenCalledTimes(1);
166
+ expect(getItemsSpy).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it('should delete credentials, re-initialize api and retry while API exception is invalid_grant', async () => {
170
+ const error = new Error('invalid_grant');
171
+ // fail twice
172
+ getListException.mockReturnValueOnce(error).mockReturnValueOnce(error);
173
+
174
+ const getItemsSpy = jest.spyOn(instance, 'getItems');
175
+ await instance.getItems((api) => api.calendarList, args);
176
+ expect(secrets.deleteCredentials).toHaveBeenCalledWith(profile);
177
+ expect(auth.getAuth).toHaveBeenCalledTimes(3);
178
+ expect(getItemsSpy).toHaveBeenCalledTimes(3);
179
+ });
180
+
181
+ it('should re-throw API exception if not invalid_grant', async () => {
182
+ const error = new Error('random exception');
183
+ getListException.mockReturnValueOnce(error);
184
+ await expect(instance.getItems((api) => api.calendarList, args)).rejects.toEqual(error);
185
+ });
186
+
187
+ it('should return items data', async () => {
188
+ const items = await instance.getItems((api) => api.calendarList, args);
189
+
190
+ expect(items).toEqual(items);
191
+ });
192
+ });
193
+
194
+ describe('revoke', () => {
195
+ it('should delete credentials file for current profile', async () => {
196
+ await instance.revoke();
197
+ expect(secrets.deleteCredentials).toHaveBeenCalledWith(profile);
198
+ });
199
+
200
+ it('should revoke credentials in google API', async () => {
201
+ await instance.revoke();
202
+ expect(googleAuth.revokeCredentials).toHaveBeenCalledWith();
203
+ });
204
+ });
205
+ });
206
+ });
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import open from 'open';
5
5
  import type GoogleApis from 'googleapis';
6
6
  import logger from '@anmiles/logger';
7
+ import emitter from 'event-emitter';
7
8
  import paths from '../paths';
8
9
  import type { Secrets } from '../../types';
9
10
  import '@anmiles/prototypes';
@@ -16,26 +17,31 @@ jest.mock<typeof secrets>('../secrets', () => ({
16
17
  getCredentials : jest.fn(),
17
18
  validateCredentials : jest.fn(),
18
19
  createCredentials : jest.fn().mockImplementation(() => credentialsJSON),
20
+ deleteCredentials : jest.fn(),
19
21
  checkSecrets : jest.fn(),
20
22
  getScopesError : jest.fn().mockImplementation(() => scopesError),
21
23
  getSecretsError : jest.fn().mockImplementation(() => secretsError),
22
24
  }));
23
25
 
24
26
  jest.mock<Partial<typeof http>>('http', () => ({
25
- createServer : jest.fn().mockImplementation((callback) => {
26
- serverCallback = callback;
27
-
28
- return {
29
- on,
30
- listen,
31
- close,
32
- destroy,
33
- };
34
- }),
27
+ createServer : jest.fn().mockImplementation(() => server),
35
28
  }));
36
29
 
30
+ let server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
31
+ let response: http.ServerResponse;
32
+
33
+ async function makeRequest(url: string | undefined) {
34
+ server.emit('request', {
35
+ url,
36
+ headers : {
37
+ host,
38
+ },
39
+ } as http.IncomingMessage, response);
40
+ }
41
+
37
42
  jest.mock<Partial<typeof fs>>('fs', () => ({
38
43
  existsSync : jest.fn().mockImplementation(() => exists),
44
+ rmSync : jest.fn(),
39
45
  }));
40
46
 
41
47
  jest.mock<Partial<typeof path>>('path', () => ({
@@ -43,7 +49,7 @@ jest.mock<Partial<typeof path>>('path', () => ({
43
49
  }));
44
50
 
45
51
  jest.mock('open', () => jest.fn().mockImplementation((url: string) => {
46
- willOpen(url.replace('http://localhost:6006', ''));
52
+ makeRequest(url.replace('http://localhost:6006', ''));
47
53
  }));
48
54
 
49
55
  jest.mock<Partial<typeof logger>>('@anmiles/logger', () => ({
@@ -56,6 +62,12 @@ jest.mock<Partial<typeof paths>>('../paths', () => ({
56
62
  getCredentialsFile : jest.fn().mockImplementation(() => credentialsFile),
57
63
  }));
58
64
 
65
+ jest.useFakeTimers();
66
+
67
+ const port = 6006;
68
+ const host = `localhost:${port}`;
69
+ const callbackURI = `http://${host}/oauthcallback`;
70
+
59
71
  const profile = 'username1';
60
72
  const scopesFile = 'scopes.json';
61
73
  const secretsFile = 'secrets/username1.json';
@@ -79,7 +91,7 @@ const secretsJSON: Secrets = {
79
91
  token_uri : 'https://oauth2.googleapis.com/token',
80
92
  auth_provider_x509_cert_url : 'https://www.googleapis.com/oauth2/v1/certs',
81
93
  client_secret : 'client_secret',
82
- redirect_uris : [ 'http://localhost:6006/oauthcallback' ],
94
+ redirect_uris : [ callbackURI ],
83
95
  /* eslint-enable camelcase */
84
96
  },
85
97
  };
@@ -98,46 +110,6 @@ const auth = {
98
110
  getToken : jest.fn().mockResolvedValue({ tokens : credentialsJSON }),
99
111
  } as unknown as GoogleApis.Common.OAuth2Client;
100
112
 
101
- const response = {
102
- end : jest.fn(),
103
- } as unknown as http.ServerResponse;
104
-
105
- let serverCallback: (
106
- request: http.IncomingMessage,
107
- response: http.ServerResponse
108
- ) => Promise<typeof credentialsJSON>;
109
-
110
- function willOpen(url: string | undefined, timeout?: number) {
111
- setTimeout(async () => {
112
- await serverCallback({
113
- url,
114
- headers : {
115
- host : 'localhost:6006',
116
- },
117
- } as http.IncomingMessage, response);
118
- }, timeout || 0);
119
- }
120
- let closedTime: number;
121
-
122
- const on = jest.fn().mockImplementation((event: string, listener: (...args: any[]) => void) => {
123
- if (event === 'connection') {
124
- // always simulate opening several connections once connections are meant to be listened
125
- connections.forEach((connection) => listener(connection));
126
- }
127
- });
128
-
129
- const listen = jest.fn();
130
- const close = jest.fn().mockImplementation(() => {
131
- closedTime = new Date().getTime();
132
- });
133
- const destroy = jest.fn();
134
-
135
- const connections = [
136
- { remoteAddress : 'server', remotePort : '1001', on : jest.fn(), destroy : jest.fn() },
137
- { remoteAddress : 'server', remotePort : '1002', on : jest.fn(), destroy : jest.fn() },
138
- { remoteAddress : 'server', remotePort : '1003', on : jest.fn(), destroy : jest.fn() },
139
- ];
140
-
141
113
  let exists: boolean;
142
114
 
143
115
  let getJSONSpy: jest.SpyInstance;
@@ -360,10 +332,38 @@ describe('src/lib/secrets', () => {
360
332
  describe('createCredentials', () => {
361
333
  const tokenUrl = `/request.url?code=${code}`;
362
334
 
363
- it('should generate authUrl', async () => {
364
- willOpen(tokenUrl, 100);
335
+ const connections = [
336
+ { remoteAddress : 'server', remotePort : '1001', on : jest.fn(), destroy : jest.fn() },
337
+ { remoteAddress : 'server', remotePort : '1002', on : jest.fn(), destroy : jest.fn() },
338
+ { remoteAddress : 'server', remotePort : '1003', on : jest.fn(), destroy : jest.fn() },
339
+ ];
340
+
341
+ let endSpy: jest.SpyInstance;
342
+
343
+ beforeEach(() => {
344
+ server = emitter({
345
+ listen : jest.fn().mockImplementation(() => {
346
+ // always simulate opening several connections once connections are meant to be listened
347
+ connections.forEach((connection) => server.emit('connection', connection));
348
+ }),
349
+ close : jest.fn(),
350
+ destroy : jest.fn(),
351
+ }) as typeof server;
352
+
353
+ response = emitter({
354
+ end : jest.fn(),
355
+ }) as typeof response;
365
356
 
366
- await original.createCredentials(profile, auth);
357
+ endSpy = jest.spyOn(response, 'end');
358
+ });
359
+
360
+ afterAll(() => {
361
+ endSpy.mockRestore();
362
+ });
363
+
364
+ it('should generate authUrl', async () => {
365
+ original.createCredentials(profile, auth);
366
+ await Promise.resolve();
367
367
 
368
368
  expect(auth.generateAuthUrl).toHaveBeenCalledWith({
369
369
  // eslint-disable-next-line camelcase
@@ -377,9 +377,8 @@ describe('src/lib/secrets', () => {
377
377
  });
378
378
 
379
379
  it('should generate authUrl and require consent if explicitly asked', async () => {
380
- willOpen(tokenUrl, 100);
381
-
382
- await original.createCredentials(profile, auth, { temporary : true }, 'consent');
380
+ original.createCredentials(profile, auth, { temporary : true }, 'consent');
381
+ await Promise.resolve();
383
382
 
384
383
  expect(auth.generateAuthUrl).toHaveBeenCalledWith({
385
384
  // eslint-disable-next-line camelcase
@@ -393,9 +392,8 @@ describe('src/lib/secrets', () => {
393
392
  });
394
393
 
395
394
  it('should generate authUrl with custom scopes', async () => {
396
- willOpen(tokenUrl, 100);
397
-
398
- await original.createCredentials(profile, auth, { scopes : [ 'scope1', 'scope2' ] });
395
+ original.createCredentials(profile, auth, { scopes : [ 'scope1', 'scope2' ] });
396
+ await Promise.resolve();
399
397
 
400
398
  expect(auth.generateAuthUrl).toHaveBeenCalledWith({
401
399
  // eslint-disable-next-line camelcase
@@ -406,39 +404,45 @@ describe('src/lib/secrets', () => {
406
404
  });
407
405
 
408
406
  it('should create server on 6006 port', async () => {
409
- willOpen(tokenUrl, 100);
410
-
411
- await original.createCredentials(profile, auth);
407
+ original.createCredentials(profile, auth);
408
+ await Promise.resolve();
412
409
 
413
410
  expect(http.createServer).toHaveBeenCalled();
414
- expect(listen).toHaveBeenCalledWith(6006);
411
+ expect(server.listen).toHaveBeenCalledWith(6006);
415
412
  });
416
413
 
417
- it('should open browser page and warn about it', async () => {
418
- willOpen(tokenUrl, 100);
414
+ it('should open browser page and warn about it once listening', async () => {
415
+ original.createCredentials(profile, auth);
416
+ await Promise.resolve();
419
417
 
420
- await original.createCredentials(profile, auth);
418
+ server.emit('listening');
421
419
 
422
420
  expect(open).toHaveBeenCalledWith('http://localhost:6006/');
423
421
  expect(logger.warn).toHaveBeenCalledWith('Please check your browser for further actions');
424
422
  });
425
423
 
426
- it('should show nothing on the browser page if request.url is empty', async () => {
427
- willOpen('', 100);
428
- willOpen(tokenUrl, 200);
424
+ it('should not open browser page and warn about it until listening', async () => {
425
+ original.createCredentials(profile, auth);
426
+ await Promise.resolve();
429
427
 
430
- await original.createCredentials(profile, auth);
428
+ expect(open).not.toHaveBeenCalled();
429
+ expect(logger.warn).not.toHaveBeenCalled();
430
+ });
431
+
432
+ it('should show nothing on the browser page if request.url is empty', async () => {
433
+ original.createCredentials(profile, auth);
434
+ makeRequest('');
435
+ await Promise.resolve();
431
436
 
432
- expect(response.end).toHaveBeenCalledWith('');
437
+ expect(endSpy).toHaveBeenCalledWith('');
433
438
  });
434
439
 
435
440
  it('should show opening instructions if opened the home page', async () => {
436
- willOpen('/', 100);
437
- willOpen(tokenUrl, 200);
441
+ original.createCredentials(profile, auth);
442
+ makeRequest('/');
443
+ await Promise.resolve();
438
444
 
439
- await original.createCredentials(profile, auth);
440
-
441
- expect(response.end).toHaveBeenCalledWith(`\
445
+ expect(endSpy).toHaveBeenCalledWith(`\
442
446
  <div style="width: 100%;height: 100%;display: flex;align-items: start;justify-content: center">\n\
443
447
  <div style="padding: 0 1em;border: 1px solid black;font-family: Arial, sans-serif;margin: 1em;">\n\
444
448
  <p>Please open <a href="${authUrl}">auth page</a> using <strong>${profile}</strong> google profile</p>\n\
@@ -449,11 +453,11 @@ describe('src/lib/secrets', () => {
449
453
  });
450
454
 
451
455
  it('should ask to close webpage', async () => {
452
- willOpen(tokenUrl, 100);
453
-
454
- await original.createCredentials(profile, auth);
456
+ original.createCredentials(profile, auth);
457
+ makeRequest(tokenUrl);
458
+ await Promise.resolve();
455
459
 
456
- expect(response.end).toHaveBeenCalledWith('\
460
+ expect(endSpy).toHaveBeenCalledWith('\
457
461
  <div style="width: 100%;height: 100%;display: flex;align-items: start;justify-content: center">\n\
458
462
  <div style="padding: 0 1em;border: 1px solid black;font-family: Arial, sans-serif;margin: 1em;">\n\
459
463
  <p>Please close this page and return to application</p>\n\
@@ -462,58 +466,72 @@ describe('src/lib/secrets', () => {
462
466
  });
463
467
 
464
468
  it('should close server and destroy all connections if request.url is truthy', async () => {
465
- willOpen(tokenUrl, 100);
466
-
467
- await original.createCredentials(profile, auth);
469
+ original.createCredentials(profile, auth);
470
+ makeRequest(tokenUrl);
471
+ await Promise.resolve();
468
472
 
469
- expect(close).toHaveBeenCalled();
473
+ expect(server.close).toHaveBeenCalled();
470
474
 
471
475
  connections.forEach((connection) => expect(connection.destroy).toHaveBeenCalled());
472
476
  });
473
477
 
474
- it('should only resolve when request.url is truthy', async () => {
475
- const emptyRequestTime = 100;
476
- const requestTime = 200;
477
-
478
- const before = new Date().getTime();
479
- willOpen(undefined, emptyRequestTime);
480
- willOpen(tokenUrl, requestTime);
478
+ it('should close server and resolve if request.url is truthy', async () => {
479
+ const promise = original.createCredentials(profile, auth);
480
+ makeRequest(tokenUrl);
481
+ const result = await Promise.resolve(promise);
482
+ expect(result).toEqual(credentialsJSON);
483
+ expect(server.close).toHaveBeenCalledTimes(1);
484
+ });
481
485
 
482
- const result = await original.createCredentials(profile, auth);
483
- const after = new Date().getTime();
486
+ it('should not close server if request.url is falsy', async () => {
487
+ original.createCredentials(profile, auth);
488
+ makeRequest(undefined);
489
+ await Promise.resolve();
484
490
 
485
- expect(close).toHaveBeenCalledTimes(1);
486
- expect(closedTime - before).toBeGreaterThanOrEqual(requestTime - 1);
487
- expect(after - before).toBeGreaterThanOrEqual(requestTime - 1);
488
- expect(result).toEqual(credentialsJSON);
491
+ expect(server.close).not.toHaveBeenCalled();
489
492
  });
490
493
 
491
- it('should only resolve when request.url contains no code', async () => {
492
- const noCodeRequestTime = 100;
493
- const requestTime = 200;
494
+ it('should re-throw a server error if error is not EADDRINUSE', () => {
495
+ const error = { code : 'RANDOM', message : 'random error' } as NodeJS.ErrnoException;
494
496
 
495
- const before = new Date().getTime();
496
- willOpen('/request.url?param=value', noCodeRequestTime);
497
- willOpen(tokenUrl, requestTime);
497
+ original.createCredentials(profile, auth);
498
+ expect(() => server.emit('error', error)).toThrow(error.message);
499
+ });
498
500
 
499
- const result = await original.createCredentials(profile, auth);
500
- const after = new Date().getTime();
501
+ it('should not re-throw a server error and try to listen again in 1000 seconds if error is EADDRINUSE', () => {
502
+ const error = { code : 'EADDRINUSE' } as NodeJS.ErrnoException;
501
503
 
502
- expect(close).toHaveBeenCalledTimes(1);
503
- expect(closedTime - before).toBeGreaterThanOrEqual(requestTime - 1);
504
- expect(after - before).toBeGreaterThanOrEqual(requestTime - 1);
505
- expect(result).toEqual(credentialsJSON);
504
+ original.createCredentials(profile, auth);
505
+ expect(server.listen).toHaveBeenCalledTimes(1);
506
+ expect(() => server.emit('error', error)).not.toThrow();
507
+ expect(server.listen).toHaveBeenCalledTimes(1);
508
+ jest.advanceTimersByTime(1000);
509
+ expect(server.listen).toHaveBeenCalledTimes(2);
506
510
  });
507
511
 
508
512
  it('should return credentials JSON', async () => {
509
- willOpen(tokenUrl, 100);
510
-
511
- const result = await original.createCredentials(profile, auth);
513
+ const promise = original.createCredentials(profile, auth);
514
+ makeRequest(tokenUrl);
515
+ const result = await promise;
512
516
 
513
517
  expect(result).toEqual(credentialsJSON);
514
518
  });
515
519
  });
516
520
 
521
+ describe('deleteCredentials', () => {
522
+ it('should delete credentials file if exists', () => {
523
+ exists = true;
524
+ original.deleteCredentials(profile);
525
+ expect(fs.rmSync).toHaveBeenCalledWith(credentialsFile);
526
+ });
527
+
528
+ it('should not do anything if credentials file does not exist', () => {
529
+ exists = false;
530
+ original.deleteCredentials(profile);
531
+ expect(fs.rmSync).not.toHaveBeenCalled();
532
+ });
533
+ });
534
+
517
535
  describe('checkSecrets', () => {
518
536
  it('should return true if redirect_uri is correct', () => {
519
537
  const result = original.checkSecrets(profile, secretsJSON, secretsFile);