@@ -412,6 +412,128 @@ describe('useFeedConversion', () => {
412412 } ) ;
413413 } ) ;
414414
415+ it ( 'does not auto-retry browserless for unauthorized faraday failures' , async ( ) => {
416+ fetchMock . mockResolvedValueOnce (
417+ new Response (
418+ JSON . stringify ( {
419+ success : false ,
420+ error : { message : 'Unauthorized' } ,
421+ } ) ,
422+ {
423+ status : 401 ,
424+ headers : { 'Content-Type' : 'application/json' } ,
425+ }
426+ )
427+ ) ;
428+
429+ const { result } = renderHook ( ( ) => useFeedConversion ( ) ) ;
430+
431+ await act ( async ( ) => {
432+ await expect (
433+ result . current . convertFeed ( 'https://example.com/articles' , 'faraday' , 'testtoken' )
434+ ) . rejects . toThrow ( 'Unauthorized' ) ;
435+ } ) ;
436+
437+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
438+ expect ( result . current . result ) . toBeNull ( ) ;
439+ expect ( result . current . error ) . toBe ( 'Unauthorized' ) ;
440+ } ) ;
441+
442+ it ( 'does not auto-retry when API returns a non-retryable BAD_REQUEST code' , async ( ) => {
443+ fetchMock . mockResolvedValueOnce (
444+ new Response (
445+ JSON . stringify ( {
446+ success : false ,
447+ error : { code : 'BAD_REQUEST' , message : 'Input rejected' } ,
448+ } ) ,
449+ {
450+ status : 400 ,
451+ headers : { 'Content-Type' : 'application/json' } ,
452+ }
453+ )
454+ ) ;
455+
456+ const { result } = renderHook ( ( ) => useFeedConversion ( ) ) ;
457+
458+ await act ( async ( ) => {
459+ await expect (
460+ result . current . convertFeed ( 'https://example.com/articles' , 'faraday' , 'testtoken' )
461+ ) . rejects . toThrow ( 'Input rejected' ) ;
462+ } ) ;
463+
464+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
465+ expect ( result . current . result ) . toBeNull ( ) ;
466+ expect ( result . current . error ) . toBe ( 'Input rejected' ) ;
467+ } ) ;
468+
469+ it ( 'still auto-retries when API returns INTERNAL_SERVER_ERROR even if message contains a url' , async ( ) => {
470+ const createdFeed = {
471+ id : 'test-id' ,
472+ name : 'Test Feed' ,
473+ url : 'https://example.com/articles' ,
474+ strategy : 'browserless' ,
475+ feed_token : 'test-token' ,
476+ public_url : 'https://example.com/feed' ,
477+ json_public_url : 'https://example.com/feed.json' ,
478+ created_at : '2024-01-01T00:00:00Z' ,
479+ updated_at : '2024-01-01T00:00:00Z' ,
480+ } ;
481+
482+ fetchMock
483+ . mockResolvedValueOnce (
484+ new Response (
485+ JSON . stringify ( {
486+ success : false ,
487+ error : {
488+ code : 'INTERNAL_SERVER_ERROR' ,
489+ message : 'Failed to fetch https://example.com/articles' ,
490+ } ,
491+ } ) ,
492+ {
493+ status : 500 ,
494+ headers : { 'Content-Type' : 'application/json' } ,
495+ }
496+ )
497+ )
498+ . mockResolvedValueOnce (
499+ new Response (
500+ JSON . stringify ( {
501+ success : true ,
502+ data : {
503+ feed : createdFeed ,
504+ } ,
505+ } ) ,
506+ {
507+ status : 201 ,
508+ headers : { 'Content-Type' : 'application/json' } ,
509+ }
510+ )
511+ )
512+ . mockResolvedValueOnce (
513+ new Response ( JSON . stringify ( { items : [ ] } ) , {
514+ status : 200 ,
515+ headers : { 'Content-Type' : 'application/feed+json' } ,
516+ } )
517+ ) ;
518+
519+ const { result } = renderHook ( ( ) => useFeedConversion ( ) ) ;
520+
521+ await act ( async ( ) => {
522+ await result . current . convertFeed ( 'https://example.com/articles' , 'faraday' , 'testtoken' ) ;
523+ } ) ;
524+
525+ const retryRequest = fetchMock . mock . calls [ 1 ] ?. [ 0 ] as Request ;
526+ expect ( await retryRequest . clone ( ) . json ( ) ) . toEqual ( {
527+ url : 'https://example.com/articles' ,
528+ strategy : 'browserless' ,
529+ } ) ;
530+ expect ( result . current . result ?. retry ) . toEqual ( {
531+ automatic : true ,
532+ from : 'faraday' ,
533+ to : 'browserless' ,
534+ } ) ;
535+ } ) ;
536+
415537 it ( 'does not offer a duplicate manual retry after automatic fallback also fails' , async ( ) => {
416538 fetchMock
417539 . mockResolvedValueOnce (
@@ -459,4 +581,125 @@ describe('useFeedConversion', () => {
459581 'Tried faraday first, then browserless. First attempt failed with: Upstream timeout. Second attempt failed with: Browserless also failed'
460582 ) ;
461583 } ) ;
584+
585+ it ( 'ignores stale preview updates from an earlier conversion request' , async ( ) => {
586+ const feedA = {
587+ id : 'feed-a-id' ,
588+ name : 'Feed A' ,
589+ url : 'https://example.com/a' ,
590+ strategy : 'faraday' ,
591+ feed_token : 'feed-a-token' ,
592+ public_url : 'https://example.com/feed-a' ,
593+ json_public_url : 'https://example.com/feed-a.json' ,
594+ created_at : '2024-01-01T00:00:00Z' ,
595+ updated_at : '2024-01-01T00:00:00Z' ,
596+ } ;
597+ const feedB = {
598+ id : 'feed-b-id' ,
599+ name : 'Feed B' ,
600+ url : 'https://example.com/b' ,
601+ strategy : 'faraday' ,
602+ feed_token : 'feed-b-token' ,
603+ public_url : 'https://example.com/feed-b' ,
604+ json_public_url : 'https://example.com/feed-b.json' ,
605+ created_at : '2024-01-01T00:00:00Z' ,
606+ updated_at : '2024-01-01T00:00:00Z' ,
607+ } ;
608+
609+ let resolvePreviewA : ( ( value : Response ) => void ) | null = null ;
610+ const previewAPromise = new Promise < Response > ( ( resolve ) => {
611+ resolvePreviewA = resolve ;
612+ } ) ;
613+ let resolvePreviewB : ( ( value : Response ) => void ) | null = null ;
614+ const previewBPromise = new Promise < Response > ( ( resolve ) => {
615+ resolvePreviewB = resolve ;
616+ } ) ;
617+
618+ fetchMock
619+ . mockResolvedValueOnce (
620+ new Response (
621+ JSON . stringify ( {
622+ success : true ,
623+ data : { feed : feedA } ,
624+ } ) ,
625+ {
626+ status : 201 ,
627+ headers : { 'Content-Type' : 'application/json' } ,
628+ }
629+ )
630+ )
631+ . mockReturnValueOnce ( previewAPromise as Promise < Response > )
632+ . mockResolvedValueOnce (
633+ new Response (
634+ JSON . stringify ( {
635+ success : true ,
636+ data : { feed : feedB } ,
637+ } ) ,
638+ {
639+ status : 201 ,
640+ headers : { 'Content-Type' : 'application/json' } ,
641+ }
642+ )
643+ )
644+ . mockReturnValueOnce ( previewBPromise as Promise < Response > ) ;
645+
646+ const { result } = renderHook ( ( ) => useFeedConversion ( ) ) ;
647+
648+ await act ( async ( ) => {
649+ await result . current . convertFeed ( 'https://example.com/a' , 'faraday' , 'testtoken' ) ;
650+ } ) ;
651+ await act ( async ( ) => {
652+ await result . current . convertFeed ( 'https://example.com/b' , 'faraday' , 'testtoken' ) ;
653+ } ) ;
654+
655+ expect ( result . current . result ?. feed . feed_token ) . toBe ( 'feed-b-token' ) ;
656+
657+ resolvePreviewB ?.(
658+ new Response (
659+ JSON . stringify ( {
660+ items : [
661+ {
662+ title : 'Preview B' ,
663+ content_text : 'Current preview item' ,
664+ url : 'https://example.com/b/item' ,
665+ date_published : '2024-01-02T00:00:00Z' ,
666+ } ,
667+ ] ,
668+ } ) ,
669+ {
670+ status : 200 ,
671+ headers : { 'Content-Type' : 'application/feed+json' } ,
672+ }
673+ )
674+ ) ;
675+
676+ await waitFor ( ( ) => {
677+ expect ( result . current . result ?. feed . feed_token ) . toBe ( 'feed-b-token' ) ;
678+ expect ( result . current . result ?. preview . items [ 0 ] ?. title ) . toBe ( 'Preview B' ) ;
679+ } ) ;
680+
681+ resolvePreviewA ?.(
682+ new Response (
683+ JSON . stringify ( {
684+ items : [
685+ {
686+ title : 'Preview A' ,
687+ content_text : 'Stale preview item' ,
688+ url : 'https://example.com/a/item' ,
689+ date_published : '2024-01-03T00:00:00Z' ,
690+ } ,
691+ ] ,
692+ } ) ,
693+ {
694+ status : 200 ,
695+ headers : { 'Content-Type' : 'application/feed+json' } ,
696+ }
697+ )
698+ ) ;
699+
700+ await waitFor ( ( ) => {
701+ expect ( result . current . result ?. feed . feed_token ) . toBe ( 'feed-b-token' ) ;
702+ expect ( result . current . result ?. preview . items [ 0 ] ?. title ) . toBe ( 'Preview B' ) ;
703+ } ) ;
704+ } ) ;
462705} ) ;
0 commit comments