posthog-node 4.10.2 → 4.11.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.
package/src/lazy.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A lazy value that is only computed when needed. Inspired by C#'s Lazy<T> class.
3
+ */
4
+ export class Lazy<T> {
5
+ private value: T | undefined
6
+ private factory: () => Promise<T>
7
+ private initializationPromise: Promise<T> | undefined
8
+
9
+ constructor(factory: () => Promise<T>) {
10
+ this.factory = factory
11
+ }
12
+
13
+ /**
14
+ * Gets the value, initializing it if necessary.
15
+ * Multiple concurrent calls will share the same initialization promise.
16
+ */
17
+ async getValue(): Promise<T> {
18
+ if (this.value !== undefined) {
19
+ return this.value
20
+ }
21
+
22
+ if (this.initializationPromise === undefined) {
23
+ this.initializationPromise = (async () => {
24
+ try {
25
+ const result = await this.factory()
26
+ this.value = result
27
+ return result
28
+ } finally {
29
+ // Clear the promise so we can retry if needed
30
+ this.initializationPromise = undefined
31
+ }
32
+ })()
33
+ }
34
+
35
+ return this.initializationPromise
36
+ }
37
+
38
+ /**
39
+ * Returns true if the value has been initialized.
40
+ */
41
+ isInitialized(): boolean {
42
+ return this.value !== undefined
43
+ }
44
+
45
+ /**
46
+ * Returns a promise that resolves when the value is initialized.
47
+ * If already initialized, resolves immediately.
48
+ */
49
+ async waitForInitialization(): Promise<void> {
50
+ if (this.isInitialized()) {
51
+ return
52
+ }
53
+ await this.getValue()
54
+ }
55
+ }
@@ -12,9 +12,11 @@ import {
12
12
  } from '../../posthog-core/src'
13
13
  import { PostHogMemoryStorage } from '../../posthog-core/src/storage-memory'
14
14
  import { EventMessage, GroupIdentifyMessage, IdentifyMessage, PostHogNodeV1 } from './types'
15
+ import { FeatureFlagDetail, FeatureFlagValue } from '../../posthog-core/src/types'
15
16
  import { FeatureFlagsPoller } from './feature-flags'
16
17
  import fetch from './fetch'
17
18
  import ErrorTracking from './error-tracking'
19
+ import { getFeatureFlagValue } from 'posthog-core/src/featureFlagUtils'
18
20
 
19
21
  export type PostHogOptions = PostHogCoreOptions & {
20
22
  persistence?: 'memory'
@@ -173,7 +175,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
173
175
  additionalProperties[`$feature/${feature}`] = variant
174
176
  }
175
177
  }
176
- const activeFlags = Object.keys(flags || {}).filter((flag) => flags?.[flag] !== false)
178
+ const activeFlags = Object.keys(flags || {})
179
+ .filter((flag) => flags?.[flag] !== false)
180
+ .sort()
177
181
  if (activeFlags.length > 0) {
178
182
  additionalProperties['$active_feature_flags'] = activeFlags
179
183
  }
@@ -227,7 +231,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
227
231
  sendFeatureFlagEvents?: boolean
228
232
  disableGeoip?: boolean
229
233
  }
