@@ -422,4 +422,187 @@ describe('Destination', () => {
422422 ) ;
423423 } ) ;
424424 } ) ;
425+
426+ describe ( 'batch with wildcard mapping' , ( ) => {
427+ beforeEach ( ( ) => {
428+ jest . useFakeTimers ( ) ;
429+ } ) ;
430+
431+ afterEach ( ( ) => {
432+ jest . useRealTimers ( ) ;
433+ } ) ;
434+
435+ it ( 'should not duplicate events when using wildcard batch config' , async ( ) => {
436+ // Capture events at call time (since batched.events gets cleared after pushBatch)
437+ const capturedBatches : { events : WalkerOS . Events ; key : string } [ ] = [ ] ;
438+ const mockPushBatch = jest . fn ( ( batch ) => {
439+ // Clone the events array to capture at call time
440+ capturedBatches . push ( {
441+ events : [ ...batch . events ] ,
442+ key : batch . key ,
443+ } ) ;
444+ } ) ;
445+ const mockPush = jest . fn ( ) ;
446+
447+ const destinationWithBatch : Destination . Instance = {
448+ push : mockPush ,
449+ pushBatch : mockPushBatch ,
450+ config : {
451+ init : true ,
452+ mapping : {
453+ '*' : {
454+ '*' : { batch : 50 } , // 50ms debounce
455+ } ,
456+ } ,
457+ } ,
458+ } ;
459+
460+ const { elb } = await startFlow ( {
461+ destinations : { batchDest : { code : destinationWithBatch } } ,
462+ } ) ;
463+
464+ // Send different event types (all should match wildcard)
465+ await elb ( 'page view' ) ;
466+ await elb ( 'product click' ) ;
467+ await elb ( 'button press' ) ;
468+
469+ // Advance timers to trigger debounce
470+ jest . advanceTimersByTime ( 100 ) ;
471+
472+ // pushBatch should be called exactly once with all 3 events
473+ expect ( mockPushBatch ) . toHaveBeenCalledTimes ( 1 ) ;
474+ expect ( capturedBatches [ 0 ] . events ) . toHaveLength ( 3 ) ;
475+
476+ // Verify all events are present (not duplicated)
477+ const eventNames = capturedBatches [ 0 ] . events . map ( ( e ) => e . name ) ;
478+ expect ( eventNames ) . toContain ( 'page view' ) ;
479+ expect ( eventNames ) . toContain ( 'product click' ) ;
480+ expect ( eventNames ) . toContain ( 'button press' ) ;
481+
482+ // Individual push should NOT be called (batch handles it)
483+ expect ( mockPush ) . not . toHaveBeenCalled ( ) ;
484+ } ) ;
485+
486+ it ( 'should batch events separately per mapping key' , async ( ) => {
487+ // Capture events at call time
488+ const capturedBatches : { events : WalkerOS . Events ; key : string } [ ] = [ ] ;
489+ const mockPushBatch = jest . fn ( ( batch ) => {
490+ capturedBatches . push ( {
491+ events : [ ...batch . events ] ,
492+ key : batch . key ,
493+ } ) ;
494+ } ) ;
495+ const mockPush = jest . fn ( ) ;
496+
497+ const destinationWithBatch : Destination . Instance = {
498+ push : mockPush ,
499+ pushBatch : mockPushBatch ,
500+ config : {
501+ init : true ,
502+ mapping : {
503+ page : {
504+ '*' : { batch : 50 } , // page events batch together
505+ } ,
506+ product : {
507+ '*' : { batch : 50 } , // product events batch together
508+ } ,
509+ } ,
510+ } ,
511+ } ;
512+
513+ const { elb } = await startFlow ( {
514+ destinations : { batchDest : { code : destinationWithBatch } } ,
515+ } ) ;
516+
517+ // Send events that match different mapping keys
518+ await elb ( 'page view' ) ;
519+ await elb ( 'page scroll' ) ;
520+ await elb ( 'product click' ) ;
521+ await elb ( 'product view' ) ;
522+
523+ // Advance timers to trigger debounce
524+ jest . advanceTimersByTime ( 100 ) ;
525+
526+ // pushBatch should be called twice (once per mapping key)
527+ expect ( mockPushBatch ) . toHaveBeenCalledTimes ( 2 ) ;
528+
529+ // Each batch should have 2 events
530+ expect ( capturedBatches [ 0 ] . events ) . toHaveLength ( 2 ) ;
531+ expect ( capturedBatches [ 1 ] . events ) . toHaveLength ( 2 ) ;
532+ } ) ;
533+
534+ it ( 'should isolate batch state when multiple destinations share same mapping config' , async ( ) => {
535+ // This test reproduces the bug where shared mapping config causes duplicate events
536+ // Two destinations with the SAME mapping config object (shared reference)
537+ const sharedMapping = {
538+ '*' : {
539+ '*' : { batch : 50 } ,
540+ } ,
541+ } ;
542+
543+ const capturedBatches : {
544+ destination : string ;
545+ events : WalkerOS . Events ;
546+ key : string ;
547+ } [ ] = [ ] ;
548+
549+ const mockPushBatch1 = jest . fn ( ( batch ) => {
550+ capturedBatches . push ( {
551+ destination : 'dest1' ,
552+ events : [ ...batch . events ] ,
553+ key : batch . key ,
554+ } ) ;
555+ } ) ;
556+ const mockPushBatch2 = jest . fn ( ( batch ) => {
557+ capturedBatches . push ( {
558+ destination : 'dest2' ,
559+ events : [ ...batch . events ] ,
560+ key : batch . key ,
561+ } ) ;
562+ } ) ;
563+
564+ const destination1 : Destination . Instance = {
565+ push : jest . fn ( ) ,
566+ pushBatch : mockPushBatch1 ,
567+ config : {
568+ init : true ,
569+ mapping : sharedMapping , // Shared reference!
570+ } ,
571+ } ;
572+
573+ const destination2 : Destination . Instance = {
574+ push : jest . fn ( ) ,
575+ pushBatch : mockPushBatch2 ,
576+ config : {
577+ init : true ,
578+ mapping : sharedMapping , // Same shared reference!
579+ } ,
580+ } ;
581+
582+ const { elb } = await startFlow ( {
583+ destinations : {
584+ dest1 : { code : destination1 } ,
585+ dest2 : { code : destination2 } ,
586+ } ,
587+ } ) ;
588+
589+ // Send events
590+ await elb ( 'page view' ) ;
591+ await elb ( 'product click' ) ;
592+
593+ // Advance timers to trigger debounce
594+ jest . advanceTimersByTime ( 100 ) ;
595+
596+ // Each destination should receive its own batch
597+ // BUG: Currently shared mapping causes only last destination to receive events
598+ // or events get duplicated across destinations
599+ const totalPushBatchCalls =
600+ mockPushBatch1 . mock . calls . length + mockPushBatch2 . mock . calls . length ;
601+ expect ( totalPushBatchCalls ) . toBe ( 2 ) ; // Should be 2 (one per destination)
602+
603+ // Each destination should receive exactly 2 events
604+ expect ( mockPushBatch1 ) . toHaveBeenCalledTimes ( 1 ) ;
605+ expect ( mockPushBatch2 ) . toHaveBeenCalledTimes ( 1 ) ;
606+ } ) ;
607+ } ) ;
425608} ) ;
0 commit comments