@@ -23,7 +23,13 @@ const fetchCalls: { url: string; init: RequestInit }[] = [];
2323const mockFetch = jest . fn ( ) . mockImplementation (
2424 async ( url : string , init ?: RequestInit ) => {
2525 fetchCalls . push ( { url : url as string , init : init ?? { } } ) ;
26- return { ok : true , json : async ( ) => ( { } ) } ;
26+ return {
27+ ok : true ,
28+ status : 200 ,
29+ headers : { get : ( ) => 'application/json' } ,
30+ json : async ( ) => ( { success : true , accepted : 1 , rejected : 0 } ) ,
31+ text : async ( ) => '' ,
32+ } ;
2733 } ,
2834) ;
2935global . fetch = mockFetch ;
@@ -718,6 +724,213 @@ describe('Audience', () => {
718724 } ) ;
719725 } ) ;
720726
727+ describe ( 'onError callback' , ( ) => {
728+ // Each test in this block overrides global.fetch with a specific failing mock.
729+ // Restore the original mock after each test so later tests (reset, etc.) are
730+ // unaffected. Also use real timers so Promise microtasks settle predictably.
731+ const savedFetch = global . fetch ;
732+ beforeEach ( ( ) => {
733+ jest . useRealTimers ( ) ;
734+ } ) ;
735+ afterEach ( ( ) => {
736+ global . fetch = savedFetch ;
737+ jest . useFakeTimers ( ) ;
738+ } ) ;
739+
740+ function mockFailingFetch ( status : number , body : unknown ) {
741+ global . fetch = jest . fn ( ) . mockResolvedValue ( {
742+ ok : false ,
743+ status,
744+ headers : {
745+ get : ( name : string ) => ( name . toLowerCase ( ) === 'content-type' ? 'application/json' : null ) ,
746+ } ,
747+ json : async ( ) => body ,
748+ text : async ( ) => JSON . stringify ( body ) ,
749+ } as unknown as Response ) ;
750+ }
751+
752+ function mockNetworkError ( err : Error ) {
753+ global . fetch = jest . fn ( ) . mockRejectedValue ( err ) ;
754+ }
755+
756+ it ( 'calls onError with FLUSH_FAILED on 400' , async ( ) => {
757+ mockFailingFetch ( 400 , { code : 'VALIDATION_ERROR' , message : 'invalid identityType' } ) ;
758+ const onError = jest . fn ( ) ;
759+ const sdk = createSDK ( { consent : 'anonymous' , onError } ) ;
760+
761+ sdk . track ( 'invalid_event' ) ;
762+ await sdk . flush ( ) ;
763+
764+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
765+ const err = onError . mock . calls [ 0 ] [ 0 ] ;
766+ expect ( err . code ) . toBe ( 'FLUSH_FAILED' ) ;
767+ expect ( err . status ) . toBe ( 400 ) ;
768+ expect ( err . responseBody ) . toEqual ( { code : 'VALIDATION_ERROR' , message : 'invalid identityType' } ) ;
769+ expect ( err ) . toBeInstanceOf ( Error ) ;
770+
771+ sdk . shutdown ( ) ;
772+ } ) ;
773+
774+ it ( 'calls onError with NETWORK_ERROR on fetch rejection' , async ( ) => {
775+ mockNetworkError ( new TypeError ( 'Failed to fetch' ) ) ;
776+ const onError = jest . fn ( ) ;
777+ const sdk = createSDK ( { consent : 'anonymous' , onError } ) ;
778+
779+ sdk . track ( 'offline_event' ) ;
780+ await sdk . flush ( ) ;
781+
782+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
783+ const err = onError . mock . calls [ 0 ] [ 0 ] ;
784+ expect ( err . code ) . toBe ( 'NETWORK_ERROR' ) ;
785+ expect ( err . status ) . toBe ( 0 ) ;
786+ expect ( err . cause ) . toBeInstanceOf ( TypeError ) ;
787+
788+ sdk . shutdown ( ) ;
789+ } ) ;
790+
791+ it ( 'calls onError with CONSENT_SYNC_FAILED on consent PUT 500' , async ( ) => {
792+ mockFailingFetch ( 500 , { code : 'INTERNAL_ERROR' } ) ;
793+ const onError = jest . fn ( ) ;
794+ const sdk = createSDK ( { consent : 'none' , onError } ) ;
795+
796+ sdk . setConsent ( 'anonymous' ) ;
797+
798+ // consent PUT is fire-and-forget; wait for microtasks to settle.
799+ // The chain is: fetch → parseBody → httpSend returns → .then → onConsentError
800+ // Each await in an async function is at least one microtask tick.
801+ for ( let i = 0 ; i < 10 ; i += 1 ) {
802+ // eslint-disable-next-line no-await-in-loop
803+ await Promise . resolve ( ) ;
804+ }
805+
806+ expect ( onError ) . toHaveBeenCalled ( ) ;
807+ const err = onError . mock . calls . find (
808+ ( call : any [ ] ) => call [ 0 ] . code === 'CONSENT_SYNC_FAILED' ,
809+ ) ;
810+ expect ( err ) . toBeDefined ( ) ;
811+ expect ( err ! [ 0 ] . status ) . toBe ( 500 ) ;
812+
813+ sdk . shutdown ( ) ;
814+ } ) ;
815+
816+ it ( 'swallows errors thrown by the onError callback' , async ( ) => {
817+ mockFailingFetch ( 400 , null ) ;
818+ const throwingCallback = jest . fn ( ) . mockImplementation ( ( ) => {
819+ throw new Error ( 'handler boom' ) ;
820+ } ) ;
821+ const sdk = createSDK ( { consent : 'anonymous' , onError : throwingCallback } ) ;
822+
823+ sdk . track ( 'will_fail' ) ;
824+ // Must not throw — SDK error handling must be resilient.
825+ await expect ( sdk . flush ( ) ) . resolves . toBeUndefined ( ) ;
826+
827+ expect ( throwingCallback ) . toHaveBeenCalled ( ) ;
828+
829+ sdk . shutdown ( ) ;
830+ } ) ;
831+
832+ it ( 'does not call onError on success' , async ( ) => {
833+ global . fetch = jest . fn ( ) . mockResolvedValue ( {
834+ ok : true ,
835+ status : 200 ,
836+ headers : {
837+ get : ( ) => 'application/json' ,
838+ } ,
839+ json : async ( ) => ( { success : true , accepted : 1 , rejected : 0 } ) ,
840+ text : async ( ) => '' ,
841+ } as unknown as Response ) ;
842+
843+ const onError = jest . fn ( ) ;
844+ const sdk = createSDK ( { consent : 'anonymous' , onError } ) ;
845+
846+ sdk . track ( 'ok_event' ) ;
847+ await sdk . flush ( ) ;
848+
849+ expect ( onError ) . not . toHaveBeenCalled ( ) ;
850+
851+ sdk . shutdown ( ) ;
852+ } ) ;
853+
854+ it ( 'logs FLUSH_FAILED errors to console when debug is enabled' , async ( ) => {
855+ mockFailingFetch ( 500 , { code : 'INTERNAL_ERROR' } ) ;
856+ const errorSpy = jest . spyOn ( console , 'error' ) . mockImplementation ( ) ;
857+ const logSpy = jest . spyOn ( console , 'log' ) . mockImplementation ( ) ;
858+ try {
859+ const sdk = createSDK ( { consent : 'anonymous' , debug : true } ) ;
860+
861+ sdk . track ( 'will_fail' ) ;
862+ await sdk . flush ( ) ;
863+
864+ const errorCall = errorSpy . mock . calls . find (
865+ ( call ) => typeof call [ 0 ] === 'string'
866+ && call [ 0 ] . includes ( 'AudienceError FLUSH_FAILED' ) ,
867+ ) ;
868+ expect ( errorCall ) . toBeDefined ( ) ;
869+ expect ( errorCall ! [ 1 ] ) . toMatchObject ( {
870+ status : 500 ,
871+ responseBody : { code : 'INTERNAL_ERROR' } ,
872+ } ) ;
873+
874+ sdk . shutdown ( ) ;
875+ } finally {
876+ errorSpy . mockRestore ( ) ;
877+ logSpy . mockRestore ( ) ;
878+ }
879+ } ) ;
880+
881+ it ( 'logs CONSENT_SYNC_FAILED errors to console when debug is enabled' , async ( ) => {
882+ mockFailingFetch ( 500 , { code : 'INTERNAL_ERROR' } ) ;
883+ const errorSpy = jest . spyOn ( console , 'error' ) . mockImplementation ( ) ;
884+ const logSpy = jest . spyOn ( console , 'log' ) . mockImplementation ( ) ;
885+ try {
886+ const sdk = createSDK ( { consent : 'none' , debug : true } ) ;
887+
888+ sdk . setConsent ( 'anonymous' ) ;
889+
890+ // consent PUT is fire-and-forget; drain microtasks so .then() runs.
891+ for ( let i = 0 ; i < 10 ; i += 1 ) {
892+ // eslint-disable-next-line no-await-in-loop
893+ await Promise . resolve ( ) ;
894+ }
895+
896+ const errorCall = errorSpy . mock . calls . find (
897+ ( call ) => typeof call [ 0 ] === 'string'
898+ && call [ 0 ] . includes ( 'AudienceError CONSENT_SYNC_FAILED' ) ,
899+ ) ;
900+ expect ( errorCall ) . toBeDefined ( ) ;
901+ expect ( errorCall ! [ 1 ] ) . toMatchObject ( {
902+ status : 500 ,
903+ } ) ;
904+
905+ sdk . shutdown ( ) ;
906+ } finally {
907+ errorSpy . mockRestore ( ) ;
908+ logSpy . mockRestore ( ) ;
909+ }
910+ } ) ;
911+
912+ it ( 'does not log AudienceError to console when debug is disabled' , async ( ) => {
913+ mockFailingFetch ( 500 , { code : 'INTERNAL_ERROR' } ) ;
914+ const errorSpy = jest . spyOn ( console , 'error' ) . mockImplementation ( ) ;
915+ try {
916+ const sdk = createSDK ( { consent : 'anonymous' , debug : false } ) ;
917+
918+ sdk . track ( 'will_fail' ) ;
919+ await sdk . flush ( ) ;
920+
921+ const errorCall = errorSpy . mock . calls . find (
922+ ( call ) => typeof call [ 0 ] === 'string'
923+ && call [ 0 ] . includes ( 'AudienceError' ) ,
924+ ) ;
925+ expect ( errorCall ) . toBeUndefined ( ) ;
926+
927+ sdk . shutdown ( ) ;
928+ } finally {
929+ errorSpy . mockRestore ( ) ;
930+ }
931+ } ) ;
932+ } ) ;
933+
721934 describe ( 'reset' , ( ) => {
722935 it ( 'clears pending messages from the queue' , async ( ) => {
723936 const sdk = createSDK ( { consent : 'full' } ) ;
0 commit comments