@@ -447,6 +447,152 @@ def test_standing_charge_converts_pence_to_pounds():
447447 assert result == 0.53
448448
449449
450+ def test_fetch_standing_charges_rest_404_falls_back_to_graphql ():
451+ """async_fetch_standing_charges() falls back to GraphQL when REST returns 404.
452+
453+ Reproduces the E.ON Next TOU tariff scenario: NEXT_SMART_SAVER_FIXED_12M_V8 has no
454+ /standing-charges/ REST endpoint, returns HTTP 404, GraphQL fallback must be used.
455+ """
456+ api = make_kraken_api (provider = "eon" , account_id = "A-AA8A473C" )
457+ api .current_tariff = {"tariff_code" : "E-TOU-NEXT_SMART_SAVER_FIXED_12M_V8-M" , "product_code" : "NEXT_SMART_SAVER_FIXED_12M_V8" }
458+ api .import_mpan = "1900000000456"
459+ api .async_fetch_standing_charges_graphql = AsyncMock (return_value = 0.6195 )
460+
461+ mock_response = AsyncMock ()
462+ mock_response .status = 404
463+ mock_session = AsyncMock ()
464+ mock_session .get = MagicMock (
465+ return_value = AsyncMock (
466+ __aenter__ = AsyncMock (return_value = mock_response ),
467+ __aexit__ = AsyncMock (return_value = None ),
468+ )
469+ )
470+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
471+ mock_session .__aexit__ = AsyncMock (return_value = None )
472+
473+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
474+ result = asyncio .run (api .async_fetch_standing_charges ())
475+
476+ assert result == 0.6195
477+ api .async_fetch_standing_charges_graphql .assert_called_once_with ("1900000000456" )
478+
479+
480+ def test_fetch_standing_charges_rest_404_no_fallback_without_import_mpan ():
481+ """async_fetch_standing_charges() returns None (no fallback) when REST 404 and import_mpan not set."""
482+ api = make_kraken_api ()
483+ api .current_tariff = {"tariff_code" : "E-TOU-OLD-M" , "product_code" : "OLD" }
484+ api .import_mpan = None
485+
486+ mock_response = AsyncMock ()
487+ mock_response .status = 404
488+ mock_session = AsyncMock ()
489+ mock_session .get = MagicMock (
490+ return_value = AsyncMock (
491+ __aenter__ = AsyncMock (return_value = mock_response ),
492+ __aexit__ = AsyncMock (return_value = None ),
493+ )
494+ )
495+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
496+ mock_session .__aexit__ = AsyncMock (return_value = None )
497+
498+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
499+ result = asyncio .run (api .async_fetch_standing_charges ())
500+
501+ assert result is None
502+
503+
504+ def test_fetch_standing_charges_rest_410_falls_back_to_graphql ():
505+ """async_fetch_standing_charges() also falls back on HTTP 410 Gone."""
506+ api = make_kraken_api (provider = "eon" , account_id = "A-AA8A473C" )
507+ api .current_tariff = {"tariff_code" : "E-TOU-GONE-V1-M" , "product_code" : "GONE-V1" }
508+ api .import_mpan = "1900000000456"
509+ api .async_fetch_standing_charges_graphql = AsyncMock (return_value = 0.53 )
510+
511+ mock_response = AsyncMock ()
512+ mock_response .status = 410
513+ mock_session = AsyncMock ()
514+ mock_session .get = MagicMock (
515+ return_value = AsyncMock (
516+ __aenter__ = AsyncMock (return_value = mock_response ),
517+ __aexit__ = AsyncMock (return_value = None ),
518+ )
519+ )
520+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
521+ mock_session .__aexit__ = AsyncMock (return_value = None )
522+
523+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
524+ result = asyncio .run (api .async_fetch_standing_charges ())
525+
526+ assert result == 0.53
527+ api .async_fetch_standing_charges_graphql .assert_called_once_with ("1900000000456" )
528+
529+
530+ def test_fetch_standing_charges_graphql_returns_value ():
531+ """async_fetch_standing_charges_graphql() converts GraphQL value to pounds/day."""
532+ api = make_kraken_api ()
533+ api .account_id = "A-AA8A473C"
534+ api .async_graphql_query = AsyncMock (
535+ return_value = {
536+ "applicableStandingCharges" : [
537+ {"value" : 61.95 , "validFrom" : "2026-04-10T00:00:00Z" , "validTo" : None },
538+ ]
539+ }
540+ )
541+
542+ result = asyncio .run (api .async_fetch_standing_charges_graphql ("1900000000456" ))
543+
544+ # 61.95 pence/day → 0.6195 pounds/day
545+ assert result is not None
546+ assert abs (result - 0.6195 ) < 1e-6
547+
548+
549+ def test_fetch_standing_charges_graphql_returns_none_on_empty ():
550+ """async_fetch_standing_charges_graphql() returns None when applicableStandingCharges is empty."""
551+ api = make_kraken_api ()
552+ api .account_id = "A-AA8A473C"
553+ api .async_graphql_query = AsyncMock (return_value = {"applicableStandingCharges" : []})
554+
555+ result = asyncio .run (api .async_fetch_standing_charges_graphql ("1900000000456" ))
556+ assert result is None
557+
558+
559+ def test_fetch_standing_charges_graphql_returns_none_on_graphql_failure ():
560+ """async_fetch_standing_charges_graphql() returns None when GraphQL query fails."""
561+ api = make_kraken_api ()
562+ api .account_id = "A-AA8A473C"
563+ api .async_graphql_query = AsyncMock (return_value = None )
564+
565+ result = asyncio .run (api .async_fetch_standing_charges_graphql ("1900000000456" ))
566+ assert result is None
567+
568+
569+ def test_fetch_standing_charges_transient_error_does_not_fall_back_to_graphql ():
570+ """async_fetch_standing_charges() returns None (no fallback) for transient errors like 500/429."""
571+ for status_code in (429 , 500 , 503 ):
572+ api = make_kraken_api ()
573+ api .current_tariff = {"tariff_code" : "E-1R-VAR-01-J" , "product_code" : "VAR-01" }
574+ api .import_mpan = "1900000000456"
575+ api .async_fetch_standing_charges_graphql = AsyncMock ()
576+
577+ mock_response = AsyncMock ()
578+ mock_response .status = status_code
579+ mock_session = AsyncMock ()
580+ mock_session .get = MagicMock (
581+ return_value = AsyncMock (
582+ __aenter__ = AsyncMock (return_value = mock_response ),
583+ __aexit__ = AsyncMock (return_value = None ),
584+ )
585+ )
586+ mock_session .__aenter__ = AsyncMock (return_value = mock_session )
587+ mock_session .__aexit__ = AsyncMock (return_value = None )
588+
589+ with patch ("aiohttp.ClientSession" , return_value = mock_session ):
590+ result = asyncio .run (api .async_fetch_standing_charges ())
591+
592+ assert result is None , f"Expected None for HTTP { status_code } , got { result } "
593+ api .async_fetch_standing_charges_graphql .assert_not_called ()
594+
595+
450596def test_export_discovery_clears_stale_when_not_found ():
451597 """Export tariff is cleared if all strategies fail (prevents stale rates)."""
452598 api = make_kraken_api ()
@@ -977,6 +1123,13 @@ def run_kraken_tests(my_predbat=None):
9771123 test_fetch_rates_rest_404_no_fallback_for_export_tariff ,
9781124 test_fetch_rates_rest_410_falls_back_to_graphql_for_import ,
9791125 test_fetch_rates_transient_error_does_not_fall_back_to_graphql ,
1126+ test_fetch_standing_charges_rest_404_falls_back_to_graphql ,
1127+ test_fetch_standing_charges_rest_404_no_fallback_without_import_mpan ,
1128+ test_fetch_standing_charges_rest_410_falls_back_to_graphql ,
1129+ test_fetch_standing_charges_graphql_returns_value ,
1130+ test_fetch_standing_charges_graphql_returns_none_on_empty ,
1131+ test_fetch_standing_charges_graphql_returns_none_on_graphql_failure ,
1132+ test_fetch_standing_charges_transient_error_does_not_fall_back_to_graphql ,
9801133 ]
9811134 for test_func in tests :
9821135 try :
0 commit comments