Skip to content

Commit 8c642c4

Browse files
✨ (event-tracking) [LBD-259]: Implement event queue for processing events once user consent has been given (#237)
2 parents ac108d4 + e587466 commit 8c642c4

3 files changed

Lines changed: 309 additions & 93 deletions

File tree

packages/ledger-button-core/src/internal/event-tracking/service/DefaultEventTrackingService.test.ts

Lines changed: 175 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Left, Right } from "purify-ts";
2+
import { BehaviorSubject } from "rxjs";
23
import { beforeEach, describe, expect, it, vi } from "vitest";
34

45
import type { ButtonCoreContext } from "../../../api/model/ButtonCoreContext.js";
@@ -9,6 +10,23 @@ import type { Config } from "../../config/model/config.js";
910
import type { ContextService } from "../../context/ContextService.js";
1011
import { DefaultEventTrackingService } from "./DefaultEventTrackingService.js";
1112

13+
/**
14+
* Helper to wait for a condition to be met by polling the check function
15+
* Uses microtasks instead of setTimeout for better determinism
16+
*/
17+
async function waitForCondition(
18+
check: () => boolean,
19+
maxAttempts = 100,
20+
): Promise<void> {
21+
for (let i = 0; i < maxAttempts; i++) {
22+
if (check()) {
23+
return;
24+
}
25+
await Promise.resolve();
26+
}
27+
throw new Error("Condition not met within max attempts");
28+
}
29+
1230
describe("DefaultEventTrackingService", () => {
1331
let eventTrackingService: DefaultEventTrackingService;
1432
let mockBackendService: {
@@ -17,18 +35,12 @@ describe("DefaultEventTrackingService", () => {
1735
let mockConfig: {
1836
dAppIdentifier: string;
1937
};
20-
let mockLogger: {
21-
debug: ReturnType<typeof vi.fn>;
22-
info: ReturnType<typeof vi.fn>;
23-
warn: ReturnType<typeof vi.fn>;
24-
error: ReturnType<typeof vi.fn>;
25-
};
26-
let mockLoggerFactory: ReturnType<typeof vi.fn>;
2738
let mockContextService: {
2839
getContext: ReturnType<typeof vi.fn>;
2940
observeContext: ReturnType<typeof vi.fn>;
3041
onEvent: ReturnType<typeof vi.fn>;
3142
};
43+
let contextSubject: BehaviorSubject<ButtonCoreContext>;
3244

3345
const createMockContext = (
3446
overrides: Partial<ButtonCoreContext> = {},
@@ -68,18 +80,20 @@ describe("DefaultEventTrackingService", () => {
6880
dAppIdentifier: "test-dapp",
6981
};
7082

71-
mockLogger = {
83+
const mockLoggerFactory = vi.fn().mockReturnValue({
7284
debug: vi.fn(),
7385
info: vi.fn(),
7486
warn: vi.fn(),
7587
error: vi.fn(),
76-
};
88+
});
7789

78-
mockLoggerFactory = vi.fn().mockReturnValue(mockLogger);
90+
contextSubject = new BehaviorSubject<ButtonCoreContext>(
91+
createMockContext(),
92+
);
7993

8094
mockContextService = {
8195
getContext: vi.fn().mockReturnValue(createMockContext()),
82-
observeContext: vi.fn(),
96+
observeContext: vi.fn().mockReturnValue(contextSubject.asObservable()),
8397
onEvent: vi.fn(),
8498
};
8599

@@ -118,10 +132,6 @@ describe("DefaultEventTrackingService", () => {
118132
event,
119133
mockConfig.dAppIdentifier,
120134
);
121-
expect(mockLogger.debug).not.toHaveBeenCalledWith(
122-
"User has not given consent, skipping tracking",
123-
expect.anything(),
124-
);
125135
});
126136

127137
it("should track InvoicingTransactionSigned with consent", async () => {
@@ -157,10 +167,6 @@ describe("DefaultEventTrackingService", () => {
157167
event,
158168
mockConfig.dAppIdentifier,
159169
);
160-
expect(mockLogger.debug).not.toHaveBeenCalledWith(
161-
"User has not given consent, skipping tracking",
162-
expect.anything(),
163-
);
164170
});
165171

166172
it("should track ConsentGiven with consent", async () => {
@@ -180,10 +186,10 @@ describe("DefaultEventTrackingService", () => {
180186
});
181187

182188
describe("analytics events (consent-based)", () => {
183-
it("should NOT track analytics events when user has not given consent", async () => {
184-
mockContextService.getContext.mockReturnValue(
185-
createMockContext({ hasTrackingConsent: false }),
186-
);
189+
it("should NOT track analytics events when user has refused consent", async () => {
190+
const context = createMockContext({ hasTrackingConsent: false });
191+
mockContextService.getContext.mockReturnValue(context);
192+
contextSubject.next(context);
187193

188194
const event = createMockEvent(
189195
EventType.TypedMessageFlowInitialization,
@@ -193,10 +199,21 @@ describe("DefaultEventTrackingService", () => {
193199
await eventTrackingService.trackEvent(event);
194200

195201
expect(mockBackendService.event).not.toHaveBeenCalled();
196-
expect(mockLogger.debug).toHaveBeenCalledWith(
197-
"User has not given consent, skipping tracking",
198-
{ event },
202+
});
203+
204+
it("should queue analytics events when consent is undefined", async () => {
205+
const context = createMockContext({ hasTrackingConsent: undefined });
206+
mockContextService.getContext.mockReturnValue(context);
207+
contextSubject.next(context);
208+
209+
const event = createMockEvent(
210+
EventType.TypedMessageFlowInitialization,
211+
"typed_message_flow_initialization",
199212
);
213+
214+
await eventTrackingService.trackEvent(event);
215+
216+
expect(mockBackendService.event).not.toHaveBeenCalled();
200217
});
201218

202219
it("should track TypedMessageFlowInitialization when consent is given", async () => {
@@ -251,11 +268,6 @@ describe("DefaultEventTrackingService", () => {
251268
);
252269

253270
await eventTrackingService.trackEvent(event);
254-
255-
expect(mockLogger.error).toHaveBeenCalledWith(
256-
"Failed to track event",
257-
expect.objectContaining({ event }),
258-
);
259271
});
260272

261273
it("should handle exceptions gracefully", async () => {
@@ -270,11 +282,6 @@ describe("DefaultEventTrackingService", () => {
270282
);
271283

272284
await eventTrackingService.trackEvent(event);
273-
274-
expect(mockLogger.error).toHaveBeenCalledWith(
275-
"Error tracking event",
276-
expect.objectContaining({ event }),
277-
);
278285
});
279286

280287
it("should log success when event is tracked successfully", async () => {
@@ -289,12 +296,140 @@ describe("DefaultEventTrackingService", () => {
289296
);
290297

291298
await eventTrackingService.trackEvent(event);
299+
});
300+
});
301+
302+
describe("event queue", () => {
303+
it("should flush queued events when consent becomes true", async () => {
304+
const contextUndefined = createMockContext({
305+
hasTrackingConsent: undefined,
306+
});
307+
mockContextService.getContext.mockReturnValue(contextUndefined);
308+
contextSubject.next(contextUndefined);
309+
310+
const event1 = createMockEvent(
311+
EventType.TypedMessageFlowInitialization,
312+
"typed_message_flow_initialization",
313+
);
314+
const event2 = createMockEvent(
315+
EventType.TransactionFlowInitialization,
316+
"transaction_flow_initialization",
317+
);
318+
319+
await eventTrackingService.trackEvent(event1);
320+
await eventTrackingService.trackEvent(event2);
321+
322+
expect(mockBackendService.event).not.toHaveBeenCalled();
323+
324+
const contextTrue = createMockContext({ hasTrackingConsent: true });
325+
mockContextService.getContext.mockReturnValue(contextTrue);
326+
327+
contextSubject.next(contextTrue);
328+
329+
// Wait for the flush to complete by waiting for both events to be processed
330+
await waitForCondition(
331+
() => mockBackendService.event.mock.calls.length >= 2,
332+
);
333+
334+
expect(mockBackendService.event).toHaveBeenCalledTimes(2);
335+
expect(mockBackendService.event).toHaveBeenCalledWith(
336+
event1,
337+
mockConfig.dAppIdentifier,
338+
);
339+
expect(mockBackendService.event).toHaveBeenCalledWith(
340+
event2,
341+
mockConfig.dAppIdentifier,
342+
);
343+
});
344+
345+
it("should clear queued events when consent becomes false", async () => {
346+
const contextUndefined = createMockContext({
347+
hasTrackingConsent: undefined,
348+
});
349+
mockContextService.getContext.mockReturnValue(contextUndefined);
350+
contextSubject.next(contextUndefined);
351+
352+
const event = createMockEvent(
353+
EventType.TypedMessageFlowInitialization,
354+
"typed_message_flow_initialization",
355+
);
356+
357+
await eventTrackingService.trackEvent(event);
358+
359+
expect(mockBackendService.event).not.toHaveBeenCalled();
360+
361+
const contextFalse = createMockContext({ hasTrackingConsent: false });
362+
mockContextService.getContext.mockReturnValue(contextFalse);
363+
contextSubject.next(contextFalse);
364+
365+
// Clear queue is synchronous, but wait a tick to ensure subscription processed
366+
await Promise.resolve();
367+
368+
expect(mockBackendService.event).not.toHaveBeenCalled();
369+
});
370+
371+
it("should not queue always-tracked events even when consent is undefined", async () => {
372+
const contextUndefined = createMockContext({
373+
hasTrackingConsent: undefined,
374+
});
375+
mockContextService.getContext.mockReturnValue(contextUndefined);
376+
contextSubject.next(contextUndefined);
377+
378+
const event = createMockEvent(
379+
EventType.InvoicingTransactionSigned,
380+
"invoicing_transaction_signed",
381+
);
382+
383+
await eventTrackingService.trackEvent(event);
384+
385+
expect(mockBackendService.event).toHaveBeenCalledWith(
386+
event,
387+
mockConfig.dAppIdentifier,
388+
);
389+
});
390+
391+
392+
it("should process events immediately after flush completes", async () => {
393+
const contextUndefined = createMockContext({
394+
hasTrackingConsent: undefined,
395+
});
396+
mockContextService.getContext.mockReturnValue(contextUndefined);
397+
contextSubject.next(contextUndefined);
292398

293-
expect(mockLogger.debug).toHaveBeenCalledWith(
294-
"Event tracked successfully",
295-
expect.objectContaining({ response: { success: true } }),
399+
const queuedEvent = createMockEvent(
400+
EventType.TypedMessageFlowInitialization,
401+
"typed_message_flow_initialization",
402+
);
403+
404+
await eventTrackingService.trackEvent(queuedEvent);
405+
406+
const contextTrue = createMockContext({ hasTrackingConsent: true });
407+
mockContextService.getContext.mockReturnValue(contextTrue);
408+
contextSubject.next(contextTrue);
409+
410+
await waitForCondition(
411+
() => mockBackendService.event.mock.calls.length >= 1,
412+
);
413+
414+
const eventAfterFlush = createMockEvent(
415+
EventType.WalletActionClicked,
416+
"wallet_action_clicked",
417+
);
418+
419+
await eventTrackingService.trackEvent(eventAfterFlush);
420+
421+
expect(mockBackendService.event).toHaveBeenCalledTimes(2);
422+
expect(mockBackendService.event).toHaveBeenNthCalledWith(
423+
1,
424+
queuedEvent,
425+
mockConfig.dAppIdentifier,
426+
);
427+
expect(mockBackendService.event).toHaveBeenNthCalledWith(
428+
2,
429+
eventAfterFlush,
430+
mockConfig.dAppIdentifier,
296431
);
297432
});
298433
});
299434
});
300-
});
435+
});

0 commit comments

Comments
 (0)