230
- ): Promise<string | boolean | undefined> {
234
+ ): Promise<FeatureFlagValue | undefined> {
231
235
  const { groups, disableGeoip } = options || {}
232
236
  let { onlyEvaluateLocally, sendFeatureFlagEvents, personProperties, groupProperties } = options || {}
233
237
 
@@ -259,8 +263,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
259
263
 
260
264
  const flagWasLocallyEvaluated = response !== undefined
261
265
  let requestId = undefined
266
+ let flagDetail: FeatureFlagDetail | undefined = undefined
262
267
  if (!flagWasLocallyEvaluated && !onlyEvaluateLocally) {
263
- const remoteResponse = await super.getFeatureFlagStateless(
268
+ const remoteResponse = await super.getFeatureFlagDetailStateless(
264
269
  key,
265
270
  distinctId,
266
271
  groups,
@@ -268,8 +273,14 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
268
273
  groupProperties,
269
274
  disableGeoip
270
275
  )
271
- response = remoteResponse.response
272
- requestId = remoteResponse.requestId
276
+
277
+ if (remoteResponse === undefined) {
278
+ return undefined
279
+ }
280
+
281
+ flagDetail = remoteResponse.response
282
+ response = getFeatureFlagValue(flagDetail) ?? false
283
+ requestId = remoteResponse?.requestId
273
284
  }
274
285
 
275
286
  const featureFlagReportedKey = `${key}_${response}`
@@ -293,6 +304,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
293
304
  properties: {
294
305
  $feature_flag: key,
295
306
  $feature_flag_response: response,
307
+ $feature_flag_id: flagDetail?.metadata?.id,
308
+ $feature_flag_version: flagDetail?.metadata?.version,
309
+ $feature_flag_reason: flagDetail?.reason?.description ?? flagDetail?.reason?.code,
296
310
  locally_evaluated: flagWasLocallyEvaluated,
297
311
  [`$feature/${key}`]: response,
298
312
  $feature_flag_request_id: requestId,
@@ -307,7 +321,7 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
307
321
  async getFeatureFlagPayload(
308
322
  key: string,
309
323
  distinctId: string,
310
- matchValue?: string | boolean,
324
+ matchValue?: FeatureFlagValue,
311
325
  options?: {
312
326
  groups?: Record<string, string>
313
327
  personProperties?: Record<string, string>
@@ -332,17 +346,22 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
332
346
 
333
347
  let response = undefined
334
348
 
335
- // Try to get match value locally if not provided
336
- if (!matchValue) {
337
- matchValue = await this.getFeatureFlag(key, distinctId, {
338
- ...options,
339
- onlyEvaluateLocally: true,
340
- })
341
- }
349
+ const localEvaluationEnabled = this.featureFlagsPoller !== undefined
350
+ if (localEvaluationEnabled) {
351
+ // Try to get match value locally if not provided
352
+ if (!matchValue) {
353
+ matchValue = await this.getFeatureFlag(key, distinctId, {
354
+ ...options,
355
+ onlyEvaluateLocally: true,
356
+ sendFeatureFlagEvents: false,
357
+ })
358
+ }
342
359
 
343
- if (matchValue) {
344
- response = await this.featureFlagsPoller?.computeFeatureFlagPayloadLocally(key, matchValue)
360
+ if (matchValue) {
361
+ response = await this.featureFlagsPoller?.computeFeatureFlagPayloadLocally(key, matchValue)
362
+ }
345
363
  }
364
+ //}
346
365
 
347
366
  // set defaults
348
367
  if (onlyEvaluateLocally == undefined) {
@@ -404,9 +423,9 @@ export class PostHog extends PostHogCoreStateless implements PostHogNodeV1 {
404
423
  onlyEvaluateLocally?: boolean
405
424
  disableGeoip?: boolean
406
425
  }
407
- ): Promise<Record<string, string | boolean>> {
426
+ ): Promise<Record<string, FeatureFlagValue>> {
408
427
  const response = await this.getAllFlagsAndPayloads(distinctId, options)
409
- return response.featureFlags
428
+ return response.featureFlags || {}
410
429
  }
411
430
 
412
431
  async getAllFlagsAndPayloads(
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { JsonType } from '../../posthog-core/src'
1
+ import { FeatureFlagValue, JsonType } from '../../posthog-core/src'
2
2
 
3
3
  export interface IdentifyMessage {
4
4
  distinctId: string
@@ -155,7 +155,7 @@ export type PostHogNodeV1 = {
155
155
  onlyEvaluateLocally?: boolean
156
156
  sendFeatureFlagEvents?: boolean
157
157
  }
158
- ): Promise<string | boolean | undefined>
158
+ ): Promise<FeatureFlagValue | undefined>
159
159
 
160
160
  /**
161
161
  * @description Retrieves payload associated with the specified flag and matched value that is passed in.
@@ -186,7 +186,7 @@ export type PostHogNodeV1 = {
186
186
  getFeatureFlagPayload(
187
187
  key: string,
188
188
  distinctId: string,
189
- matchValue?: string | boolean,
189
+ matchValue?: FeatureFlagValue,
190
190
  options?: {
191
191
  onlyEvaluateLocally?: boolean
192
192
  }
@@ -0,0 +1,36 @@
1
+ import * as crypto from '../src/crypto'
2
+ import * as cryptoHelpers from '../src/crypto-helpers'
3
+
4
+ describe('crypto', () => {
5
+ describe('hashSHA1', () => {
6
+ const testString = 'some-flag.some_distinct_id'
7
+ const expectedHash = 'e4ce124e800a818c63099f95fa085dc2b620e173'
8
+
9
+ afterEach(() => {
10
+ jest.restoreAllMocks() // <- Reset all mocks after each test
11
+ })
12
+
13
+ it('should hash correctly using Node.js crypto', async () => {
14
+ jest.spyOn(cryptoHelpers, 'getWebCrypto').mockResolvedValue(undefined)
15
+
16
+ const hash = await crypto.hashSHA1(testString)
17
+ expect(hash).toBe(expectedHash)
18
+ })
19
+
20
+ it('should hash correctly using Web Crypto API', async () => {
21
+ jest.spyOn(cryptoHelpers, 'getNodeCrypto').mockResolvedValue(undefined)
22
+
23
+ const hash = await crypto.hashSHA1(testString)
24
+ expect(hash).toBe(expectedHash)
25
+ })
26
+
27
+ it('should throw if no crypto implementation is available', async () => {
28
+ jest.spyOn(cryptoHelpers, 'getNodeCrypto').mockResolvedValue(undefined)
29
+ jest.spyOn(cryptoHelpers, 'getWebCrypto').mockResolvedValue(undefined)
30
+
31
+ await expect(crypto.hashSHA1(testString)).rejects.toThrow(
32
+ 'No crypto implementation available. Tried Node Crypto API and Web SubtleCrypto API'
33
+ )
34
+ })
35
+ })
36
+ })
@@ -0,0 +1,293 @@
1
+ import { PostHog as PostHog, PostHogOptions } from '../src/posthog-node'
2
+ import fetch from '../src/fetch'
3
+ import { apiImplementation, apiImplementationV4 } from './test-utils'
4
+ import { waitForPromises } from 'posthog-core/test/test-utils/test-utils'
5
+ import { PostHogV4DecideResponse } from 'posthog-core/src/types'
6
+ jest.mock('../src/fetch')
7
+
8
+ jest.spyOn(console, 'debug').mockImplementation()
9
+
10
+ const mockedFetch = jest.mocked(fetch, true)
11
+
12
+ const posthogImmediateResolveOptions: PostHogOptions = {
13
+ fetchRetryCount: 0,
14
+ }
15
+
16
+ describe('decide v4', () => {
17
+ describe('getFeatureFlag v4', () => {
18
+ it('returns false if the flag is not found', async () => {
19
+ const decideResponse: PostHogV4DecideResponse = {
20
+ flags: {},
21
+ errorsWhileComputingFlags: false,
22
+ requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
23
+ }
24
+ mockedFetch.mockImplementation(apiImplementationV4(decideResponse))
25
+
26
+ const posthog = new PostHog('TEST_API_KEY', {
27
+ host: 'http://example.com',
28
+ ...posthogImmediateResolveOptions,
29
+ })
30
+ let capturedMessage: any
31
+ posthog.on('capture', (message) => {
32
+ capturedMessage = message
33
+ })
34
+
35
+ const result = await posthog.getFeatureFlag('non-existent-flag', 'some-distinct-id')
36
+
37
+ expect(result).toBe(false)
38
+ expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
39
+
40
+ await waitForPromises()
41
+ expect(capturedMessage).toMatchObject({
42
+ distinct_id: 'some-distinct-id',
43
+ event: '$feature_flag_called',
44
+ library: posthog.getLibraryId(),
45
+ library_version: posthog.getLibraryVersion(),
46
+ properties: {
47
+ '$feature/non-existent-flag': false,
48
+ $feature_flag: 'non-existent-flag',
49
+ $feature_flag_response: false,
50
+ $feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
51
+ $groups: undefined,
52
+ $lib: posthog.getLibraryId(),
53
+ $lib_version: posthog.getLibraryVersion(),
54
+ locally_evaluated: false,
55
+ },
56
+ })
57
+ })
58
+
59
+ it.each([
60
+ {
61
+ key: 'variant-flag',
62
+ expectedResponse: 'variant-value',
63
+ expectedReason: 'Matched condition set 3',
64
+ expectedId: 2,
65
+ expectedVersion: 23,
66
+ },
67
+ {
68
+ key: 'boolean-flag',
69
+ expectedResponse: true,
70
+ expectedReason: 'Matched condition set 1',
71
+ expectedId: 1,
72
+ expectedVersion: 12,
73
+ },
74
+ {
75
+ key: 'non-matching-flag',
76
+ expectedResponse: false,
77
+ expectedReason: 'Did not match any condition',
78
+ expectedId: 3,
79
+ expectedVersion: 2,
80
+ },
81
+ ])(
82
+ 'captures a feature flag called event with extra metadata when the flag is found',
83
+ async ({ key, expectedResponse, expectedReason, expectedId, expectedVersion }) => {
84
+ const decideResponse: PostHogV4DecideResponse = {
85
+ flags: {
86
+ 'variant-flag': {
87
+ key: 'variant-flag',
88
+ enabled: true,
89
+ variant: 'variant-value',
90
+ reason: {
91
+ code: 'variant',
92
+ condition_index: 2,
93
+ description: 'Matched condition set 3',
94
+ },
95
+ metadata: {
96
+ id: 2,
97
+ version: 23,
98
+ payload: '{"key": "value"}',
99
+ description: 'description',
100
+ },
101
+ },
102
+ 'boolean-flag': {
103
+ key: 'boolean-flag',
104
+ enabled: true,
105
+ variant: undefined,
106
+ reason: {
107
+ code: 'boolean',
108
+ condition_index: 1,
109
+ description: 'Matched condition set 1',
110
+ },
111
+ metadata: {
112
+ id: 1,
113
+ version: 12,
114
+ payload: undefined,
115
+ description: 'description',
116
+ },
117
+ },
118
+ 'non-matching-flag': {
119
+ key: 'non-matching-flag',
120
+ enabled: false,
121
+ variant: undefined,
122
+ reason: {
123
+ code: 'boolean',
124
+ condition_index: 1,
125
+ description: 'Did not match any condition',
126
+ },
127
+ metadata: {
128
+ id: 3,
129
+ version: 2,
130
+ payload: undefined,
131
+ description: 'description',
132
+ },
133
+ },
134
+ },
135
+ errorsWhileComputingFlags: false,
136
+ requestId: '0152a345-295f-4fba-adac-2e6ea9c91082',
137
+ }
138
+ mockedFetch.mockImplementation(apiImplementationV4(decideResponse))
139
+
140
+ const posthog = new PostHog('TEST_API_KEY', {
141
+ host: 'http://example.com',
142
+ ...posthogImmediateResolveOptions,
143
+ })
144
+ let capturedMessage: any
145
+ posthog.on('capture', (message) => {
146
+ capturedMessage = message
147
+ })
148
+
149
+ const result = await posthog.getFeatureFlag(key, 'some-distinct-id')
150
+
151
+ expect(result).toBe(expectedResponse)
152
+ expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
153
+
154
+ await waitForPromises()
155
+ expect(capturedMessage).toMatchObject({
156
+ distinct_id: 'some-distinct-id',
157
+ event: '$feature_flag_called',
158
+ library: posthog.getLibraryId(),
159
+ library_version: posthog.getLibraryVersion(),
160
+ properties: {
161
+ [`$feature/${key}`]: expectedResponse,
162
+ $feature_flag: key,
163
+ $feature_flag_response: expectedResponse,
164
+ $feature_flag_id: expectedId,
165
+ $feature_flag_version: expectedVersion,
166
+ $feature_flag_reason: expectedReason,
167
+ $feature_flag_request_id: '0152a345-295f-4fba-adac-2e6ea9c91082',
168
+ $groups: undefined,
169
+ $lib: posthog.getLibraryId(),
170
+ $lib_version: posthog.getLibraryVersion(),
171
+ locally_evaluated: false,
172
+ },
173
+ })
174
+ }
175
+ )
176
+
177
+ describe('getFeatureFlagPayload v4', () => {
178
+ it('returns payload', async () => {
179
+ mockedFetch.mockImplementation(
180
+ apiImplementationV4({
181
+ flags: {
182
+ 'flag-with-payload': {
183
+ key: 'flag-with-payload',
184
+ enabled: true,
185
+ variant: undefined,
186
+ reason: {
187
+ code: 'boolean',
188
+ condition_index: 1,
189
+ description: 'Matched condition set 2',
190
+ },
191
+ metadata: {
192
+ id: 1,
193
+ version: 12,
194
+ payload: '[0, 1, 2]',
195
+ description: 'description',
196
+ },
197
+ },
198
+ },
199
+ errorsWhileComputingFlags: false,
200
+ })
201
+ )
202
+
203
+ const posthog = new PostHog('TEST_API_KEY', {
204
+ host: 'http://example.com',
205
+ ...posthogImmediateResolveOptions,
206
+ })
207
+ let capturedMessage: any
208
+ posthog.on('capture', (message) => {
209
+ capturedMessage = message
210
+ })
211
+
212
+ const result = await posthog.getFeatureFlagPayload('flag-with-payload', 'some-distinct-id')
213
+
214
+ expect(result).toEqual([0, 1, 2])
215
+ expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
216
+
217
+ await waitForPromises()
218
+ expect(capturedMessage).toBeUndefined()
219
+ })
220
+ })
221
+ })
222
+ })
223
+
224
+ describe('decide v3', () => {
225
+ describe('getFeatureFlag v3', () => {
226
+ it('returns false if the flag is not found', async () => {
227
+ mockedFetch.mockImplementation(apiImplementation({ decideFlags: {} }))
228
+
229
+ const posthog = new PostHog('TEST_API_KEY', {
230
+ host: 'http://example.com',
231
+ ...posthogImmediateResolveOptions,
232
+ })
233
+ let capturedMessage: any
234
+ posthog.on('capture', (message) => {
235
+ capturedMessage = message
236
+ })
237
+
238
+ const result = await posthog.getFeatureFlag('non-existent-flag', 'some-distinct-id')
239
+
240
+ expect(result).toBe(false)
241
+ expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
242
+
243
+ await waitForPromises()
244
+ expect(capturedMessage).toMatchObject({
245
+ distinct_id: 'some-distinct-id',
246
+ event: '$feature_flag_called',
247
+ library: posthog.getLibraryId(),
248
+ library_version: posthog.getLibraryVersion(),
249
+ properties: {
250
+ '$feature/non-existent-flag': false,
251
+ $feature_flag: 'non-existent-flag',
252
+ $feature_flag_response: false,
253
+ $groups: undefined,
254
+ $lib: posthog.getLibraryId(),
255
+ $lib_version: posthog.getLibraryVersion(),
256
+ locally_evaluated: false,
257
+ },
258
+ })
259
+ })
260
+
261
+ describe('getFeatureFlagPayload v3', () => {
262
+ it('returns payload', async () => {
263
+ mockedFetch.mockImplementation(
264
+ apiImplementation({
265
+ decideFlags: {
266
+ 'flag-with-payload': true,
267
+ },
268
+ decideFlagPayloads: {
269
+ 'flag-with-payload': [0, 1, 2],
270
+ },
271
+ })
272
+ )
273
+
274
+ const posthog = new PostHog('TEST_API_KEY', {
275
+ host: 'http://example.com',
276
+ ...posthogImmediateResolveOptions,
277
+ })
278
+ let capturedMessage: any = undefined
279
+ posthog.on('capture', (message) => {
280
+ capturedMessage = message
281
+ })
282
+
283
+ const result = await posthog.getFeatureFlagPayload('flag-with-payload', 'some-distinct-id')
284
+
285
+ expect(result).toEqual([0, 1, 2])
286
+ expect(mockedFetch).toHaveBeenCalledWith('http://example.com/decide/?v=4', expect.any(Object))
287
+
288
+ await waitForPromises()
289
+ expect(capturedMessage).toBeUndefined()
290
+ })
291
+ })
292
+ })
293
+ })
@@ -347,7 +347,7 @@ describe('local evaluation', () => {
347
347
  })
348
348
  ).toEqual('decide-fallback-value')
349
349
  expect(mockedFetch).toHaveBeenCalledWith(
350
- 'http://example.com/decide/?v=3',
350
+ 'http://example.com/decide/?v=4',
351
351
  expect.objectContaining({
352
352
  body: JSON.stringify({
353
353
  token: 'TEST_API_KEY',
@@ -371,7 +371,7 @@ describe('local evaluation', () => {
371
371
  await posthog.getFeatureFlag('complex-flag', 'some-distinct-id', { personProperties: { doesnt_matter: '1' } })
372
372
  ).toEqual('decide-fallback-value')
373
373
  expect(mockedFetch).toHaveBeenCalledWith(
374
- 'http://example.com/decide/?v=3',
374
+ 'http://example.com/decide/?v=4',
375
375
  expect.objectContaining({
376
376
  body: JSON.stringify({
377
377
  token: 'TEST_API_KEY',
@@ -0,0 +1,71 @@
1
+ import { Lazy } from '../src/lazy'
2
+
3
+ describe('Lazy', () => {
4
+ it('should only call the factory once', async (): Promise<void> => {
5
+ let callCount = 0
6
+ const factory = async (): Promise<string> => {
7
+ callCount++
8
+ return 'value'
9
+ }
10
+
11
+ const lazy = new Lazy(factory)
12
+ expect(callCount).toBe(0)
13
+
14
+ const value1 = await lazy.getValue()
15
+ expect(value1).toBe('value')
16
+ expect(callCount).toBe(1)
17
+
18
+ const value2 = await lazy.getValue()
19
+ expect(value2).toBe('value')
20
+ expect(callCount).toBe(1)
21
+ })
22
+
23
+ it('should handle errors in the factory', async (): Promise<void> => {
24
+ const factory = async (): Promise<string> => {
25
+ throw new Error('Factory error')
26
+ }
27
+
28
+ const lazy = new Lazy(factory)
29
+ await expect(lazy.getValue()).rejects.toThrow('Factory error')
30
+ })
31
+
32
+ it('should handle undefined values', async (): Promise<void> => {
33
+ const factory = async (): Promise<undefined> => {
34
+ return undefined
35
+ }
36
+
37
+ const lazy = new Lazy(factory)
38
+ const value = await lazy.getValue()
39
+ expect(value).toBeUndefined()
40
+ })
41
+
42
+ it('should handle complex types', async (): Promise<void> => {
43
+ interface ComplexType {
44
+ id: number
45
+ name: string
46
+ }
47
+
48
+ const factory = async (): Promise<ComplexType> => {
49
+ return { id: 1, name: 'test' }
50
+ }
51
+
52
+ const lazy = new Lazy<ComplexType>(factory)
53
+ const value = await lazy.getValue()
54
+ expect(value).toEqual({ id: 1, name: 'test' })
55
+ })
56
+
57
+ it('should handle concurrent calls', async (): Promise<void> => {
58
+ let callCount = 0
59
+ const factory = async (): Promise<string> => {
60
+ callCount++
61
+ return 'value'
62
+ }
63
+
64
+ const lazy = new Lazy(factory)
65
+ const [value1, value2] = await Promise.all([lazy.getValue(), lazy.getValue()])
66
+
67
+ expect(value1).toBe('value')
68
+ expect(value2).toBe('value')
69
+ expect(callCount).toBe(1)
70
+ })
71
+ })