@@ -529,3 +529,307 @@ def test_len_with_auto_detect_and_countries_first(self):
529529 length = len (countries )
530530 # 249 countries + 3 first (AU, US, GB)
531531 self .assertEqual (length , 249 + 3 )
532+
533+
534+ @pytest .mark .skipif (not settings .USE_I18N , reason = "No i18n" )
535+ class TestBrowserLanguageDetection (BaseTest ):
536+ """
537+ Test auto-detect with browser Accept-Language header simulation.
538+
539+ These tests simulate how COUNTRIES_FIRST_AUTO_DETECT works with real
540+ browser language preferences, like a user visiting from Australia with
541+ their browser set to "en-AU" or a French-Canadian user with "fr-CA".
542+
543+ In production, Django's LocaleMiddleware parses the Accept-Language header
544+ and activates the appropriate language. These tests simulate that flow.
545+ """
546+
547+ def _parse_accept_language (self , accept_language ):
548+ """
549+ Parse Accept-Language header and return best language code.
550+
551+ This simulates what Django's LocaleMiddleware does when processing
552+ the HTTP Accept-Language header from a browser.
553+ """
554+ from django .utils .translation import trans_real
555+
556+ # Parse the Accept-Language header
557+ parsed = trans_real .parse_accept_lang_header (accept_language )
558+ if not parsed :
559+ return None
560+
561+ # Get the first (highest priority) language
562+ # Format: [(lang, priority), ...] sorted by priority
563+ return parsed [0 ][0 ].lower () if parsed else None
564+
565+ def test_australian_browser_auto_detect (self ):
566+ """Test that Australian browser (en-AU) gets Australia first."""
567+ with self .settings (COUNTRIES_FIRST_AUTO_DETECT = True ):
568+ # Simulate browser sending Accept-Language: en-AU
569+ language = self ._parse_accept_language ("en-AU" )
570+ self .assertEqual (language , "en-au" )
571+
572+ # Activate the language like Django's LocaleMiddleware would
573+ with translation .override (language ):
574+ country_list = list (countries )
575+
576+ # Verify AU is first
577+ self .assertEqual (country_list [0 ].code , "AU" )
578+ # Second should be alphabetical
579+ self .assertEqual (country_list [1 ].code , "AF" )
580+
581+ def test_canadian_french_browser_with_language_mapping (self ):
582+ """Test French-Canadian browser with language-based first countries."""
583+ with self .settings (
584+ COUNTRIES_FIRST_AUTO_DETECT = True ,
585+ COUNTRIES_FIRST_BY_LANGUAGE = {
586+ "fr" : ["FR" , "CH" , "BE" , "LU" ],
587+ },
588+ ):
589+ # Simulate browser sending Accept-Language: fr-CA
590+ language = self ._parse_accept_language ("fr-CA" )
591+ self .assertEqual (language , "fr-ca" )
592+
593+ # Activate the language like Django's LocaleMiddleware would
594+ with translation .override (language ):
595+ country_list = list (countries )
596+
597+ # Should get CA prepended to French group
598+ self .assertEqual (country_list [0 ].code , "CA" )
599+ self .assertEqual (country_list [1 ].code , "FR" )
600+ self .assertEqual (country_list [2 ].code , "CH" )
601+ self .assertEqual (country_list [3 ].code , "BE" )
602+
603+ def test_multiple_language_preferences (self ):
604+ """Test browser with multiple language preferences in Accept-Language."""
605+ with self .settings (
606+ COUNTRIES_FIRST_AUTO_DETECT = True ,
607+ COUNTRIES_FIRST = ["US" , "GB" ],
608+ ):
609+ # Browser prefers Spanish-Mexican, then English
610+ # Accept-Language: es-MX,es;q=0.9,en;q=0.8
611+ language = self ._parse_accept_language ("es-MX,es;q=0.9,en;q=0.8" )
612+
613+ # Django should pick es-mx as the highest priority
614+ self .assertEqual (language , "es-mx" )
615+
616+ # Activate the language like Django's LocaleMiddleware would
617+ with translation .override (language ):
618+ country_list = list (countries )
619+
620+ # Should get MX prepended to COUNTRIES_FIRST
621+ self .assertEqual (country_list [0 ].code , "MX" )
622+ self .assertEqual (country_list [1 ].code , "US" )
623+ self .assertEqual (country_list [2 ].code , "GB" )
624+
625+ def test_german_swiss_browser (self ):
626+ """Test German-Swiss browser (de-CH) with language mapping."""
627+ with self .settings (
628+ COUNTRIES_FIRST_AUTO_DETECT = True ,
629+ COUNTRIES_FIRST_BY_LANGUAGE = {
630+ "de" : ["DE" , "AT" , "CH" , "LI" ],
631+ },
632+ ):
633+ # Simulate browser sending Accept-Language: de-CH
634+ language = self ._parse_accept_language ("de-CH" )
635+ self .assertEqual (language , "de-ch" )
636+
637+ # Activate the language like Django's LocaleMiddleware would
638+ with translation .override (language ):
639+ country_list = list (countries )
640+
641+ # CH should move to front (deduplication)
642+ self .assertEqual (country_list [0 ].code , "CH" )
643+ self .assertEqual (country_list [1 ].code , "DE" )
644+ self .assertEqual (country_list [2 ].code , "AT" )
645+ self .assertEqual (country_list [3 ].code , "LI" )
646+ # Make sure CH only appears once
647+ codes = [c .code for c in country_list [:10 ]]
648+ self .assertEqual (codes .count ("CH" ), 1 )
649+
650+ def test_base_language_without_country (self ):
651+ """Test browser sending only base language (no country code)."""
652+ with self .settings (
653+ COUNTRIES_FIRST_AUTO_DETECT = True ,
654+ COUNTRIES_FIRST = ["US" , "GB" ],
655+ ):
656+ # Simulate browser sending Accept-Language: en (no country)
657+ language = self ._parse_accept_language ("en" )
658+
659+ # Language detected as just 'en'
660+ self .assertIn (language , ["en" , "en-us" ]) # Depends on Django version
661+
662+ # Activate the language like Django's LocaleMiddleware would
663+ with translation .override (language ):
664+ country_list = list (countries )
665+
666+ # No country to detect, should fall back to COUNTRIES_FIRST
667+ self .assertEqual (country_list [0 ].code , "US" )
668+ self .assertEqual (country_list [1 ].code , "GB" )
669+
670+ def test_unsupported_language_falls_back (self ):
671+ """Test browser with unsupported language falls back to default."""
672+ with self .settings (
673+ COUNTRIES_FIRST_AUTO_DETECT = True ,
674+ COUNTRIES_FIRST = ["US" ],
675+ LANGUAGE_CODE = "en-us" ,
676+ ):
677+ # Simulate browser sending unsupported language
678+ language = self ._parse_accept_language ("zh-CN" )
679+
680+ # Should fall back to LANGUAGE_CODE since zh is not in LANGUAGES
681+ self .assertIn (language , ["en-us" , "zh-cn" ])
682+
683+ # Activate the language like Django's LocaleMiddleware would
684+ with translation .override (language ):
685+ country_list = list (countries )
686+
687+ # If zh-cn is supported, CN should be first; otherwise US
688+ if language == "zh-cn" :
689+ self .assertEqual (country_list [0 ].code , "CN" )
690+ else :
691+ self .assertEqual (country_list [0 ].code , "US" )
692+
693+
694+ # Define a view for middleware tests
695+ def country_view_for_tests (request ):
696+ """
697+ Simple view that returns the country list and current language.
698+
699+ This simulates a real view where a form with CountryField is rendered.
700+ """
701+ from django .http import JsonResponse
702+
703+ country_list = list (countries )
704+ return JsonResponse (
705+ {
706+ "language" : translation .get_language (),
707+ "countries" : [{"code" : c .code , "name" : c .name } for c in country_list [:5 ]],
708+ }
709+ )
710+
711+
712+ # URL patterns for middleware tests
713+ test_urlpatterns = [
714+ __import__ ("django.urls" ).urls .path ("countries/" , country_view_for_tests ),
715+ ]
716+
717+
718+ @pytest .mark .skipif (not settings .USE_I18N , reason = "No i18n" )
719+ class TestBrowserLanguageViaMiddleware (BaseTest ):
720+ """
721+ Test auto-detect through actual Django middleware and views.
722+
723+ These tests make HTTP requests through Django's test client, simulating
724+ how browsers send Accept-Language headers in production. The requests
725+ are processed through LocaleMiddleware, which activates the language.
726+ """
727+
728+ def test_australian_browser_via_middleware (self ):
729+ """
730+ Test that Australian browser (Accept-Language: en-AU) gets AU first.
731+
732+ This simulates a real user from Australia visiting a form with a
733+ CountryField. Their browser sends 'en-AU' which Django's LocaleMiddleware
734+ detects, and COUNTRIES_FIRST_AUTO_DETECT uses to show Australia first.
735+
736+ Note: Django's default LANGUAGES setting includes 'en-au', so we don't
737+ need to explicitly configure it.
738+ """
739+ with self .settings (
740+ COUNTRIES_FIRST_AUTO_DETECT = True ,
741+ MIDDLEWARE = [
742+ "django.middleware.common.CommonMiddleware" ,
743+ "django.middleware.locale.LocaleMiddleware" ,
744+ ],
745+ ROOT_URLCONF = "django_countries.tests.test_dynamic_first" ,
746+ ):
747+ response = self .client .get ("/countries/" , HTTP_ACCEPT_LANGUAGE = "en-AU" )
748+
749+ self .assertEqual (response .status_code , 200 )
750+ data = response .json ()
751+
752+ # LocaleMiddleware should have activated en-au
753+ self .assertEqual (data ["language" ], "en-au" )
754+
755+ # AU should be first in the list
756+ self .assertEqual (data ["countries" ][0 ]["code" ], "AU" )
757+
758+ def test_french_canadian_with_language_mapping (self ):
759+ """
760+ Test French-Canadian browser with language-based country mapping.
761+
762+ A user from Quebec has their browser set to 'fr-CA'. Since Django's
763+ default LANGUAGES doesn't include 'fr-ca', LocaleMiddleware falls back
764+ to 'fr'. To get full auto-detect functionality, add specific locales
765+ to LANGUAGES. This test demonstrates the feature WITH configuration.
766+ """
767+ with self .settings (
768+ COUNTRIES_FIRST_AUTO_DETECT = True ,
769+ COUNTRIES_FIRST_BY_LANGUAGE = {
770+ "fr" : ["FR" , "CH" , "BE" , "LU" ],
771+ },
772+ MIDDLEWARE = [
773+ "django.middleware.common.CommonMiddleware" ,
774+ "django.middleware.locale.LocaleMiddleware" ,
775+ ],
776+ # Add fr-ca so LocaleMiddleware doesn't fall back to fr
777+ LANGUAGES = [
778+ ("en" , "English" ),
779+ ("fr" , "French" ),
780+ ("fr-ca" , "French (Canada)" ),
781+ ],
782+ ROOT_URLCONF = "django_countries.tests.test_dynamic_first" ,
783+ ):
784+ response = self .client .get ("/countries/" , HTTP_ACCEPT_LANGUAGE = "fr-CA" )
785+
786+ self .assertEqual (response .status_code , 200 )
787+ data = response .json ()
788+
789+ # Should detect fr-ca (with explicit LANGUAGES configuration)
790+ self .assertEqual (data ["language" ], "fr-ca" )
791+
792+ # Should see CA prepended to French group
793+ self .assertEqual (data ["countries" ][0 ]["code" ], "CA" )
794+ self .assertEqual (data ["countries" ][1 ]["code" ], "FR" )
795+ self .assertEqual (data ["countries" ][2 ]["code" ], "CH" )
796+
797+ def test_complex_accept_language_header (self ):
798+ """
799+ Test browser with multiple languages and quality values.
800+
801+ Modern browsers send complex Accept-Language headers like:
802+ 'es-MX,es;q=0.9,en-US;q=0.8,en;q=0.7'
803+
804+ LocaleMiddleware parses quality values and picks the highest priority
805+ language, which should be es-MX, showing MX first in the country list.
806+
807+ Note: Django's default LANGUAGES includes 'es-mx'.
808+ """
809+ with self .settings (
810+ COUNTRIES_FIRST_AUTO_DETECT = True ,
811+ COUNTRIES_FIRST = ["US" , "GB" ],
812+ MIDDLEWARE = [
813+ "django.middleware.common.CommonMiddleware" ,
814+ "django.middleware.locale.LocaleMiddleware" ,
815+ ],
816+ ROOT_URLCONF = "django_countries.tests.test_dynamic_first" ,
817+ ):
818+ response = self .client .get (
819+ "/countries/" ,
820+ HTTP_ACCEPT_LANGUAGE = "es-MX,es;q=0.9,en-US;q=0.8,en;q=0.7" ,
821+ )
822+
823+ self .assertEqual (response .status_code , 200 )
824+ data = response .json ()
825+
826+ # Should pick es-mx (highest priority)
827+ self .assertEqual (data ["language" ], "es-mx" )
828+
829+ # MX should be prepended to COUNTRIES_FIRST
830+ self .assertEqual (data ["countries" ][0 ]["code" ], "MX" )
831+ self .assertEqual (data ["countries" ][1 ]["code" ], "US" )
832+ self .assertEqual (data ["countries" ][2 ]["code" ], "GB" )
833+
834+
835+ urlpatterns = test_urlpatterns # Make urlpatterns available at module level
0 commit comments