@@ -1998,5 +1998,86 @@ describe('Iterable', () => {
19981998 logSpy . mockRestore ( ) ;
19991999 } ) ;
20002000 } ) ;
2001+
2002+ it ( 'should not block the next callback when authHandler rejects (SDK-520 follow-up regression)' , async ( ) => {
2003+ // Regression for the zombie-invocation bug: when an authHandler
2004+ // rejects, the .catch path previously left the invocation at the head
2005+ // of pendingAuthInvocations. The late native event for the rejected
2006+ // invocation buffered into the zombie, so the next real invocation's
2007+ // native success event routed to the zombie and its successCallback
2008+ // was silently dropped. With removeInvocation() in the .catch path,
2009+ // the rejected invocation is dropped from the queue before native's
2010+ // late event arrives, so the next invocation's native event routes
2011+ // correctly.
2012+ const nativeEmitter = new NativeEventEmitter ( ) ;
2013+ nativeEmitter . removeAllListeners ( IterableEventName . handleAuthCalled ) ;
2014+ nativeEmitter . removeAllListeners (
2015+ IterableEventName . handleAuthSuccessCalled
2016+ ) ;
2017+ nativeEmitter . removeAllListeners (
2018+ IterableEventName . handleAuthFailureCalled
2019+ ) ;
2020+
2021+ const config = new IterableConfig ( ) ;
2022+ config . logReactNativeSdkCalls = false ;
2023+ config . authCallbackTimeoutMs = 2000 ;
2024+
2025+ const successCallback2 = jest . fn ( ) ;
2026+ const failureCallback2 = jest . fn ( ) ;
2027+ const authResponse2 = new IterableAuthResponse ( ) ;
2028+ authResponse2 . authToken = 'retry-success-token' ;
2029+ authResponse2 . successCallback = successCallback2 ;
2030+ authResponse2 . failureCallback = failureCallback2 ;
2031+
2032+ // inv1 rejects (authHandler throws); inv2 resolves with an
2033+ // IterableAuthResponse. Controllable promises let us sequence the
2034+ // second resolve after the first rejection has settled.
2035+ let resolveAuth2 : ( value : IterableAuthResponse ) => void = ( ) => { } ;
2036+ let callCount = 0 ;
2037+ config . authHandler = jest . fn ( ( ) => {
2038+ callCount += 1 ;
2039+ if ( callCount === 1 ) {
2040+ return Promise . reject ( new Error ( 'Auth failed (inv1)' ) ) ;
2041+ }
2042+ return new Promise < IterableAuthResponse > ( ( resolve ) => {
2043+ resolveAuth2 = resolve ;
2044+ } ) ;
2045+ } ) ;
2046+
2047+ const logSpy = jest . spyOn ( IterableLogger , 'log' ) ;
2048+
2049+ Iterable . initialize ( 'apiKey' , config ) ;
2050+
2051+ // WHEN the first handleAuthCalled fires and authHandler rejects.
2052+ // The .catch path must remove inv1 from the queue.
2053+ nativeEmitter . emit ( IterableEventName . handleAuthCalled ) ;
2054+ // Let the rejection settle (microtask queue flush).
2055+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
2056+
2057+ // WHEN a second handleAuthCalled fires and authHandler resolves with
2058+ // an IterableAuthResponse, wiring inv2's latch.
2059+ nativeEmitter . emit ( IterableEventName . handleAuthCalled ) ;
2060+ resolveAuth2 ( authResponse2 ) ;
2061+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) ) ;
2062+
2063+ // THEN the native success event for inv2 routes to inv2 (not to a
2064+ // zombie at queue head) and inv2's successCallback fires.
2065+ nativeEmitter . emit ( IterableEventName . handleAuthSuccessCalled ) ;
2066+
2067+ return await TestHelper . delayed ( 50 , ( ) => {
2068+ expect ( successCallback2 ) . toBeCalled ( ) ;
2069+ expect ( failureCallback2 ) . not . toBeCalled ( ) ;
2070+ // No "No callback received" warning: if inv1 had stayed at queue
2071+ // head, inv2's native event would have routed to the zombie and
2072+ // inv2's safety-net timer would have fired NO_CALLBACK.
2073+ const noCallbackCalls = logSpy . mock . calls . filter (
2074+ ( args ) =>
2075+ typeof args [ 0 ] === 'string' &&
2076+ args [ 0 ] . includes ( 'No callback received from native layer' )
2077+ ) ;
2078+ expect ( noCallbackCalls ) . toHaveLength ( 0 ) ;
2079+ logSpy . mockRestore ( ) ;
2080+ } ) ;
2081+ } ) ;
20012082 } ) ;
20022083} ) ;
0 commit comments