@pbvision/cloud-run-service 0.0.29 → 0.0.31
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/.env +1 -1
- package/.vscode/settings.json +2 -1
- package/package.json +6 -4
- package/src/analytics.js +151 -0
- package/src/index.js +5 -0
- package/src/placeholder.js +27 -1
- package/test/base-test.js +6 -2
- package/test/main.js +2 -2
- package/test/unit-test-analytics.js +235 -0
package/.env
CHANGED
package/.vscode/settings.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pbvision/cloud-run-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.31",
|
|
4
4
|
"description": "fastify-firestore-service Web Framework on Cloud Run",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
"exports": "./src/index.js",
|
|
11
11
|
"scripts": {
|
|
12
12
|
"coverage": "yarn -s start-local-db && yarn -s test --coverage",
|
|
13
|
-
"debug": "./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./test --inspect=9229
|
|
13
|
+
"debug": "yarn start-local-db && INDEBUGGER=1 ./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./test --exec 'node --inspect=9229 --env-file=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json --runInBand'",
|
|
14
14
|
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
|
15
15
|
"setup": "yarn install --frozen-lockfile",
|
|
16
16
|
"start-local-db": "./node_modules/@pbvision/firestore-orm/scripts/start-local-db.sh",
|
|
17
|
-
"start-local": "yarn -s start-local-db && K_REVISION=localhost node --env-file=.env test/main.js",
|
|
17
|
+
"start-local": "yarn -s start-local-db && K_REVISION=localhost PORT=8090 node --env-file=.env test/main.js",
|
|
18
18
|
"test": "yarn -s start-local-db && yarn -s test-without-starting-db",
|
|
19
19
|
"test-without-starting-db": "node --env-file=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json",
|
|
20
20
|
"watch": "yarn -s test --watch"
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@google-cloud/tasks": "^4.0.1",
|
|
31
31
|
"@pbvision/fastify-firestore-service": "^0.0.38",
|
|
32
|
-
"google-auth-library": "^9.4.2"
|
|
32
|
+
"google-auth-library": "^9.4.2",
|
|
33
|
+
"ua-parser-js": "^1.0.37"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@babel/core": "^7.17.12",
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"eslint-plugin-promise": "^6.0.0",
|
|
47
48
|
"firebase-tools": "^13.5.2",
|
|
48
49
|
"jest": "^29.7.0",
|
|
50
|
+
"nodemon": "^3.1.0",
|
|
49
51
|
"standard": "^17.1.0",
|
|
50
52
|
"superagent": "^8",
|
|
51
53
|
"superagent-defaults": "^0.1.14",
|
package/src/analytics.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { DatabaseAPI, EXCEPTIONS } from '@pbvision/fastify-firestore-service'
|
|
5
|
+
import UAParser from 'ua-parser-js'
|
|
6
|
+
|
|
7
|
+
import { isProd } from './utils.js'
|
|
8
|
+
|
|
9
|
+
// istanbul ignore next
|
|
10
|
+
export const mixpanelToken = isProd ? '78c48e38f59ab21c1850740e2bb4ecff' : '52bd993b07bdba759c2f141345e7c32a'
|
|
11
|
+
|
|
12
|
+
const mixpanelUpdateProfileURLs = {
|
|
13
|
+
$set: 'https://api.mixpanel.com/engage#profile-set',
|
|
14
|
+
$set_once: 'https://api.mixpanel.com/engage#profile-set-once'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Analytics are only sent if the transaction commits. Aborting or throwing an
|
|
18
|
+
// exception from the tx will result in analytics NOT being sent to mixpanel.
|
|
19
|
+
export class DatabaseAPIWithAnalytics extends DatabaseAPI {
|
|
20
|
+
constructor (fastify, req, reply) {
|
|
21
|
+
super(fastify, req, reply)
|
|
22
|
+
this.__analyticsEvents = []
|
|
23
|
+
this.__analyticsUserProfileUpdates = {} // uid to $set/$set_once to changes
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async postCommit (respData) {
|
|
27
|
+
// send analytics events after committing
|
|
28
|
+
await this.sendAnalyticsEvents()
|
|
29
|
+
return super.postCommit(respData)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
logAnalyticsEvent (mixpanelUserId, eventName, inputProperties, deviceId = null) {
|
|
33
|
+
this.__analyticsEvents.push({
|
|
34
|
+
event: eventName,
|
|
35
|
+
properties: addSenderId(mixpanelUserId, {
|
|
36
|
+
...inputProperties,
|
|
37
|
+
token: mixpanelToken,
|
|
38
|
+
time: new Date().getTime(),
|
|
39
|
+
$insert_id: randomUUID(),
|
|
40
|
+
ip: this.req.ip
|
|
41
|
+
}, deviceId)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
updateAnalyticsUserProfile (uid, key, value, method = '$set') {
|
|
46
|
+
assert(mixpanelUpdateProfileURLs[method])
|
|
47
|
+
// should be our user id not a device id... profile data is not recommended
|
|
48
|
+
// for anonymous users
|
|
49
|
+
assert(!uid.startsWith('$device:'))
|
|
50
|
+
if (!this.__analyticsUserProfileUpdates[method]) {
|
|
51
|
+
this.__analyticsUserProfileUpdates[method] = {}
|
|
52
|
+
}
|
|
53
|
+
const updatesByUser = this.__analyticsUserProfileUpdates[method]
|
|
54
|
+
if (!updatesByUser[uid]) {
|
|
55
|
+
updatesByUser[uid] = {}
|
|
56
|
+
}
|
|
57
|
+
updatesByUser[uid][key] = value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async sendAnalyticsEvents () {
|
|
61
|
+
const events = this.__analyticsEvents
|
|
62
|
+
this.__analyticsEvents = []
|
|
63
|
+
const userProfileUpdates = this.__analyticsUserProfileUpdates
|
|
64
|
+
this.__analyticsUserProfileUpdates = {}
|
|
65
|
+
|
|
66
|
+
const promises = []
|
|
67
|
+
if (events.length) {
|
|
68
|
+
const uaData = {}
|
|
69
|
+
const parser = new UAParser(this.req.headers['user-agent'])
|
|
70
|
+
const browser = parser.getBrowser()
|
|
71
|
+
if (browser.name) {
|
|
72
|
+
uaData.$browser = browser.name
|
|
73
|
+
// istanbul ignore else
|
|
74
|
+
if (browser.version) {
|
|
75
|
+
uaData.$browser += ` ${browser.version}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const device = parser.getDevice()
|
|
79
|
+
const $device = [device.vendor, device.model, device.type].filter(x => !!x).join(' ')
|
|
80
|
+
if ($device) {
|
|
81
|
+
uaData.$device = $device
|
|
82
|
+
}
|
|
83
|
+
const os = parser.getOS()
|
|
84
|
+
if (os.name) {
|
|
85
|
+
uaData.$os = os.name
|
|
86
|
+
// istanbul ignore else
|
|
87
|
+
if (os.version) {
|
|
88
|
+
uaData.$os += ` ${os.version}`
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const x of events) {
|
|
93
|
+
Object.assign(x.properties, uaData)
|
|
94
|
+
}
|
|
95
|
+
// send all the events in one Mixpanel API call
|
|
96
|
+
promises.push(this.callAPI({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
url: 'https://api.mixpanel.com/track',
|
|
99
|
+
headers: { accept: 'text/plain' },
|
|
100
|
+
body: events
|
|
101
|
+
}))
|
|
102
|
+
}
|
|
103
|
+
const sent = []
|
|
104
|
+
for (const type of Object.keys(userProfileUpdates)) {
|
|
105
|
+
const updatesByUser = userProfileUpdates[type]
|
|
106
|
+
const body = []
|
|
107
|
+
for (const uid of Object.keys(updatesByUser)) {
|
|
108
|
+
const updates = updatesByUser[uid]
|
|
109
|
+
body.push({
|
|
110
|
+
$distinct_id: uid,
|
|
111
|
+
$token: mixpanelToken,
|
|
112
|
+
[type]: updates
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
promises.push(this.__sendUserProfileUpdates(type, body))
|
|
116
|
+
sent.push({ type, body })
|
|
117
|
+
}
|
|
118
|
+
const responses = await Promise.all(promises)
|
|
119
|
+
for (let i = 0; i < responses.length; i++) {
|
|
120
|
+
const resp = responses[i]
|
|
121
|
+
if (!resp.isOk || resp.data !== 1) {
|
|
122
|
+
console.log('error response from mixpanel', resp, sent)
|
|
123
|
+
throw new EXCEPTIONS.RequestError(
|
|
124
|
+
'failed to log analytics', { resp, sent }, 551)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async __sendUserProfileUpdates (type, body) {
|
|
130
|
+
const mixpanelApiURL = mixpanelUpdateProfileURLs[type]
|
|
131
|
+
assert(mixpanelApiURL) // make sure a valid type was passed
|
|
132
|
+
return this.callAPI({
|
|
133
|
+
method: 'POST',
|
|
134
|
+
url: mixpanelApiURL,
|
|
135
|
+
headers: { accept: 'text/plain' },
|
|
136
|
+
body
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function addSenderId (mixpanelUserId, properties, deviceId) {
|
|
142
|
+
if (mixpanelUserId.startsWith('$device:')) {
|
|
143
|
+
properties.$device_id = mixpanelUserId
|
|
144
|
+
} else {
|
|
145
|
+
properties.$user_id = mixpanelUserId
|
|
146
|
+
if (deviceId) {
|
|
147
|
+
properties.$device_id = deviceId
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return properties
|
|
151
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DatabaseAPIWithAnalytics, mixpanelToken } from './analytics.js'
|
|
1
2
|
import { makeService, runService } from './main.js'
|
|
2
3
|
import { enqueueCloudTask } from './tasks.js'
|
|
3
4
|
import { isCloud, isDev, isProd, isLocalhost, isUnitTesting, getServiceProtocolAndHost } from './utils.js'
|
|
@@ -7,6 +8,10 @@ export {
|
|
|
7
8
|
makeService, // for use with unit testing
|
|
8
9
|
runService,
|
|
9
10
|
|
|
11
|
+
// analytics
|
|
12
|
+
DatabaseAPIWithAnalytics, mixpanelToken,
|
|
13
|
+
|
|
14
|
+
// utility
|
|
10
15
|
getServiceProtocolAndHost,
|
|
11
16
|
isCloud, isDev, isProd, isLocalhost, isUnitTesting
|
|
12
17
|
}
|
package/src/placeholder.js
CHANGED
|
@@ -6,7 +6,9 @@ import { API, DatabaseAPI } from '@pbvision/fastify-firestore-service'
|
|
|
6
6
|
import db from '@pbvision/firestore-orm'
|
|
7
7
|
import S from '@pbvision/schema'
|
|
8
8
|
|
|
9
|
-
import { isUnitTesting } from './utils.js'
|
|
9
|
+
import { isLocalhost, isUnitTesting } from './utils.js'
|
|
10
|
+
|
|
11
|
+
import { DatabaseAPIWithAnalytics } from './index.js'
|
|
10
12
|
|
|
11
13
|
class Test extends db.Model {
|
|
12
14
|
static KEY = { id: S.str }
|
|
@@ -43,3 +45,27 @@ export class TestCallServiceAPI extends API {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
export class TestAnalyticsAPI extends DatabaseAPIWithAnalytics {
|
|
50
|
+
static PATH = '/analytics'
|
|
51
|
+
static DESC = 'This is used by unit tests only to test DatabaseAPIWithAnalytics.'
|
|
52
|
+
static BODY = S.obj()
|
|
53
|
+
|
|
54
|
+
async computeResponse () {
|
|
55
|
+
// istanbul ignore next
|
|
56
|
+
assert(isUnitTesting || isLocalhost)
|
|
57
|
+
const { eventCalls, profileUpdates, moreEventCalls } = this.req.body
|
|
58
|
+
for (const x of (eventCalls ?? [])) {
|
|
59
|
+
this.logAnalyticsEvent(...x)
|
|
60
|
+
}
|
|
61
|
+
for (const x of (profileUpdates ?? [])) {
|
|
62
|
+
this.updateAnalyticsUserProfile(...x)
|
|
63
|
+
}
|
|
64
|
+
if (moreEventCalls) {
|
|
65
|
+
await this.sendAnalyticsEvents()
|
|
66
|
+
for (const x of moreEventCalls) {
|
|
67
|
+
this.logAnalyticsEvent(...x)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/test/base-test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BaseAppTest, BaseTest, runTests } from '../node_modules/@pbvision/fastify-firestore-service/test/base-test.js'
|
|
2
|
-
const { TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
|
|
3
2
|
|
|
3
|
+
const { TestAnalyticsAPI, TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
|
|
4
4
|
export {
|
|
5
5
|
BaseTest, runTests
|
|
6
6
|
}
|
|
@@ -8,6 +8,10 @@ export {
|
|
|
8
8
|
export class AppTest extends BaseAppTest {
|
|
9
9
|
async getMakeServiceFunc () {
|
|
10
10
|
const { makeService } = await import('../src/main.js')
|
|
11
|
-
return () => makeService({
|
|
11
|
+
return () => makeService({
|
|
12
|
+
TestAnalyticsAPI,
|
|
13
|
+
TestAPI,
|
|
14
|
+
TestCallServiceAPI
|
|
15
|
+
})
|
|
12
16
|
}
|
|
13
17
|
}
|
package/test/main.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { runService } from '../src/index.js'
|
|
2
|
-
const { TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
|
|
2
|
+
const { TestAnalyticsAPI, TestAPI, TestCallServiceAPI } = await import('../src/placeholder.js')
|
|
3
3
|
|
|
4
|
-
await runService({ TestAPI, TestCallServiceAPI })
|
|
4
|
+
await runService({ TestAnalyticsAPI, TestAPI, TestCallServiceAPI })
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { mixpanelToken } from '../src/analytics.js'
|
|
2
|
+
|
|
3
|
+
import { AppTest, runTests } from './base-test.js'
|
|
4
|
+
|
|
5
|
+
class AnalyticsTest extends AppTest {
|
|
6
|
+
async beforeEach () {
|
|
7
|
+
await super.beforeEach()
|
|
8
|
+
// mock using node-fetch to request the mixpanel APIs
|
|
9
|
+
this.fetchMock.mockResp(1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async testMixpanelAPIFailure () {
|
|
13
|
+
this.fetchMock.mockResp(0)
|
|
14
|
+
await this.sendBasicEvent(null, 551)
|
|
15
|
+
|
|
16
|
+
this.fetchMock.mockResp('', 500)
|
|
17
|
+
await this.sendBasicEvent(null, 551)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async testNoAnalyticsLogged () {
|
|
21
|
+
await this.app.post('/analytics')
|
|
22
|
+
.send({})
|
|
23
|
+
.expect(200)
|
|
24
|
+
expect(this.fetchMock).not.toHaveBeenCalled()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async testSendingEvents () {
|
|
28
|
+
await this.sendBasicEvent()
|
|
29
|
+
expect(this.fetchMock).toHaveBeenCalledWith(
|
|
30
|
+
'https://api.mixpanel.com/track',
|
|
31
|
+
expect.objectContaining({
|
|
32
|
+
headers: { accept: 'text/plain', 'content-type': 'application/json' },
|
|
33
|
+
method: 'POST',
|
|
34
|
+
compress: true
|
|
35
|
+
}))
|
|
36
|
+
const calls = this.fetchMock.mock.calls
|
|
37
|
+
expect(calls.length).toBe(1)
|
|
38
|
+
const actualBody = JSON.parse(calls[0][1].body)
|
|
39
|
+
expect(actualBody).toEqual([{
|
|
40
|
+
event: 'some event',
|
|
41
|
+
properties: expect.objectContaining({
|
|
42
|
+
cool: 1,
|
|
43
|
+
hi: 'world',
|
|
44
|
+
$user_id: 'some uid',
|
|
45
|
+
token: mixpanelToken
|
|
46
|
+
})
|
|
47
|
+
}])
|
|
48
|
+
const props = actualBody[0].properties
|
|
49
|
+
expect(typeof props.ip).toBe('string')
|
|
50
|
+
expect(new Date().getTime() - props.time).toBeLessThan(5000)
|
|
51
|
+
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}/)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async sendBasicEvent (userAgent, expRespCode = 200) {
|
|
55
|
+
const req = this.app.post('/analytics')
|
|
56
|
+
if (userAgent) {
|
|
57
|
+
req.set('User-Agent', userAgent)
|
|
58
|
+
}
|
|
59
|
+
await req.send({
|
|
60
|
+
eventCalls: [
|
|
61
|
+
['some uid', 'some event', { cool: 1, hi: 'world' }]
|
|
62
|
+
]
|
|
63
|
+
}).expect(expRespCode)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async testSendInBatches () {
|
|
67
|
+
await this.app.post('/analytics')
|
|
68
|
+
.send({
|
|
69
|
+
eventCalls: [
|
|
70
|
+
['xyz', 'some event', { cool: 1, hi: 'world' }]
|
|
71
|
+
],
|
|
72
|
+
moreEventCalls: [
|
|
73
|
+
['xyz', 'another event', { x: 2 }]
|
|
74
|
+
]
|
|
75
|
+
}).expect(200)
|
|
76
|
+
const calls = this.fetchMock.mock.calls
|
|
77
|
+
expect(calls.length).toBe(2)
|
|
78
|
+
const actualBody = JSON.parse(calls[0][1].body)
|
|
79
|
+
expect(actualBody).toEqual([{
|
|
80
|
+
event: 'some event',
|
|
81
|
+
properties: expect.objectContaining({
|
|
82
|
+
cool: 1,
|
|
83
|
+
hi: 'world',
|
|
84
|
+
$user_id: 'xyz',
|
|
85
|
+
token: mixpanelToken
|
|
86
|
+
})
|
|
87
|
+
}])
|
|
88
|
+
const actualBody2 = JSON.parse(calls[1][1].body)
|
|
89
|
+
expect(actualBody2).toEqual([{
|
|
90
|
+
event: 'another event',
|
|
91
|
+
properties: expect.objectContaining({
|
|
92
|
+
x: 2,
|
|
93
|
+
$user_id: 'xyz',
|
|
94
|
+
token: mixpanelToken
|
|
95
|
+
})
|
|
96
|
+
}])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async testDeviceId () {
|
|
100
|
+
await this.app.post('/analytics')
|
|
101
|
+
.send({
|
|
102
|
+
eventCalls: [
|
|
103
|
+
['$device:xyz', 'some event', { cool: 1, hi: 'world' }]
|
|
104
|
+
]
|
|
105
|
+
}).expect(200)
|
|
106
|
+
const calls = this.fetchMock.mock.calls
|
|
107
|
+
expect(calls.length).toBe(1)
|
|
108
|
+
const actualBody = JSON.parse(calls[0][1].body)
|
|
109
|
+
expect(actualBody).toEqual([{
|
|
110
|
+
event: 'some event',
|
|
111
|
+
properties: expect.objectContaining({
|
|
112
|
+
cool: 1,
|
|
113
|
+
hi: 'world',
|
|
114
|
+
$device_id: '$device:xyz',
|
|
115
|
+
token: mixpanelToken
|
|
116
|
+
})
|
|
117
|
+
}])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async testDeviceIdWithUserId () {
|
|
121
|
+
await this.app.post('/analytics')
|
|
122
|
+
.send({
|
|
123
|
+
eventCalls: [
|
|
124
|
+
['some uid', 'some event', { cool: 1, hi: 'world' }, '$device:xyz']
|
|
125
|
+
]
|
|
126
|
+
}).expect(200)
|
|
127
|
+
const calls = this.fetchMock.mock.calls
|
|
128
|
+
expect(calls.length).toBe(1)
|
|
129
|
+
const actualBody = JSON.parse(calls[0][1].body)
|
|
130
|
+
expect(actualBody).toEqual([{
|
|
131
|
+
event: 'some event',
|
|
132
|
+
properties: expect.objectContaining({
|
|
133
|
+
cool: 1,
|
|
134
|
+
hi: 'world',
|
|
135
|
+
$user_id: 'some uid',
|
|
136
|
+
$device_id: '$device:xyz',
|
|
137
|
+
token: mixpanelToken
|
|
138
|
+
})
|
|
139
|
+
}])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async testUserAgent () {
|
|
143
|
+
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')
|
|
144
|
+
const calls = this.fetchMock.mock.calls
|
|
145
|
+
expect(calls.length).toBe(1)
|
|
146
|
+
const actualBody = JSON.parse(calls[0][1].body)
|
|
147
|
+
const props = actualBody[0].properties
|
|
148
|
+
expect(props.$browser).toBe('Chrome 122.0.0.0')
|
|
149
|
+
expect(props.$device).toBe(undefined)
|
|
150
|
+
expect(props.$os).toBe('Windows 10')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async testUserAgentWithDevice () {
|
|
154
|
+
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')
|
|
155
|
+
const calls = this.fetchMock.mock.calls
|
|
156
|
+
expect(calls.length).toBe(1)
|
|
157
|
+
const actualBody = JSON.parse(calls[0][1].body)
|
|
158
|
+
const props = actualBody[0].properties
|
|
159
|
+
expect(props.$browser).toBe('Safari 7.1.0.7')
|
|
160
|
+
expect(props.$device).toBe('RIM PlayBook tablet')
|
|
161
|
+
expect(props.$os).toBe('RIM Tablet OS 1.0.0')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async sendUserProfileUpdate (profileUpdates) {
|
|
165
|
+
const req = this.app.post('/analytics')
|
|
166
|
+
await req.send({ profileUpdates }).expect(200)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async testUserProfileUpdates () {
|
|
170
|
+
const uids = ['uid0', 'uid1']
|
|
171
|
+
await this.sendUserProfileUpdate([
|
|
172
|
+
[uids[0], 'p1', true],
|
|
173
|
+
[uids[0], 'p2', 5],
|
|
174
|
+
[uids[1], 'p2', 6],
|
|
175
|
+
[uids[1], 'p1', 'nope', '$set'],
|
|
176
|
+
[uids[1], 'p1', 'cool'],
|
|
177
|
+
[uids[1], 'p1', 'once', '$set_once'],
|
|
178
|
+
[uids[0], 'p1', false],
|
|
179
|
+
[uids[0], 'p3', 'hi']
|
|
180
|
+
])
|
|
181
|
+
const calls = this.fetchMock.mock.calls
|
|
182
|
+
expect(calls.length).toBe(2)
|
|
183
|
+
const setURL = 'https://api.mixpanel.com/engage#profile-set'
|
|
184
|
+
const setOnceURL = 'https://api.mixpanel.com/engage#profile-set-once'
|
|
185
|
+
expect(this.fetchMock).toHaveBeenCalledWith(
|
|
186
|
+
setURL,
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
headers: { accept: 'text/plain', 'content-type': 'application/json' },
|
|
189
|
+
method: 'POST',
|
|
190
|
+
compress: true
|
|
191
|
+
}))
|
|
192
|
+
expect(this.fetchMock).toHaveBeenCalledWith(
|
|
193
|
+
setOnceURL,
|
|
194
|
+
expect.objectContaining({
|
|
195
|
+
headers: { accept: 'text/plain', 'content-type': 'application/json' },
|
|
196
|
+
method: 'POST',
|
|
197
|
+
compress: true
|
|
198
|
+
}))
|
|
199
|
+
// sort $set first, then $set_once (just so we have a consistent order for
|
|
200
|
+
// the test to check)
|
|
201
|
+
calls.sort((a, b) => a[0].localeCompare(b[0]))
|
|
202
|
+
|
|
203
|
+
// check $set updates
|
|
204
|
+
const actualSetUpdates = JSON.parse(calls[0][1].body)
|
|
205
|
+
actualSetUpdates.sort((a, b) => a.$distinct_id.localeCompare(b.$distinct_id))
|
|
206
|
+
const expectedSets = [
|
|
207
|
+
{ p1: false, p2: 5, p3: 'hi' },
|
|
208
|
+
{ p1: 'cool', p2: 6 }
|
|
209
|
+
]
|
|
210
|
+
for (let i = 0; i < actualSetUpdates.length; i++) {
|
|
211
|
+
const actualArgs = actualSetUpdates[i]
|
|
212
|
+
expect(actualArgs).toEqual({
|
|
213
|
+
$token: mixpanelToken,
|
|
214
|
+
$distinct_id: uids[i],
|
|
215
|
+
$set: expectedSets[i]
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// check $set_once updates
|
|
220
|
+
const actualSetOnceUpdates = JSON.parse(calls[1][1].body)
|
|
221
|
+
const expectedSetOnces = [
|
|
222
|
+
{ p1: 'once' }
|
|
223
|
+
]
|
|
224
|
+
for (let i = 0; i < actualSetOnceUpdates.length; i++) {
|
|
225
|
+
const actualArgs = actualSetOnceUpdates[i]
|
|
226
|
+
expect(actualArgs).toEqual({
|
|
227
|
+
$token: mixpanelToken,
|
|
228
|
+
$distinct_id: uids[1],
|
|
229
|
+
$set_once: expectedSetOnces[i]
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
runTests(AnalyticsTest)
|