@reservamos/browser-analytics 1.1.8-alpha.2 → 1.1.8-alpha.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reservamos/browser-analytics",
3
- "version": "1.1.8-alpha.2",
3
+ "version": "1.1.8-alpha.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/reservamos/reservamos-browser-analytics.git"
@@ -0,0 +1,367 @@
1
+ import type { InitConfig } from '@/init';
2
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ profile: vi.fn(),
6
+ people: vi.fn(),
7
+ restore: vi.fn(),
8
+ identify: vi.fn(),
9
+ user: 'user-a',
10
+ apiBaseUrl: 'https://core.test',
11
+ appName: '',
12
+ fingerprint: null as string | null,
13
+ }));
14
+ vi.mock('@reservamos/js-api-client/core', () => ({
15
+ default: {
16
+ setConfig: vi.fn(),
17
+ profiles: { createAnonymousProfile: mocks.profile },
18
+ },
19
+ }));
20
+ vi.mock('@/services/mixpanel', async () => {
21
+ const lifecycle = (await import('@/services/trackingLifecycle')).default;
22
+ return {
23
+ default: {
24
+ init: vi.fn(async () => lifecycle.beginInitialization()),
25
+ validateConfiguration: vi.fn(),
26
+ isReady: () => true,
27
+ getMixpanelDistinctId: () => mocks.user,
28
+ identify: mocks.identify.mockImplementation((user: string) => {
29
+ mocks.user = user;
30
+ lifecycle.identify(user);
31
+ }),
32
+ setPeopleProperties: mocks.people,
33
+ restorePeopleProperties: mocks.restore,
34
+ attachProperty: vi.fn(),
35
+ track: vi.fn(),
36
+ },
37
+ };
38
+ });
39
+ vi.mock('@/services/fingerprint', () => ({
40
+ getFingerprintForIdentify: vi.fn().mockResolvedValue(''),
41
+ default: {
42
+ initFingerprint: vi.fn().mockResolvedValue(undefined),
43
+ getCachedFingerprint: () => mocks.fingerprint,
44
+ },
45
+ }));
46
+ vi.mock('@/services/geolocation', () => ({
47
+ default: {
48
+ init: vi.fn().mockResolvedValue(undefined),
49
+ getCoordinates: () => null,
50
+ getGeolocationSource: () => null,
51
+ },
52
+ }));
53
+ vi.mock('@/services/config', () => ({
54
+ default: {
55
+ setEnvironment: vi.fn(),
56
+ setAppName: vi.fn(),
57
+ getCoreAPIConfig: () => ({
58
+ baseUrl: mocks.apiBaseUrl,
59
+ defaultHeaders: { 'x-app-name': mocks.appName },
60
+ }),
61
+ },
62
+ }));
63
+
64
+ const config = { mixpanelToken: 'recovery-project', identificationKey: 'test' };
65
+ let init: typeof import('@/init').default;
66
+ let identify: typeof import('./identify').default;
67
+
68
+ async function loadDestination(destinationConfig: InitConfig = config) {
69
+ const now = Date.now();
70
+ vi.clearAllTimers();
71
+ vi.setSystemTime(now);
72
+ vi.resetModules();
73
+ init = (await import('@/init')).default;
74
+ identify = (await import('./identify')).default;
75
+ await init(destinationConfig);
76
+ await vi.advanceTimersByTimeAsync(0);
77
+ }
78
+
79
+ beforeEach(async () => {
80
+ vi.useFakeTimers();
81
+ vi.resetModules();
82
+ sessionStorage.clear();
83
+ localStorage.clear();
84
+ mocks.user = 'user-a';
85
+ mocks.apiBaseUrl = 'https://core.test';
86
+ mocks.appName = '';
87
+ mocks.fingerprint = null;
88
+ mocks.profile.mockReset();
89
+ mocks.people
90
+ .mockReset()
91
+ .mockImplementation((_props, _report, accepted?: () => void) =>
92
+ accepted?.(),
93
+ );
94
+ mocks.restore
95
+ .mockReset()
96
+ .mockImplementation((_user, _props, _report, accepted?: () => void) =>
97
+ accepted?.(),
98
+ );
99
+ mocks.identify.mockReset();
100
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
101
+ init = (await import('@/init')).default;
102
+ identify = (await import('./identify')).default;
103
+ await init(config);
104
+ });
105
+ afterEach(() => {
106
+ vi.clearAllTimers();
107
+ vi.useRealTimers();
108
+ vi.restoreAllMocks();
109
+ });
110
+
111
+ it('recovers a lost profile response on destination init without consumer identify', async () => {
112
+ mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
113
+ await identify('user-a', {
114
+ email: 'a@example.com',
115
+ firstName: 'Original name',
116
+ customField: 'Not persisted',
117
+ });
118
+ expect(sessionStorage.length).toBe(1);
119
+ const stored = sessionStorage.getItem(sessionStorage.key(0) || '') || '';
120
+ expect(stored).toContain('a@example.com');
121
+ expect(stored).not.toContain('Not persisted');
122
+
123
+ mocks.profile.mockResolvedValueOnce({ id: 'profile-a' });
124
+ await loadDestination();
125
+
126
+ expect(mocks.profile).toHaveBeenCalledTimes(2);
127
+ expect(mocks.restore).toHaveBeenCalledWith(
128
+ 'user-a',
129
+ { reservamos_one_id: 'profile-a' },
130
+ expect.any(Function),
131
+ expect.any(Function),
132
+ );
133
+ expect(mocks.identify).toHaveBeenCalledTimes(1);
134
+ expect(sessionStorage.length).toBe(0);
135
+ });
136
+
137
+ it('keeps a resolved numeric profile ID until people acceptance and drops the contact identifier', async () => {
138
+ mocks.people.mockImplementation(() => undefined);
139
+ mocks.profile.mockResolvedValue({ id: 42 });
140
+ await identify('user-a', { email: 'a@example.com' });
141
+ await vi.advanceTimersByTimeAsync(0);
142
+ const stored = sessionStorage.getItem(sessionStorage.key(0) || '') || '';
143
+ expect(JSON.parse(stored)).toEqual(
144
+ expect.objectContaining({ profileId: 42 }),
145
+ );
146
+ expect(stored).not.toContain('a@example.com');
147
+ await loadDestination();
148
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
149
+ expect(mocks.restore).toHaveBeenCalledWith(
150
+ 'user-a',
151
+ { reservamos_one_id: 42 },
152
+ expect.any(Function),
153
+ expect.any(Function),
154
+ );
155
+ expect(sessionStorage.length).toBe(0);
156
+ });
157
+
158
+ it('does not resume an intent for a different persisted user', async () => {
159
+ mocks.profile.mockReturnValue(new Promise(() => undefined));
160
+ await identify('user-a', { email: 'a@example.com' });
161
+ mocks.user = 'user-b';
162
+ await loadDestination();
163
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
164
+ expect(mocks.restore).not.toHaveBeenCalled();
165
+ expect(sessionStorage.length).toBe(0);
166
+ });
167
+
168
+ it.each(['project', 'proxy', 'core', 'application'])(
169
+ 'does not resume an intent with a different %s',
170
+ async (change) => {
171
+ mocks.profile.mockReturnValue(new Promise(() => undefined));
172
+ await identify('user-a', { email: 'a@example.com' });
173
+ const destination: InitConfig = { ...config };
174
+ if (change === 'project') destination.mixpanelToken = 'other-project';
175
+ if (change === 'proxy')
176
+ destination.mixpanelProxyUrl = 'https://other-proxy.test';
177
+ if (change === 'core') mocks.apiBaseUrl = 'https://other-core.test';
178
+ if (change === 'application') mocks.appName = 'other-app';
179
+ await loadDestination(destination);
180
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
181
+ expect(mocks.restore).not.toHaveBeenCalled();
182
+ expect(sessionStorage.length).toBe(0);
183
+ },
184
+ );
185
+
186
+ it('recovers a phone-only profile and preserves its original user identifier', async () => {
187
+ mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
188
+ await identify('user-a', { phone: '+525500000000' });
189
+ mocks.profile.mockResolvedValueOnce({ id: 'phone-profile' });
190
+ await loadDestination();
191
+ expect(mocks.profile.mock.calls[1][0]).toEqual(
192
+ expect.objectContaining({
193
+ identifier_key: 'phone',
194
+ identifier_value: '+525500000000',
195
+ identifiers: [{ key: 'distinct_id', value: 'user-a' }],
196
+ }),
197
+ );
198
+ expect(mocks.restore).toHaveBeenCalledWith(
199
+ 'user-a',
200
+ { reservamos_one_id: 'phone-profile' },
201
+ expect.any(Function),
202
+ expect.any(Function),
203
+ );
204
+ });
205
+
206
+ it('bounds a stalled request without retrying until another initialization', async () => {
207
+ mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
208
+ await identify('user-a', { email: 'a@example.com' });
209
+ await vi.advanceTimersByTimeAsync(3100);
210
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
211
+ expect(sessionStorage.length).toBe(1);
212
+ mocks.profile.mockResolvedValueOnce({ id: 'recovered-profile' });
213
+ await loadDestination();
214
+ expect(mocks.profile).toHaveBeenCalledTimes(2);
215
+ expect(sessionStorage.length).toBe(0);
216
+ });
217
+
218
+ it.each(['user-a', 'user-b'])(
219
+ 'does not let a superseded response overwrite or clear a replacement intent for %s',
220
+ async (nextUser) => {
221
+ let finish!: (profile: { id: string }) => void;
222
+ mocks.profile.mockReturnValueOnce(
223
+ new Promise((resolve) => {
224
+ finish = resolve;
225
+ }),
226
+ );
227
+ await identify('user-a', { email: 'old@example.com' });
228
+ mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
229
+ await identify(nextUser, { email: 'new@example.com' });
230
+ finish({ id: 'old-profile' });
231
+ await vi.advanceTimersByTimeAsync(0);
232
+ expect(
233
+ mocks.people.mock.calls.some(
234
+ ([properties]) => properties.reservamos_one_id === 'old-profile',
235
+ ),
236
+ ).toBe(false);
237
+ const entry = JSON.parse(
238
+ sessionStorage.getItem(sessionStorage.key(0) || '') || '{}',
239
+ );
240
+ expect(entry.userId).toBe(nextUser);
241
+ expect(entry.request.identifier_value).toBe('new@example.com');
242
+ },
243
+ );
244
+
245
+ it('rejects expired recovery work on initialization', async () => {
246
+ mocks.profile.mockReturnValue(new Promise(() => undefined));
247
+ await identify('user-a', { email: 'a@example.com' });
248
+ vi.setSystemTime(Date.now() + 10 * 60 * 1000 + 1);
249
+ await loadDestination();
250
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
251
+ expect(sessionStorage.length).toBe(0);
252
+ });
253
+
254
+ it.each(['{invalid', JSON.stringify({ version: 99 }), 'x'.repeat(4097)])(
255
+ 'discards invalid persisted work without requesting a profile',
256
+ async (entry) => {
257
+ sessionStorage.setItem('reservamos_pending_profile_v1', entry);
258
+ await loadDestination();
259
+ expect(mocks.profile).not.toHaveBeenCalled();
260
+ expect(sessionStorage.length).toBe(0);
261
+ expect(mocks.restore).not.toHaveBeenCalled();
262
+ },
263
+ );
264
+
265
+ it('limits failed profile HTTP attempts across page initialization', async () => {
266
+ mocks.profile.mockRejectedValue(new Error('private backend error'));
267
+ await identify('user-a', { email: 'a@example.com' });
268
+ await vi.advanceTimersByTimeAsync(0);
269
+ await loadDestination();
270
+ await loadDestination();
271
+ await loadDestination();
272
+ expect(mocks.profile).toHaveBeenCalledTimes(3);
273
+ expect(sessionStorage.length).toBe(0);
274
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
275
+ 'private backend error',
276
+ );
277
+ });
278
+
279
+ it('preserves normal enrichment when session storage is blocked', async () => {
280
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
281
+ this: Storage,
282
+ ) {
283
+ if (this === sessionStorage)
284
+ throw new DOMException('Blocked', 'SecurityError');
285
+ });
286
+ mocks.profile.mockResolvedValue({ id: 'profile-a' });
287
+ await identify('user-a', { email: 'a@example.com' });
288
+ await vi.advanceTimersByTimeAsync(0);
289
+ expect(mocks.identify).toHaveBeenCalledTimes(1);
290
+ expect(mocks.people).toHaveBeenCalledWith(
291
+ { reservamos_one_id: 'profile-a' },
292
+ expect.any(Function),
293
+ expect.any(Function),
294
+ );
295
+ expect(sessionStorage.length).toBe(0);
296
+ });
297
+
298
+ it('replays the complete original Core request with unchanged details and identifiers', async () => {
299
+ mocks.fingerprint = 'original-fingerprint';
300
+ mocks.profile.mockReturnValueOnce(new Promise(() => undefined));
301
+ await identify('user-a', {
302
+ email: 'new@example.com',
303
+ phone: '+525500000001',
304
+ cpf: '12345678900',
305
+ firstName: 'Original',
306
+ lastName: 'Name',
307
+ });
308
+ const original = mocks.profile.mock.calls[0][0];
309
+ expect(original.details).toEqual({ firstName: 'Original', lastName: 'Name' });
310
+ expect(original.identifiers).toContainEqual({
311
+ key: 'phone',
312
+ value: '+525500000001',
313
+ });
314
+ expect(original.identifiers).toContainEqual({
315
+ key: 'fingerprint',
316
+ value: 'original-fingerprint',
317
+ });
318
+ mocks.fingerprint = 'destination-fingerprint';
319
+ mocks.profile.mockImplementationOnce(async (request) => ({
320
+ id: request.identifiers.some(
321
+ (identifier: { key: string; value: string }) =>
322
+ identifier.key === 'phone' && identifier.value === '+525500000001',
323
+ )
324
+ ? 'strong-phone-profile'
325
+ : 'weak-user-profile',
326
+ }));
327
+ await loadDestination();
328
+ expect(mocks.profile.mock.calls[1][0]).toEqual(original);
329
+ expect(mocks.restore).toHaveBeenCalledWith(
330
+ 'user-a',
331
+ { reservamos_one_id: 'strong-phone-profile' },
332
+ expect.any(Function),
333
+ expect.any(Function),
334
+ );
335
+ });
336
+
337
+ it('removes superseded recovery intent when replacing its storage record fails', async () => {
338
+ mocks.profile.mockReturnValue(new Promise(() => undefined));
339
+ await identify('user-a', { email: 'old@example.com' });
340
+ const setItem = Storage.prototype.setItem;
341
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (
342
+ this: Storage,
343
+ key,
344
+ value,
345
+ ) {
346
+ if (this === sessionStorage) throw new Error('write unavailable');
347
+ return setItem.call(this, key, value);
348
+ });
349
+ await identify('user-a', { email: 'new@example.com' });
350
+ expect(sessionStorage.length).toBe(0);
351
+ await loadDestination();
352
+ expect(mocks.profile).toHaveBeenCalledTimes(2);
353
+ });
354
+
355
+ it('does not retry when the attempt count cannot be persisted', async () => {
356
+ mocks.profile.mockReturnValue(new Promise(() => undefined));
357
+ await identify('user-a', { email: 'a@example.com' });
358
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
359
+ throw new Error('blocked');
360
+ });
361
+ vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
362
+ throw new Error('blocked');
363
+ });
364
+ await loadDestination();
365
+ await loadDestination();
366
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
367
+ });
@@ -1,9 +1,9 @@
1
1
  import type { IdentifyReporter } from '@/util/identifyAttempt';
