CGI-Lingua
view release on metacpan or search on metacpan
t/locales.t view on Meta::CPAN
#!/usr/bin/env perl
# t/locales.t â CGI::Lingua locale coverage
# 1. Geographic (GeoIP): GB, US, FR, DE, CN â case, concurrency, caching
# 2. POSIX system locale: en_US.UTF-8, de_DE.UTF-8, ja_JP.UTF-8
use strict;
use warnings;
use POSIX qw(ENOENT);
use Test::Most;
use Test::Needs qw(CHI IP::Country);
use Test::Mockingbird;
use lib 't/lib';
BEGIN { use_ok('CGI::Lingua') }
# ââ Sanity: the GeoIP mock must be operational ââââââââââââââââââââââââââââ
# BAIL_OUT immediately if IP::Country::Fast isn't accessible â all
# geographic subtests below depend on it.
subtest 'GeoIP sanity' => sub {
my $probed = 0;
Test::Mockingbird::mock('IP::Country::Fast', 'inet_atocc', sub {
$probed = 1;
return 'GB';
});
local %ENV = (
REMOTE_ADDR => '1.2.3.4',
HTTP_ACCEPT_LANGUAGE => 'en-gb',
);
my $l = CGI::Lingua->new(supported => ['en-gb']);
$l->country(); # triggers the mock
unless($probed) {
Test::Mockingbird::restore_all();
BAIL_OUT('IP::Country::Fast mock is not functioning â GeoIP tests cannot run');
}
Test::Mockingbird::restore_all();
pass('GeoIP mock is operational');
};
# ââ Geographic subtests âââââââââââââââââââââââââââââââââââââââââââââââââââ
# Country-to-language table used by the geographic tests.
# Each entry: [ ip, mock_cc, accept_lang, supported, expected_language, expected_country ]
my @GEO_CASES = (
[ '1.2.3.4', 'GB', 'en-gb', ['en', 'en-gb'], 'English', 'gb' ],
[ '8.8.8.8', 'US', 'en-us', ['en', 'en-us'], 'English', 'us' ],
[ '90.0.0.1', 'FR', 'fr', ['fr', 'en'], 'French', 'fr' ],
[ '80.0.0.1', 'DE', 'de', ['de', 'en'], 'German', 'de' ],
[ '1.180.0.1', 'CN', 'zh-cn', ['zh', 'en'], 'Chinese', 'cn' ],
);
my $cache = CHI->new(driver => 'Memory', global => 0);
for my $case (@GEO_CASES) {
my ($ip, $cc, $lang, $supported, $expected_lang, $expected_country) = @{$case};
subtest "GeoIP: $cc ($expected_lang)" => sub {
Test::Mockingbird::mock('IP::Country::Fast', 'inet_atocc', sub { $cc });
# Clear LANG so _what_language() doesn't fall through to the system locale
# path and produce a different language than the HTTP header specifies.
local %ENV = (
REMOTE_ADDR => $ip,
HTTP_ACCEPT_LANGUAGE => $lang,
);
delete $ENV{LANG};
my $l = CGI::Lingua->new(supported => $supported, cache => $cache);
is($l->country(), $expected_country, "country() returns '$expected_country' for $cc");
is($l->language(), $expected_lang, "language() returns '$expected_lang' for $cc");
Test::Mockingbird::restore_all();
};
}
# Case-insensitivity: uppercase Accept-Language should match the same way
subtest 'Case insensitivity â EN-GB' => sub {
Test::Mockingbird::mock('IP::Country::Fast', 'inet_atocc', sub { 'GB' });
local %ENV = (
REMOTE_ADDR => '1.2.3.4',
HTTP_ACCEPT_LANGUAGE => 'EN-GB', # uppercase
);
delete $ENV{LANG};
my $l = CGI::Lingua->new(supported => ['en-gb']);
is($l->sublanguage_code_alpha2(), 'gb', 'Uppercase Accept-Language handled correctly');
Test::Mockingbird::restore_all();
};
# Concurrent instances must not share state through the cache or globals.
# country() reads $ENV{REMOTE_ADDR} lazily at call time, so we must keep the
# correct REMOTE_ADDR in scope when calling country() on each object. Both
# objects are kept alive simultaneously (declared in the outer scope) to verify
# true concurrent isolation â neither touches the other's _country field.
subtest 'Concurrent instances do not share state' => sub {
Test::Mockingbird::mock('IP::Country::Fast', 'inet_atocc', sub {
my ($self_mock, $ip) = @_;
return $ip =~ /^8\.8/ ? 'US' : 'FR';
});
local %ENV = (HTTP_ACCEPT_LANGUAGE => 'en');
# Create and immediately resolve each object while its REMOTE_ADDR is live.
# $us and $fr are declared in the outer scope so both remain alive together.
my ($us, $us_cc);
{ local $ENV{REMOTE_ADDR} = '8.8.8.8';
$us = CGI::Lingua->new(supported => ['en', 'fr']);
$us_cc = $us->country(); }
my ($fr, $fr_cc);
{ local $ENV{REMOTE_ADDR} = '90.0.0.1';
$fr = CGI::Lingua->new(supported => ['en', 'fr']);
$fr_cc = $fr->country(); }
# Both objects are live here â verify they hold independent state.
is($us_cc, 'us', 'US IP resolves to us');
is($fr_cc, 'fr', 'FR IP resolves to fr');
isnt($us_cc, $fr_cc, 'Different IPs resolve to different countries');
Test::Mockingbird::restore_all();
};
# Cache: a second lookup for the same IP should hit the cache
subtest 'Country result is cached between instances' => sub {
my $call_count = 0;
Test::Mockingbird::mock('IP::Country::Fast', 'inet_atocc', sub {
$call_count++;
return 'US';
});
my $shared_cache = CHI->new(driver => 'Memory', global => 0);
local %ENV = (
REMOTE_ADDR => '4.4.4.4',
HTTP_ACCEPT_LANGUAGE => 'en',
);
my $first = CGI::Lingua->new(supported => ['en'], cache => $shared_cache);
$first->country();
my $first_calls = $call_count;
# Second instance with the same IP and cache â should not call inet_atocc again
my $second = CGI::Lingua->new(supported => ['en'], cache => $shared_cache);
$second->country();
is($call_count, $first_calls, 'inet_atocc not called again for cached IP');
Test::Mockingbird::restore_all();
};
# ââ POSIX system locale subtests ââââââââââââââââââââââââââââââââââââââââââ
# Test that CGI::Lingua returns consistent results regardless of LC_ALL.
# We deliberately do NOT use POSIX::strerror() â we source error strings
# directly from Perl's errno layer to avoid C-library divergence.
my @POSIX_LOCALES = (
'en_US.UTF-8',
'de_DE.UTF-8',
'ja_JP.UTF-8',
);
subtest 'POSIX locale independence' => sub {
for my $locale (@POSIX_LOCALES) {
subtest "Locale $locale" => sub {
local $ENV{LC_ALL} = $locale;
local $ENV{LANG} = $locale;
local $ENV{LC_CTYPE} = $locale;
delete $ENV{HTTP_ACCEPT_LANGUAGE};
delete $ENV{REMOTE_ADDR};
# Core language detection must be unaffected by system locale
local $ENV{HTTP_ACCEPT_LANGUAGE} = 'fr';
my $l = CGI::Lingua->new(supported => ['fr', 'en']);
is($l->language(), 'French', "language() returns 'French' under $locale");
# Error path: verify that the ENOENT message can be obtained
# from Perl's errno layer (not from POSIX::strerror) under all locales
local $! = ENOENT;
my $enoent_msg = "$!";
ok(length($enoent_msg) > 0, "ENOENT message is non-empty under $locale: $enoent_msg");
# Simulate a missing /etc/timezone by ensuring time_zone()
# can survive the absence gracefully (if REMOTE_ADDR is unset,
# it tries to read /etc/timezone or fall back to DateTime)
# We only verify it doesn't die; actual value depends on the host.
my $tz;
eval { $tz = $l->time_zone() };
ok(!$@, "time_zone() does not die under $locale (err: $@)");
};
}
};
# Verify that language names returned by CGI::Lingua are consistent
# regardless of system locale â they come from Locale::Language (not libc)
subtest 'Language names are locale-independent' => sub {
my %expected = (
en => 'English',
fr => 'French',
de => 'German',
ja => 'Japanese',
);
for my $locale (@POSIX_LOCALES) {
local $ENV{LC_ALL} = $locale;
local $ENV{LANG} = $locale;
for my $code (sort keys %expected) {
local $ENV{HTTP_ACCEPT_LANGUAGE} = $code;
delete $ENV{REMOTE_ADDR};
my $l = CGI::Lingua->new(supported => [$code]);
is(
$l->language(), $expected{$code},
"'$code' â '$expected{$code}' under $locale"
);
}
}
};
# ââ LANG env-var fallback ââââââââââââââââââââââââââââââââââââââââââââââââââââ
# When there is no HTTP_ACCEPT_LANGUAGE and no REMOTE_ADDR (e.g. running from
# the command line), _what_language() falls back to $ENV{LANG}. Verify that
# a full POSIX locale string like "de_DE.UTF-8" is accepted (not rejected by
# the untainting regex) and that it produces a sensible language result.
subtest 'LANG env-var fallback: POSIX locale form is accepted and used' => sub {
local %ENV = ();
delete $ENV{HTTP_ACCEPT_LANGUAGE};
delete $ENV{REMOTE_ADDR};
$ENV{LANG} = 'de_DE.UTF-8';
# 'de' is the only supported language â language() must return German
# by detecting 'de' from the LANG string even without an HTTP header.
my $l = CGI::Lingua->new(supported => ['de', 'en']);
my $lang = $l->language();
# We can't guarantee a match because _what_language returns the raw LANG
# string 'de_DE.UTF-8', and _find_language passes it to I18N::AcceptLanguage
# which may or may not parse the POSIX form. What we DO guarantee:
# - language() does not die
# - the LANG string was not rejected by the untainting regex (a rejection
# would return undef from _what_language, making language() return Unknown)
ok(defined $lang, 'language() does not die when LANG is a POSIX locale string');
diag("LANG=de_DE.UTF-8 â language()='$lang'") if $ENV{TEST_VERBOSE};
};
# ââ Croak message locale independence ââââââââââââââââââââââââââââââââââââââââ
# CGI::Lingua's own Carp::croak messages must be in English regardless of the
# system locale. They are hardcoded string literals; this test catches any
# future regression where a message is accidentally sourced from libc/iconv.
subtest 'CGI::Lingua error messages are locale-independent' => sub {
for my $locale (@POSIX_LOCALES) {
subtest "Croak text under $locale" => sub {
local $ENV{LC_ALL} = $locale;
local $ENV{LANG} = $locale;
delete $ENV{HTTP_ACCEPT_LANGUAGE};
delete $ENV{REMOTE_ADDR};
my $err;
eval { CGI::Lingua->new(supported => undef) };
$err = $@;
like($err, qr/supported languages/i,
"'supported languages' message is in English under $locale");
};
}
};
done_testing();
( run in 0.747 second using v1.01-cache-2.11-cpan-800906f7e73 )