@@ -70,16 +70,24 @@ describe("app switch resume flow", () => {
7070 expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
7171 } ) ;
7272
73- test ( "should test null fetching resume params with invalid callback passed" , ( ) => {
73+ test ( "should extract resume params from hash with non-action prefix via web fallback" , ( ) => {
74+ // When hash is not a known action (e.g. #Unknown) but contains PayPal params,
75+ // the web fallback should extract them from the hash fragment.
7476 vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
7577 hash : `#Unknown?button_session_id=${ buttonSessionID } &token=${ orderID } &fundingSource=${ fundingSource } ` ,
78+ search : "" ,
7679 } ) ;
7780
7881 const params = getAppSwitchResumeParams ( ) ;
7982
8083 expect . assertions ( 2 ) ;
81- expect ( params ) . toEqual ( null ) ;
82- expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
84+ expect ( params ) . toEqual ( {
85+ buttonSessionID,
86+ checkoutState : "onCancel" ,
87+ fundingSource,
88+ orderID,
89+ } ) ;
90+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
8391 } ) ;
8492
8593 test ( "should test fetching multiple resume params when parameters are correctly passed" , ( ) => {
@@ -140,4 +148,270 @@ describe("app switch resume flow", () => {
140148 } ) ;
141149 expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
142150 } ) ;
151+
152+ test ( "should extract resume params when merchant return_url has hash fragment with ? delimiter" , ( ) => {
153+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
154+ hash : `#payment?button_session_id=${ buttonSessionID } &token=${ orderID } &fundingSource=${ fundingSource } &PayerID=PP-payer-122` ,
155+ search : "" ,
156+ } ) ;
157+
158+ const params = getAppSwitchResumeParams ( ) ;
159+
160+ expect ( params ) . toEqual ( {
161+ buttonSessionID,
162+ checkoutState : "onApprove" ,
163+ fundingSource,
164+ orderID,
165+ payerID : "PP-payer-122" ,
166+ } ) ;
167+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
168+ } ) ;
169+
170+ test ( "should extract resume params when merchant return_url has hash fragment without PayerID (cancel)" , ( ) => {
171+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
172+ hash : `#payment?button_session_id=${ buttonSessionID } &token=${ orderID } &fundingSource=${ fundingSource } ` ,
173+ search : "" ,
174+ } ) ;
175+
176+ const params = getAppSwitchResumeParams ( ) ;
177+
178+ expect ( params ) . toEqual ( {
179+ buttonSessionID,
180+ checkoutState : "onCancel" ,
181+ fundingSource,
182+ orderID,
183+ } ) ;
184+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
185+ } ) ;
186+
187+ test ( "should extract resume params when hash uses & delimiter instead of ?" , ( ) => {
188+ // Real-world case: URL like /ppcp-js-sdk?clientSideDelay=0#payment&token=...&PayerID=...
189+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
190+ hash : `#payment&token=${ orderID } &PayerID=PP-payer-122&button_session_id=${ buttonSessionID } ` ,
191+ search : "?clientSideDelay=0&serverSideDelay=0" ,
192+ } ) ;
193+
194+ const params = getAppSwitchResumeParams ( ) ;
195+
196+ expect ( params ) . toEqual ( {
197+ buttonSessionID,
198+ checkoutState : "onApprove" ,
199+ orderID,
200+ payerID : "PP-payer-122" ,
201+ } ) ;
202+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
203+ } ) ;
204+
205+ test ( "should extract vault resume params from hash fragment" , ( ) => {
206+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
207+ hash : `#/step3?button_session_id=${ buttonSessionID } &token=${ orderID } &approval_token_id=VA-3` ,
208+ search : "" ,
209+ } ) ;
210+
211+ const params = getAppSwitchResumeParams ( ) ;
212+
213+ expect ( params ) . toEqual ( {
214+ buttonSessionID,
215+ checkoutState : "onCancel" ,
216+ orderID,
217+ vaultSetupToken : "VA-3" ,
218+ } ) ;
219+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
220+ } ) ;
221+
222+ test ( "should prefer search params over hash params when both exist" , ( ) => {
223+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
224+ hash : `#payment?token=WRONG-TOKEN` ,
225+ search : `?button_session_id=${ buttonSessionID } &token=${ orderID } &PayerID=PP-123` ,
226+ } ) ;
227+
228+ const params = getAppSwitchResumeParams ( ) ;
229+
230+ expect ( params ) . toEqual ( {
231+ buttonSessionID,
232+ checkoutState : "onApprove" ,
233+ orderID,
234+ payerID : "PP-123" ,
235+ } ) ;
236+ } ) ;
237+
238+ test ( "should return null when hash has merchant fragment but no PayPal params" , ( ) => {
239+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
240+ hash : "#payment" ,
241+ search : "" ,
242+ } ) ;
243+
244+ const params = getAppSwitchResumeParams ( ) ;
245+
246+ expect ( params ) . toEqual ( null ) ;
247+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
248+ } ) ;
249+
250+ test ( "should return null when hash has merchant fragment with unrelated params" , ( ) => {
251+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
252+ hash : "#/checkout?step=review&cart=abc123" ,
253+ search : "" ,
254+ } ) ;
255+
256+ const params = getAppSwitchResumeParams ( ) ;
257+
258+ expect ( params ) . toEqual ( null ) ;
259+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
260+ } ) ;
261+
262+ test ( "should handle vaultSetupToken in search params" , ( ) => {
263+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
264+ hash : "" ,
265+ search : `?button_session_id=${ buttonSessionID } &vaultSetupToken=VA-123&PayerID=PP-456` ,
266+ } ) ;
267+
268+ const params = getAppSwitchResumeParams ( ) ;
269+
270+ expect ( params ) . toEqual ( {
271+ buttonSessionID,
272+ checkoutState : "onApprove" ,
273+ payerID : "PP-456" ,
274+ vaultSetupToken : "VA-123" ,
275+ } ) ;
276+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
277+ } ) ;
278+
279+ test ( "should handle approval_token_id in search params" , ( ) => {
280+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
281+ hash : "" ,
282+ search : `?button_session_id=${ buttonSessionID } &approval_token_id=AT-789` ,
283+ } ) ;
284+
285+ const params = getAppSwitchResumeParams ( ) ;
286+
287+ expect ( params ) . toEqual ( {
288+ buttonSessionID,
289+ checkoutState : "onCancel" ,
290+ vaultSetupToken : "AT-789" ,
291+ } ) ;
292+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
293+ } ) ;
294+
295+ test ( "should handle approval_session_id in search params" , ( ) => {
296+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
297+ hash : "" ,
298+ search : `?button_session_id=${ buttonSessionID } &approval_session_id=AS-999` ,
299+ } ) ;
300+
301+ const params = getAppSwitchResumeParams ( ) ;
302+
303+ expect ( params ) . toEqual ( {
304+ buttonSessionID,
305+ checkoutState : "onCancel" ,
306+ vaultSetupToken : "AS-999" ,
307+ } ) ;
308+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
309+ } ) ;
310+
311+ describe ( "hash URL variations" , ( ) => {
312+ // Tests for all supported hash URL formats that the web fallback must handle.
313+ // PayPal appends params to the URL after app switch; the exact position depends
314+ // on the merchant's return_url structure.
315+
316+ test ( "#hash - plain hash with no params returns null" , ( ) => {
317+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
318+ hash : "#hash" ,
319+ search : "" ,
320+ } ) ;
321+
322+ const params = getAppSwitchResumeParams ( ) ;
323+
324+ expect ( params ) . toEqual ( null ) ;
325+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
326+ } ) ;
327+
328+ test ( "?query=param#hash - PayPal params in search, merchant hash fragment" , ( ) => {
329+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
330+ hash : "#hash" ,
331+ search : `?button_session_id=${ buttonSessionID } &token=${ orderID } &PayerID=PP-payer-122` ,
332+ } ) ;
333+
334+ const params = getAppSwitchResumeParams ( ) ;
335+
336+ expect ( params ) . toEqual ( {
337+ buttonSessionID,
338+ checkoutState : "onApprove" ,
339+ orderID,
340+ payerID : "PP-payer-122" ,
341+ } ) ;
342+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
343+ } ) ;
344+
345+ test ( "#hash?query=param - PayPal params embedded in hash after ?" , ( ) => {
346+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
347+ hash : `#hash?button_session_id=${ buttonSessionID } &token=${ orderID } &PayerID=PP-payer-122` ,
348+ search : "" ,
349+ } ) ;
350+
351+ const params = getAppSwitchResumeParams ( ) ;
352+
353+ expect ( params ) . toEqual ( {
354+ buttonSessionID,
355+ checkoutState : "onApprove" ,
356+ orderID,
357+ payerID : "PP-payer-122" ,
358+ } ) ;
359+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
360+ } ) ;
361+
362+ test ( "/#/checkout/completed - SPA-style hash path with no params returns null" , ( ) => {
363+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
364+ hash : "#/checkout/completed" ,
365+ search : "" ,
366+ } ) ;
367+
368+ const params = getAppSwitchResumeParams ( ) ;
369+
370+ expect ( params ) . toEqual ( null ) ;
371+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
372+ } ) ;
373+
374+ test ( "/#/checkout/completed?query=param - PayPal params after SPA-style hash path" , ( ) => {
375+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
376+ hash : `#/checkout/completed?button_session_id=${ buttonSessionID } &token=${ orderID } &PayerID=PP-payer-122` ,
377+ search : "" ,
378+ } ) ;
379+
380+ const params = getAppSwitchResumeParams ( ) ;
381+
382+ expect ( params ) . toEqual ( {
383+ buttonSessionID,
384+ checkoutState : "onApprove" ,
385+ orderID,
386+ payerID : "PP-payer-122" ,
387+ } ) ;
388+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( true ) ;
389+ } ) ;
390+ } ) ;
391+
392+ test ( "should return null when web fallback throws error" , ( ) => {
393+ // Mock location.search as a getter that throws an error
394+ // eslint-disable-next-line compat/compat
395+ const originalURLSearchParams = window . URLSearchParams ;
396+ // eslint-disable-next-line compat/compat
397+ window . URLSearchParams = class {
398+ constructor ( ) {
399+ throw new Error ( "Invalid URL" ) ;
400+ }
401+ } ;
402+
403+ vi . spyOn ( window , "location" , "get" ) . mockReturnValue ( {
404+ hash : "" ,
405+ search : "?invalid" ,
406+ } ) ;
407+
408+ const params = getAppSwitchResumeParams ( ) ;
409+
410+ expect ( params ) . toEqual ( null ) ;
411+ expect ( isAppSwitchResumeFlow ( ) ) . toEqual ( false ) ;
412+
413+ // Restore original URLSearchParams
414+ // eslint-disable-next-line compat/compat
415+ window . URLSearchParams = originalURLSearchParams ;
416+ } ) ;
143417} ) ;
0 commit comments