2
- import { createAnonymousProfileForIdentify } from '@/profiles/createAnonymousProfile/createAnonymousProfile';
3
2
  import configService from '@/services/config';
4
3
  import { getFingerprintForIdentify } from '@/services/fingerprint';
5
4
  import geolocationService from '@/services/geolocation';
6
5
  import mixpanelService from '@/services/mixpanel';
6
+ import profileEnrichment from '@/services/profileEnrichment';
7
7
  import trackingLifecycle from '@/services/trackingLifecycle';
8
8
  import validatorService from '@/services/validator';
9
9
  import {
@@ -120,13 +120,10 @@ async function enrichProfileAndLocation(
120
120
  isActive: () => boolean,
121
121
  report: IdentifyReporter,
122
122
  ): Promise<void> {
123
- const profile = await waitForIdentifyStage(
124
- 'profile',
125
- 3000,
126
- (context) =>
127
- isActive()
128
- ? createAnonymousProfileForIdentify({ ...properties }, context.report)
129
- : Promise.resolve(undefined),
123
+ const profile = await profileEnrichment.enrich(
124
+ userId,
125
+ properties,
126
+ isActive,
130
127
  report,
131
128
  );
132
129
  if (!isActive()) return;
@@ -134,16 +131,6 @@ async function enrichProfileAndLocation(
134
131
  ...properties,
135
132
  ...(profile && { reservamos_one_id: profile.id }),
136
133
  };
137
- if (profile) {
138
- try {
139
- mixpanelService.setPeopleProperties(
140
- { reservamos_one_id: profile.id },
141
- report,
142
- );
143
- } catch {
144
- report('profile', 'failed');
145
- }
146
- }
147
134
  void identifyWithLocation(userId, enriched, report);
148
135
  }
149
136
 
package/src/init.ts CHANGED
@@ -4,6 +4,7 @@ import configService from '@/services/config';
4
4
  import fingerprintService from '@/services/fingerprint';
5
5
  import geolocationService from '@/services/geolocation';
6
6
  import mixpanelService from '@/services/mixpanel';
7
+ import profileEnrichment from '@/services/profileEnrichment';
7
8
  import validator, { InitConfigSchema } from '@/services/validator';
8
9
 
9
10
  /**
@@ -51,12 +52,20 @@ async function init(config: InitConfig) {
51
52
  if (appName) {
52
53
  configService.setAppName(appName);
53
54
  }
54
- coreApi.setConfig(configService.getCoreAPIConfig());
55
+ const coreConfig = configService.getCoreAPIConfig();
56
+ coreApi.setConfig(coreConfig);
55
57
 
56
58
  // Dispatch the 'Tracker Ready' event
57
59
  onLoaded();
58
60
  if (!isCurrent()) return;
59
61
 
62
+ profileEnrichment.initialize({
63
+ project: mixpanelToken,
64
+ proxy: mixpanelProxyUrl,
65
+ apiBaseUrl: coreConfig.baseUrl,
66
+ appName: coreConfig.defaultHeaders?.['x-app-name'],
67
+ });
68
+
60
69
  await Promise.all([
61
70
  initializeOptionalStage('fingerprint', () =>
62
71
  isCurrent()
@@ -87,6 +87,16 @@ async function requestAnonymousProfile(
87
87
  payload: CreateAnonymousProfileProps,
88
88
  report?: IdentifyReporter,
89
89
  ): Promise<AnonymousProfile | undefined> {
90
+ return requestPreparedAnonymousProfile(
91
+ prepareAnonymousProfileForIdentify(payload, report),
92
+ );
93
+ }
94
+
95
+ /** Captures exactly the normalized Core request, including its original identifiers. */
96
+ export function prepareAnonymousProfileForIdentify(
97
+ payload: CreateAnonymousProfileProps,
98
+ report?: IdentifyReporter,
99
+ ): CreateAnonymousProfilePayload {
90
100
  validatorService.validateProps(payload, CreateAnonymousProfileSchema);
91
101
 
92
102
  const identifiers: AnonymousIdentifier[] = [];
@@ -108,8 +118,14 @@ async function requestAnonymousProfile(
108
118
  identifiers.push({ key: 'fingerprint', value: userFingerprintId });
109
119
  if (distinctId) identifiers.push({ key: 'distinct_id', value: distinctId });
110
120
 
111
- const dataPayload = getAnonymousProfilePayload(payload, identifiers);
112
- return await coreApi.profiles.createAnonymousProfile(dataPayload);
121
+ return getAnonymousProfilePayload(payload, identifiers);
122
+ }
123
+
124
+ /** Sends a captured request without rebuilding identifiers from destination state. */
125
+ export function requestPreparedAnonymousProfile(
126
+ payload: CreateAnonymousProfilePayload,
127
+ ): Promise<AnonymousProfile> {
128
+ return coreApi.profiles.createAnonymousProfile(payload);
113
129
  }
114
130
 
115
131
  export default createAnonymousProfile;
@@ -0,0 +1,108 @@
1
+ import mixpanel from 'mixpanel-browser';
2
+ import { afterAll, beforeAll, expect, it, vi } from 'vitest';
3
+ import mixpanelService from './mixpanel';
4
+
5
+ interface BatchPayload {
6
+ event?: string;
7
+ properties?: Record<string, unknown>;
8
+ $distinct_id?: string;
9
+ $set?: Record<string, unknown>;
10
+ }
11
+
12
+ // Keep the real identity handling, batching, storage, and timers; replace HTTP only.
13
+ const transport = mixpanel as unknown as {
14
+ _send_request(
15
+ url: string,
16
+ data: { data: string },
17
+ options: object,
18
+ callback?: (response: number) => void,
19
+ ): boolean;
20
+ stop_batch_senders(): void;
21
+ _flags: { identify_called: boolean };
22
+ };
23
+ const batches: { url: string; payload: BatchPayload[] }[] = [];
24
+
25
+ beforeAll(async () => {
26
+ vi.useFakeTimers();
27
+ localStorage.clear();
28
+ await mixpanelService.init('one-second-batching-token');
29
+ vi.spyOn(transport, '_send_request').mockImplementation(
30
+ (url, data, _options, callback) => {
31
+ if (url.endsWith('/track/') || url.endsWith('/engage/')) {
32
+ batches.push({
33
+ url,
34
+ payload: JSON.parse(data.data) as BatchPayload[],
35
+ });
36
+ }
37
+ callback?.(1);
38
+ return true;
39
+ },
40
+ );
41
+ });
42
+
43
+ afterAll(() => {
44
+ transport.stop_batch_senders();
45
+ vi.restoreAllMocks();
46
+ vi.clearAllTimers();
47
+ vi.useRealTimers();
48
+ localStorage.clear();
49
+ });
50
+
51
+ it('batches native identity and people updates within one second', async () => {
52
+ mixpanelService.identify('redirect-user', { $email: 'redirect@example.com' });
53
+
54
+ await vi.advanceTimersByTimeAsync(999);
55
+ expect(batches).toEqual([]);
56
+
57
+ await vi.advanceTimersByTimeAsync(1);
58
+ expect(batches).toHaveLength(2);
59
+ expect(batches.find(({ url }) => url.endsWith('/track/'))?.payload).toEqual([
60
+ expect.objectContaining({
61
+ event: '$identify',
62
+ properties: expect.objectContaining({ distinct_id: 'redirect-user' }),
63
+ }),
64
+ ]);
65
+ expect(
66
+ batches.find(({ url }) => url.endsWith('/engage/'))?.payload,
67
+ ).toContainEqual(
68
+ expect.objectContaining({
69
+ $distinct_id: 'redirect-user',
70
+ $set: expect.objectContaining({ $email: 'redirect@example.com' }),
71
+ }),
72
+ );
73
+ });
74
+
75
+ it('restores people readiness for the unchanged user without another native identity event', async () => {
76
+ mixpanelService.identify('restored-user', {});
77
+ await vi.advanceTimersByTimeAsync(1000);
78
+ batches.length = 0;
79
+ transport._flags.identify_called = false;
80
+ const accepted = vi.fn();
81
+ const report = vi.fn();
82
+
83
+ mixpanelService.restorePeopleProperties(
84
+ 'other-user',
85
+ { reservamos_one_id: 'wrong' },
86
+ report,
87
+ accepted,
88
+ );
89
+ expect(accepted).not.toHaveBeenCalled();
90
+
91
+ mixpanelService.restorePeopleProperties(
92
+ 'restored-user',
93
+ { reservamos_one_id: 42 },
94
+ report,
95
+ accepted,
96
+ );
97
+ expect(accepted).toHaveBeenCalledTimes(1);
98
+ await vi.advanceTimersByTimeAsync(1000);
99
+ expect(batches).toHaveLength(1);
100
+ expect(batches[0].url).toContain('/engage/');
101
+ expect(batches[0].payload).toContainEqual(
102
+ expect.objectContaining({
103
+ $distinct_id: 'restored-user',
104
+ $set: expect.objectContaining({ reservamos_one_id: 42 }),
105
+ }),
106
+ );
107
+ expect(report).not.toHaveBeenCalled();
108
+ });
@@ -59,6 +59,7 @@ function init(
59
59
  return new Promise<void>((resolve) => {
60
60
  mixpanel.init(mixpanelToken, {
61
61
  debug,
62
+ batch_flush_interval_ms: 1000,
62
63
  loaded: () => {
63
64
  if (attempt !== initialization) {
64
65
  resolve();
@@ -131,32 +132,47 @@ function attachProperty(key: string, value: unknown): void {
131
132
  function setPeopleProperties(
132
133
  properties: Record<string, unknown>,
133
134
  report?: IdentifyReporter,
135
+ onAccepted?: () => void,
134
136
  ): void {
135
137
  if (!isReady()) {
136
138
  return;
137
139
  }
138
140
 
139
- writePeopleProperties(properties, report);
141
+ writePeopleProperties(properties, report, onAccepted);
142
+ }
143
+
144
+ /** Restores people readiness only when the native persisted identity still matches. */
145
+ function restorePeopleProperties(
146
+ userId: string,
147
+ properties: Record<string, unknown>,
148
+ report: IdentifyReporter,
149
+ onAccepted: () => void,
150
+ ): void {
151
+ if (!isReady() || mixpanel.get_distinct_id() !== userId) return;
152
+ mixpanel.identify(userId);
153
+ writePeopleProperties(properties, report, onAccepted);
140
154
  }
141
155
 
142
156
  function writePeopleProperties(
143
157
  properties: Record<string, unknown>,
144
158
  report?: IdentifyReporter,
159
+ onAccepted?: () => void,
145
160
  ): void {
146
- if (!report) {
161
+ if (!report && !onAccepted) {
147
162
  mixpanel.people.set(properties);
148
163
  return;
149
164
  }
150
- const observer = observeIdentifyResult('people', report);
165
+ const observer = report ? observeIdentifyResult('people', report) : undefined;
151
166
  try {
152
167
  mixpanel.people.set(properties, (response) => {
153
168
  const status = typeof response === 'number' ? response : response?.status;
154
- observer.complete(
169
+ if (status === 1) onAccepted?.();
170
+ observer?.complete(
155
171
  status === 1 ? undefined : status === 0 ? 'failed' : 'unconfirmed',
156
172
  );
157
173
  });
158
174
  } catch {
159
- observer.complete('failed');
175
+ observer?.complete('failed');
160
176
  }
161
177
  }
162
178
 
@@ -224,6 +240,7 @@ const mixpanelService = {
224
240
  identify,
225
241
  attachProperty,
226
242
  setPeopleProperties,
243
+ restorePeopleProperties,
227
244
  getMixpanelDistinctId,
228
245
  };
229
246