@pbvision/cloud-run-service 0.0.46

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,280 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ import { mixpanelToken } from '../src/analytics.js'
4
+
5
+ import { AppTest, runTests } from './base-test.js'
6
+
7
+ class AnalyticsTest extends AppTest {
8
+ async beforeEach () {
9
+ await super.beforeEach()
10
+ // mock using node-fetch to request the mixpanel APIs
11
+ this.fetchMock.mockResp(1)
12
+ }
13
+
14
+ async testMixpanelAPIFailure () {
15
+ this.fetchMock.mockResp(0)
16
+ await this.sendBasicEvent(null, 551)
17
+
18
+ this.fetchMock.mockResp('', 500)
19
+ await this.sendBasicEvent(null, 551)
20
+ }
21
+
22
+ async testNoAnalyticsLogged () {
23
+ await this.app.post('/analytics')
24
+ .send({})
25
+ .expect(200)
26
+ expect(this.fetchMock).not.toHaveBeenCalled()
27
+ }
28
+
29
+ async testSendingEvents () {
30
+ await this.sendBasicEvent()
31
+ expect(this.fetchMock).toHaveBeenCalledWith(
32
+ 'https://api.mixpanel.com/track',
33
+ expect.objectContaining({
34
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
35
+ method: 'POST',
36
+ compress: true
37
+ }))
38
+ const calls = this.fetchMock.mock.calls
39
+ expect(calls.length).toBe(1)
40
+ const actualBody = JSON.parse(calls[0][1].body)
41
+ expect(actualBody).toEqual([{
42
+ event: 'some event',
43
+ properties: expect.objectContaining({
44
+ cool: 1,
45
+ hi: 'world',
46
+ $user_id: 'some uid',
47
+ token: mixpanelToken
48
+ })
49
+ }])
50
+ const props = actualBody[0].properties
51
+ expect(typeof props.ip).toBe('string')
52
+ expect(new Date().getTime() - props.time).toBeLessThan(5000)
53
+ expect(props.$insert_id).toMatch(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)
54
+ }
55
+
56
+ async sendBasicEvent (userAgent, expRespCode = 200) {
57
+ const req = this.app.post('/analytics')
58
+ if (userAgent) {
59
+ req.set('User-Agent', userAgent)
60
+ }
61
+ await req.send({
62
+ eventCalls: [
63
+ ['some uid', 'some event', { cool: 1, hi: 'world' }]
64
+ ]
65
+ }).expect(expRespCode)
66
+ }
67
+
68
+ async testSendInBatches () {
69
+ await this.app.post('/analytics')
70
+ .send({
71
+ eventCalls: [
72
+ ['xyz', 'some event', { cool: 1, hi: 'world' }]
73
+ ],
74
+ moreEventCalls: [
75
+ ['xyz', 'another event', { x: 2 }]
76
+ ]
77
+ }).expect(200)
78
+ const calls = this.fetchMock.mock.calls
79
+ expect(calls.length).toBe(2)
80
+ const actualBody = JSON.parse(calls[0][1].body)
81
+ expect(actualBody).toEqual([{
82
+ event: 'some event',
83
+ properties: expect.objectContaining({
84
+ cool: 1,
85
+ hi: 'world',
86
+ $user_id: 'xyz',
87
+ token: mixpanelToken
88
+ })
89
+ }])
90
+ const actualBody2 = JSON.parse(calls[1][1].body)
91
+ expect(actualBody2).toEqual([{
92
+ event: 'another event',
93
+ properties: expect.objectContaining({
94
+ x: 2,
95
+ $user_id: 'xyz',
96
+ token: mixpanelToken
97
+ })
98
+ }])
99
+ }
100
+
101
+ async testDeviceId () {
102
+ await this.app.post('/analytics')
103
+ .send({
104
+ eventCalls: [
105
+ ['$device:xyz', 'some event', { cool: 1, hi: 'world' }]
106
+ ]
107
+ }).expect(200)
108
+ const calls = this.fetchMock.mock.calls
109
+ expect(calls.length).toBe(1)
110
+ const actualBody = JSON.parse(calls[0][1].body)
111
+ expect(actualBody).toEqual([{
112
+ event: 'some event',
113
+ properties: expect.objectContaining({
114
+ cool: 1,
115
+ hi: 'world',
116
+ distinct_id: '$device:xyz',
117
+ $device_id: 'xyz',
118
+ token: mixpanelToken
119
+ })
120
+ }])
121
+ }
122
+
123
+ async testDeviceIdWithUserId () {
124
+ await this.app.post('/analytics')
125
+ .send({
126
+ eventCalls: [
127
+ ['some uid', 'some event', { cool: 1, hi: 'world' }, '$device:xyz']
128
+ ]
129
+ }).expect(200)
130
+ const calls = this.fetchMock.mock.calls
131
+ expect(calls.length).toBe(1)
132
+ const actualBody = JSON.parse(calls[0][1].body)
133
+ expect(actualBody).toEqual([{
134
+ event: 'some event',
135
+ properties: expect.objectContaining({
136
+ cool: 1,
137
+ hi: 'world',
138
+ $user_id: 'some uid',
139
+ distinct_id: 'some uid',
140
+ $device_id: 'xyz',
141
+ token: mixpanelToken
142
+ })
143
+ }])
144
+ }
145
+
146
+ async testCustomInsertionId () {
147
+ await this.app.post('/analytics')
148
+ .send({
149
+ eventCalls: [
150
+ ['some uid', 'some event', { cool: 1, hi: 'world' }, null, 'xx']
151
+ ]
152
+ }).expect(200)
153
+ const calls = this.fetchMock.mock.calls
154
+ expect(calls.length).toBe(1)
155
+ const actualBody = JSON.parse(calls[0][1].body)
156
+ expect(actualBody).toEqual([{
157
+ event: 'some event',
158
+ properties: expect.objectContaining({
159
+ cool: 1,
160
+ hi: 'world',
161
+ $user_id: 'some uid',
162
+ token: mixpanelToken,
163
+ $insert_id: crypto.createHash('md5').update('xx').digest('hex')
164
+ })
165
+ }])
166
+ }
167
+
168
+ async testOmittingEventProperties () {
169
+ await this.app.post('/analytics')
170
+ .send({
171
+ eventCalls: [
172
+ ['some uid', 'some event']
173
+ ]
174
+ }).expect(200)
175
+ const calls = this.fetchMock.mock.calls
176
+ expect(calls.length).toBe(1)
177
+ const actualBody = JSON.parse(calls[0][1].body)
178
+ expect(actualBody).toEqual([{
179
+ event: 'some event',
180
+ properties: expect.objectContaining({
181
+ $user_id: 'some uid',
182
+ token: mixpanelToken
183
+ })
184
+ }])
185
+ }
186
+
187
+ async testUserAgent () {
188
+ await this.sendBasicEvent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36')
189
+ const calls = this.fetchMock.mock.calls
190
+ expect(calls.length).toBe(1)
191
+ const actualBody = JSON.parse(calls[0][1].body)
192
+ const props = actualBody[0].properties
193
+ expect(props.$browser).toBe('Chrome 122.0.0.0')
194
+ expect(props.$device).toBe(undefined)
195
+ expect(props.$os).toBe('Windows 10')
196
+ }
197
+
198
+ async testUserAgentWithDevice () {
199
+ await this.sendBasicEvent('Mozilla/5.0 (PlayBook; U; RIM Tablet OS 1.0.0; en-US) AppleWebKit/534.11 (KHTML, like Gecko) Version/7.1.0.7 Safari/534.11')
200
+ const calls = this.fetchMock.mock.calls
201
+ expect(calls.length).toBe(1)
202
+ const actualBody = JSON.parse(calls[0][1].body)
203
+ const props = actualBody[0].properties
204
+ expect(props.$browser).toBe('Safari 7.1.0.7')
205
+ expect(props.$device).toBe('RIM PlayBook tablet')
206
+ expect(props.$os).toBe('RIM Tablet OS 1.0.0')
207
+ }
208
+
209
+ async sendUserProfileUpdate (profileUpdates) {
210
+ const req = this.app.post('/analytics')
211
+ await req.send({ profileUpdates }).expect(200)
212
+ }
213
+
214
+ async testUserProfileUpdates () {
215
+ const uids = ['uid0', 'uid1']
216
+ await this.sendUserProfileUpdate([
217
+ [uids[0], 'p1', true],
218
+ [uids[0], 'p2', 5],
219
+ [uids[1], 'p2', 6],
220
+ [uids[1], 'p1', 'nope', '$set'],
221
+ [uids[1], 'p1', 'cool'],
222
+ [uids[1], 'p1', 'once', '$set_once'],
223
+ [uids[0], 'p1', false],
224
+ [uids[0], 'p3', 'hi']
225
+ ])
226
+ const calls = this.fetchMock.mock.calls
227
+ expect(calls.length).toBe(2)
228
+ const setURL = 'https://api.mixpanel.com/engage#profile-set'
229
+ const setOnceURL = 'https://api.mixpanel.com/engage#profile-set-once'
230
+ expect(this.fetchMock).toHaveBeenCalledWith(
231
+ setURL,
232
+ expect.objectContaining({
233
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
234
+ method: 'POST',
235
+ compress: true
236
+ }))
237
+ expect(this.fetchMock).toHaveBeenCalledWith(
238
+ setOnceURL,
239
+ expect.objectContaining({
240
+ headers: { accept: 'text/plain', 'content-type': 'application/json' },
241
+ method: 'POST',
242
+ compress: true
243
+ }))
244
+ // sort $set first, then $set_once (just so we have a consistent order for
245
+ // the test to check)
246
+ calls.sort((a, b) => a[0].localeCompare(b[0]))
247
+
248
+ // check $set updates
249
+ const actualSetUpdates = JSON.parse(calls[0][1].body)
250
+ actualSetUpdates.sort((a, b) => a.$distinct_id.localeCompare(b.$distinct_id))
251
+ const expectedSets = [
252
+ { p1: false, p2: 5, p3: 'hi' },
253
+ { p1: 'cool', p2: 6 }
254
+ ]
255
+ for (let i = 0; i < actualSetUpdates.length; i++) {
256
+ const actualArgs = actualSetUpdates[i]
257
+ expect(actualArgs).toEqual({
258
+ $token: mixpanelToken,
259
+ $distinct_id: uids[i],
260
+ $set: expectedSets[i]
261
+ })
262
+ }
263
+
264
+ // check $set_once updates
265
+ const actualSetOnceUpdates = JSON.parse(calls[1][1].body)
266
+ const expectedSetOnces = [
267
+ { p1: 'once' }
268
+ ]
269
+ for (let i = 0; i < actualSetOnceUpdates.length; i++) {
270
+ const actualArgs = actualSetOnceUpdates[i]
271
+ expect(actualArgs).toEqual({
272
+ $token: mixpanelToken,
273
+ $distinct_id: uids[1],
274
+ $set_once: expectedSetOnces[i]
275
+ })
276
+ }
277
+ }
278
+ }
279
+
280
+ runTests(AnalyticsTest)
@@ -0,0 +1,96 @@
1
+ import { jest } from '@jest/globals'
2
+ import { GoogleAuth } from 'google-auth-library'
3
+
4
+ import { AppTest, runTests } from './base-test.js'
5
+
6
+ class TestCallServiceAPI extends AppTest {
7
+ async beforeAll () {
8
+ await super.beforeAll()
9
+
10
+ // mock GoogleAuth because we can't actually get a token during testing
11
+ this.fetchIdToken = jest.fn().mockReturnValue('fake-token')
12
+ jest.spyOn(GoogleAuth.prototype, 'getIdTokenClient').mockImplementation(() => ({
13
+ idTokenProvider: {
14
+ fetchIdToken: this.fetchIdToken
15
+ }
16
+ }))
17
+ }
18
+
19
+ async beforeEach () {
20
+ await super.beforeEach()
21
+ // mock using node-fetch to request an API
22
+ this.fetchMock.mockResp()
23
+ this.fetchIdToken.mockClear()
24
+ GoogleAuth.prototype.getIdTokenClient.mockClear()
25
+ }
26
+
27
+ async afterAll () {
28
+ await super.afterAll()
29
+ jest.restoreAllMocks()
30
+ }
31
+
32
+ async check (args, respBody = '', expCode = 200, expPort = 8080) {
33
+ const result = await this.app.post('/callService').send(args).expect(200)
34
+ const resp = result.body
35
+ expect(resp.code).toBe(expCode)
36
+ expect(resp.body).toEqual(respBody)
37
+
38
+ const shouldHaveToken = args.isServiceInternal ?? true
39
+ const expHeaders = args.headers ?? {}
40
+ if (shouldHaveToken) {
41
+ expHeaders.Authorization = 'Bearer fake-token'
42
+ }
43
+ expect(this.fetchMock).toHaveBeenCalledWith(
44
+ `http://localhost:${expPort}${args.path}`, {
45
+ body: args.body,
46
+ headers: expHeaders,
47
+ method: args.method ?? 'POST',
48
+ compress: true
49
+ })
50
+
51
+ // make sure Google Auth was called with appropriate arguments (or not, if
52
+ // this wasn't an internal call)
53
+ if (shouldHaveToken) {
54
+ const targetAudience = `http://localhost:${expPort}/`
55
+ expect(GoogleAuth.prototype.getIdTokenClient).toHaveBeenCalledWith(targetAudience)
56
+ expect(this.fetchIdToken).toHaveBeenCalledWith(targetAudience)
57
+ } else {
58
+ expect(GoogleAuth.prototype.getIdTokenClient).not.toHaveBeenCalled()
59
+ }
60
+ }
61
+
62
+ async testServiceCallingItsOwnInternalAPI () {
63
+ await this.check({
64
+ service: process.env.SERVICE,
65
+ path: '/someAPI',
66
+ isServiceInternal: true
67
+ })
68
+ }
69
+
70
+ async testServiceCallingItsOwnPublicAPI () {
71
+ await this.check({
72
+ service: process.env.SERVICE,
73
+ path: '/someAPI',
74
+ isServiceInternal: false
75
+ })
76
+ }
77
+
78
+ async testServiceCallingAnotherOneOfItsAPIs () {
79
+ this.fetchMock.mockResp({ x: 3 })
80
+ await this.check({
81
+ service: process.env.SERVICE,
82
+ path: '/x/y/z'
83
+ }, JSON.stringify({ x: 3 }))
84
+ }
85
+
86
+ async testServiceCallingAnotherService () {
87
+ process.env.LOCAL_SERVICE_PORT_MAP = JSON.stringify({ notMe: 9999 })
88
+ this.fetchMock.mockResp('test resp', 222)
89
+ await this.check({
90
+ service: 'notMe',
91
+ path: '/x'
92
+ }, 'test resp', 222, 9999)
93
+ }
94
+ }
95
+
96
+ runTests(TestCallServiceAPI)
@@ -0,0 +1,10 @@
1
+ import { AppTest, runTests } from './base-test.js'
2
+
3
+ class PlaceholderTest extends AppTest {
4
+ async testTime () {
5
+ const ret = await this.app.get('/time').expect(200)
6
+ expect(Object.keys(ret.body)).toEqual(['epoch'])
7
+ }
8
+ }
9
+
10
+ runTests(PlaceholderTest)
@@ -0,0 +1,133 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ import { CloudTasksClient } from '@google-cloud/tasks'
4
+ import { expect, jest } from '@jest/globals'
5
+
6
+ import { enqueueCloudTask } from '../src/tasks.js'
7
+
8
+ import { BaseTest, runTests } from './base-test.js'
9
+
10
+ class TestTasks extends BaseTest {
11
+ async beforeAll () {
12
+ await super.beforeAll()
13
+
14
+ process.env.LOCAL_SERVICE_PORT_MAP = JSON.stringify({ internal: 8888 })
15
+
16
+ // mock GoogleAuth because we can't actually get a token during testing
17
+ this.createTaskReturnValue = new Promise(resolve => resolve())
18
+ jest.spyOn(CloudTasksClient.prototype, 'createTask').mockImplementation(
19
+ () => this.createTaskReturnValue)
20
+ }
21
+
22
+ async beforeEach () {
23
+ await super.beforeEach()
24
+ CloudTasksClient.prototype.createTask.mockClear()
25
+ }
26
+
27
+ async afterAll () {
28
+ await super.afterAll()
29
+ jest.restoreAllMocks()
30
+ }
31
+
32
+ async check (args, taskExpectations = {}, expRejectionMsg = null) {
33
+ const promise = enqueueCloudTask({
34
+ queue: 'test-queue',
35
+ ...args
36
+ })
37
+ if (expRejectionMsg) {
38
+ await expect(promise).rejects.toThrow(expRejectionMsg)
39
+ } else {
40
+ await promise
41
+ }
42
+ expect(CloudTasksClient.prototype.createTask).toHaveBeenCalledWith({
43
+ // assuming values for project (localhost-emulator) and region (us-central1) and
44
+ // service (tbd)
45
+ parent: 'projects/localhost-emulator/locations/us-central1/queues/test-queue',
46
+ task: {
47
+ httpRequest: {
48
+ body: Buffer.from(JSON.stringify(args.payload)).toString('base64'),
49
+ headers: { 'Content-Type': 'application/json' },
50
+ httpMethod: 'POST',
51
+ oidcToken: {
52
+ serviceAccountEmail: 'cr-tbd@localhost-emulator.iam.gserviceaccount.com'
53
+ },
54
+ url: 'http://localhost:8888/test_queue'
55
+ },
56
+ ...taskExpectations
57
+ }
58
+ })
59
+ }
60
+
61
+ async testEnqueueTask () {
62
+ await this.check({ payload: { x: 3 } })
63
+ }
64
+
65
+ async testEnqueueTaskWithDelay () {
66
+ const now = new Date().getTime() / 1000
67
+ await this.check(
68
+ { payload: { x: 3 }, delaySecs: 10 },
69
+ { scheduleTime: { seconds: expect.closeTo(now + 10, -0.7) } })
70
+ }
71
+
72
+ async testEnqueueTaskWithSchedule () {
73
+ const target = 100 + Math.floor(new Date().getTime() / 1000)
74
+ await this.check(
75
+ { payload: { x: 3 }, scheduledEpoch: target },
76
+ { scheduleTime: { seconds: target } })
77
+ }
78
+
79
+ async testEnqueueTaskWithName () {
80
+ await this.check(
81
+ { name: 'x', payload: { x: 3 } },
82
+ { name: 'projects/localhost-emulator/locations/us-central1/queues/test-queue/tasks/x' })
83
+ }
84
+
85
+ async testEnqueueTaskWithHashName () {
86
+ const partsToCheck = [
87
+ [], ['x'], ['x', 'yyy', '']
88
+ ]
89
+ for (const hashNameParts of partsToCheck) {
90
+ const expName = crypto.createHash('md5').update(
91
+ ['test-queue'].concat(hashNameParts).join('|')).digest('hex')
92
+ await this.check(
93
+ { hashNameParts, payload: { x: 3 } },
94
+ { name: 'projects/localhost-emulator/locations/us-central1/queues/test-queue/tasks/' + expName })
95
+ }
96
+ }
97
+
98
+ async testEnqueueTaskWithNameThatWasRecentlyUsedNOTOkay () {
99
+ this.createTaskReturnValue = new Promise((resolve, reject) => {
100
+ const err = new Error('6 ALREADY_EXISTS and other random details')
101
+ err.code = 6
102
+ reject(err)
103
+ })
104
+ await this.check(
105
+ { name: 'x', payload: { x: 3 }, ignoreNameAlreadyUsedError: true },
106
+ { name: 'projects/localhost-emulator/locations/us-central1/queues/test-queue/tasks/x' })
107
+ }
108
+
109
+ async testEnqueueTaskWithNameThatWasRecentlyUsedAndThatsOkay () {
110
+ this.createTaskReturnValue = new Promise((resolve, reject) => {
111
+ const err = new Error('6 ALREADY_EXISTS and other random details')
112
+ err.code = 6
113
+ reject(err)
114
+ })
115
+ await this.check(
116
+ { name: 'x', payload: { x: 3 } },
117
+ { name: 'projects/localhost-emulator/locations/us-central1/queues/test-queue/tasks/x' },
118
+ 'task name already used recently: x')
119
+ }
120
+
121
+ async testEnqueueTaskThrowsUnknownExceptions () {
122
+ this.createTaskReturnValue = new Promise((resolve, reject) => {
123
+ const err = new Error('ALREADY_EXISTS but not in right format')
124
+ reject(err)
125
+ })
126
+ await this.check(
127
+ { name: 'x', payload: { x: 3 } },
128
+ { name: 'projects/localhost-emulator/locations/us-central1/queues/test-queue/tasks/x' },
129
+ 'not in right format')
130
+ }
131
+ }
132
+
133
+ runTests(TestTasks)
@@ -0,0 +1,44 @@
1
+ import { jest } from '@jest/globals'
2
+
3
+ import { BaseTest, runTests } from '../node_modules/@pbvision/fastify-firestore-service/test/base-test.js'
4
+ import { port } from '../src/port.js'
5
+ import { getServiceHost, isCloud, isDev, isLocalhost, isProd, usingEmulator } from '../src/utils.js'
6
+
7
+ const ORIG_ENV = process.env
8
+
9
+ class TestUtils extends BaseTest {
10
+ beforeEach () {
11
+ jest.resetModules()
12
+ process.env = { ...ORIG_ENV }
13
+ }
14
+
15
+ afterEach () {
16
+ process.env = ORIG_ENV
17
+ }
18
+
19
+ testEnv () {
20
+ expect(process.env.GIT_HASH).toBe(undefined)
21
+ expect(process.env.K_REVISION).toBe('unittest')
22
+ expect(process.env.NODE_ENV).toBe('localhost')
23
+ expect(isLocalhost).toBe(true)
24
+ expect(isCloud).toBe(false)
25
+ expect(isProd).toBe(false)
26
+ expect(isDev).toBe(false)
27
+ expect(usingEmulator).toBe(true)
28
+ }
29
+
30
+ testGetServiceHost () {
31
+ expect(getServiceHost(process.env.SERVICE)).toBe(`localhost:${port}`)
32
+ expect(() => getServiceHost('unknown')).toThrow()
33
+ process.env.LOCAL_SERVICE_PORT_MAP = JSON.stringify({ unknown: 8088 })
34
+ expect(getServiceHost('unknown')).toBe('localhost:8088')
35
+ expect(() => getServiceHost('actually_unknown')).toThrow()
36
+
37
+ process.env.NODE_ENV = 'dev'
38
+ const testSuffix = '-tbd-uc.a.run.app'
39
+ process.env.CLOUD_RUN_HOSTNAME_SUFFIX = testSuffix
40
+ expect(getServiceHost('unknown')).toBe('unknown' + testSuffix)
41
+ }
42
+ }
43
+
44
+ runTests(TestUtils)