CGI-Lingua
view release on metacpan or search on metacpan
t/integration.t view on Meta::CPAN
#!/usr/bin/env perl
# t/integration.t -- CGI::Lingua end-to-end integration tests.
#
# These subtests focus on stateful workflows and cross-method coherence rather
# than testing individual methods in isolation (which t/unit.t covers).
#
# Network I/O (Whois, geoplugin, ip-api.com) is blocked globally; individual
# subtests install narrowly-scoped mocks for specific responses as needed.
#
# IP::Country is excluded via Test::Without::Module so CGI::Lingua's lazy-
# require guard naturally sets _have_ipcountry = GEO_ABSENT throughout this
# file. Subtests that exercise the "IP::Country present" code path inject the
# sentinel and mock directly after construction, bypassing the guard.
# Geo::IP and Geo::IPfree are NOT globally excluded â Section 9 tests both the
# "present" path (via _inject_geoip / _inject_geoipfree) and the "absent" path
# (by setting the sentinels to GEO_ABSENT explicitly on the object).
use strict;
use warnings;
use CHI;
use Readonly;
use Scalar::Util qw(blessed);
use Test::Most;
use Test::Mockingbird;
use Test::Returns qw(returns_ok);
use Test::Without::Module qw(IP::Country);
use lib 't/lib';
BEGIN { use_ok('CGI::Lingua') }
# Pre-require lazy-loaded network modules before mocking them.
# A module's BEGIN block runs on first require and would clobber any mock
# installed before that point. We load both unconditionally so the symbol
# table entries are stable before any mocks are installed.
my $HAS_LWP = eval { require LWP::Simple::WithCache; 1 } ? 1 : 0;
my $HAS_JSON = eval { require JSON::Parse; 1 } ? 1 : 0;
# ââ Shared constants ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Readonly my %LANG => (
EN => 'en',
EN_GB => 'en-gb',
EN_US => 'en-us',
FR => 'fr',
DE => 'de',
ZH => 'zh',
);
Readonly my %IP => (
PUBLIC => '8.8.8.8',
PRIVATE => '192.168.1.1',
LOOPBACK => '127.0.0.1',
GB => '1.2.3.4',
FR => '90.0.0.1',
US => '4.4.4.4',
);
# Canned JSON bodies returned by mocked geoplugin / ip-api calls.
Readonly my $GEO_JSON_US => '{"geoplugin_countryCode":"US"}';
Readonly my $GEO_JSON_GB => '{"geoplugin_countryCode":"GB"}';
Readonly my $TZ_JSON_GB => '{"timezone":"Europe/London"}';
Readonly my $TZ_JSON_US => '{"timezone":"America/New_York"}';
# ââ Global network block ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Installed once at the start; reinstalled after any restore_all() call.
# This ensures no test ever makes a real network round-trip.
_block_network();
# ââ Shared helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
sub _block_network {
Test::Mockingbird::mock('CGI::Lingua', '_resolve_country_via_whois', sub { });
Test::Mockingbird::mock('LWP::Simple::WithCache', 'get', sub { undef })
if $HAS_LWP;
}
sub _obj {
my ($supported, %extra) = @_;
CGI::Lingua->new(supported => $supported, %extra);
}
# Simulate "IP::Country present" for an already-constructed object by injecting
# the sentinel flags and a mock that returns the given country code.
# Because IP::Country is blocked by Test::Without::Module, the lazy-require
# guard always sets _have_ipcountry = GEO_ABSENT; this helper overrides that.
sub _inject_ipcountry {
my ($l, $cc) = @_;
Test::Mockingbird::mock('IP::Country::Fast', 'inet_atocc', sub { $cc });
$l->{_have_ipcountry} = 1; # GEO_PRESENT
$l->{_ipcountry} = bless {}, 'IP::Country::Fast';
$l->{_have_geoip} = 0; # GEO_ABSENT
$l->{_have_geoipfree} = 0; # GEO_ABSENT
}
# Simulate "Geo::IP present" â bypasses the lazy-require + db-file guard in
# _load_geoip() by injecting sentinels directly after construction.
sub _inject_geoip {
my ($l, $cc) = @_;
eval { require Geo::IP }; # pre-require so mock is not overwritten on first load
Test::Mockingbird::mock('Geo::IP', 'country_code_by_addr', sub { $cc });
$l->{_have_ipcountry} = 0; # GEO_ABSENT
$l->{_have_geoip} = 1; # GEO_PRESENT
$l->{_geoip} = bless {}, 'Geo::IP';
$l->{_have_geoipfree} = 0; # GEO_ABSENT
}
# Simulate "Geo::IPfree present" â bypasses the lazy-require guard.
sub _inject_geoipfree {
my ($l, $cc) = @_;
eval { require Geo::IPfree }; # pre-require so mock is not overwritten on first load
# LookUp returns a list; the module takes element [0] as the country code.
Test::Mockingbird::mock('Geo::IPfree', 'LookUp', sub { return ($cc) });
$l->{_have_ipcountry} = 0; # GEO_ABSENT
$l->{_have_geoip} = 0; # GEO_ABSENT
$l->{_have_geoipfree} = 1; # GEO_PRESENT
$l->{_geoipfree} = bless {}, 'Geo::IPfree';
}
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# SECTION 1: Full detection-pipeline coherence
#
# Strategy: verify that all language-related accessors return a mutually
# consistent picture when constructed from a single Accept-Language header.
# Each subtest creates one object and checks every public method, exercising
# the entire pipeline from header â I18N::AcceptLanguage â language/sub-lang.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'pipeline coherence: en-gb produces consistent results across all accessors' => sub {
local %ENV = (HTTP_ACCEPT_LANGUAGE => $LANG{EN_GB});
my $l = _obj([$LANG{EN_GB}]);
is($l->language(), 'English', 'language()');
is($l->preferred_language(), 'English', 'preferred_language()');
is($l->name(), 'English', 'name()');
is($l->sublanguage(), 'United Kingdom', 'sublanguage()');
is($l->language_code_alpha2(), $LANG{EN}, 'language_code_alpha2()');
is($l->code_alpha2(), $LANG{EN}, 'code_alpha2()');
is($l->sublanguage_code_alpha2(), 'gb', 'sublanguage_code_alpha2()');
like($l->requested_language(), qr/^English\s+\(United Kingdom\)$/,
'requested_language() matches "Language (Sublanguage)" format');
returns_ok($l->language(), { type => 'string' }, 'language() returns a string');
returns_ok($l->requested_language(), { type => 'string' }, 'requested_language() returns a string');
t/integration.t view on Meta::CPAN
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# SECTION 7: Clone workflow
#
# Strategy: verify that cloning (calling new() on an existing object) produces
# an independent object that respects the new supported-languages parameter.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'clone: new supported list is respected and state is independent' => sub {
local %ENV = (HTTP_ACCEPT_LANGUAGE => $LANG{FR});
my $orig = _obj([$LANG{EN}, $LANG{FR}]);
my $clone = $orig->new(supported => [$LANG{DE}]);
isa_ok($clone, 'CGI::Lingua', 'Clone is a CGI::Lingua object');
isnt($orig, $clone, 'Clone is a distinct reference');
# Clone's supported list is de-only â fr header should yield Unknown
is($clone->language(), 'Unknown',
'Clone with de-only supported returns Unknown for fr header');
# Populating the clone must not affect the original
is($orig->language(), 'French',
'Original object unaffected by clone language computation');
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# SECTION 8: Network call verification via spies
#
# Strategy: use Test::Mockingbird::spy() to intercept calls to external
# resolution routines and verify they are (or are not) invoked depending
# on which faster lookup path succeeds first.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'spy: _resolve_country_via_whois NOT called when IP::Country is present' => sub {
local %ENV = (REMOTE_ADDR => $IP{PUBLIC}, HTTP_ACCEPT_LANGUAGE => $LANG{EN});
my $l = _obj([$LANG{EN}]);
_inject_ipcountry($l, 'US');
# Spy wraps the existing no-op mock and records every call.
my $whois_spy = Test::Mockingbird::spy('CGI::Lingua', '_resolve_country_via_whois');
$l->country();
my @calls = $whois_spy->();
is(scalar @calls, 0,
'_resolve_country_via_whois never called when IP::Country returns a result');
{ local $SIG{__WARN__} = sub {}; Test::Mockingbird::restore_all() }
_block_network();
};
subtest 'spy: LWP::Simple::WithCache::get called when IP::Country is absent (geoplugin fallback)' => sub {
SKIP: {
skip 'LWP::Simple::WithCache or JSON::Parse not installed', 1
unless $HAS_LWP && $HAS_JSON;
local %ENV = (REMOTE_ADDR => $IP{PUBLIC});
# IP::Country is blocked by Test::Without::Module â the sentinel will be
# set to GEO_ABSENT by CGI::Lingua's eval{require} guard.
# Inject GEO_ABSENT explicitly in case an earlier test left the sentinel set.
my $l = _obj([$LANG{EN}]);
$l->{_have_ipcountry} = 0; # GEO_ABSENT
$l->{_have_geoip} = 0; # GEO_ABSENT
$l->{_have_geoipfree} = 0; # GEO_ABSENT
# Spy on the mocked LWP::Simple::WithCache::get (which currently returns undef).
# Suppress the prototype mismatch warning that fires because WithCache
# declares get($) but the spy installs a prototype-free wrapper.
my $lwp_spy;
{ local $SIG{__WARN__} = sub {};
$lwp_spy = Test::Mockingbird::spy('LWP::Simple::WithCache', 'get') }
$l->country();
my @calls = $lwp_spy->();
ok(scalar @calls > 0,
'LWP::Simple::WithCache::get called at least once for geoplugin fallback');
diag('LWP call args: ' . join(', ', map { $_->[1] // '(undef)' } @calls))
if $ENV{TEST_VERBOSE};
{ local $SIG{__WARN__} = sub {}; Test::Mockingbird::restore_all() }
_block_network();
}
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# SECTION 9: Optional dependency degradation
#
# IP::Country is blocked file-wide via Test::Without::Module. For Geo::IP and
# Geo::IPfree the sentinel-injection helpers (_inject_geoip, _inject_geoipfree)
# cover the "present" path; explicit GEO_ABSENT injection covers the "absent"
# path. Together these four subtests walk the full fallback chain:
# IP::Country â Geo::IP â Geo::IPfree â geoplugin â Whois
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'optional: IP::Country absent â country() falls through to geoplugin JSON' => sub {
SKIP: {
skip 'LWP::Simple::WithCache or JSON::Parse not installed', 1
unless $HAS_LWP && $HAS_JSON;
local %ENV = (REMOTE_ADDR => $IP{PUBLIC});
my $l = _obj([$LANG{EN}]);
$l->{_have_ipcountry} = 0; # GEO_ABSENT (confirmed by blocked module)
$l->{_have_geoip} = 0; # GEO_ABSENT
$l->{_have_geoipfree} = 0; # GEO_ABSENT
# Override the global no-op LWP mock to return a real-looking JSON body.
Test::Mockingbird::mock('LWP::Simple::WithCache', 'get',
sub { $GEO_JSON_US });
my $cc = $l->country();
is($cc, 'us',
'country() returns US from geoplugin JSON when IP::Country is absent');
{ local $SIG{__WARN__} = sub {}; Test::Mockingbird::restore_all() }
_block_network();
}
};
subtest 'optional: Geo::IP resolves country when IP::Country absent' => sub {
# Strategy: inject Geo::IP as the active resolver (IP::Country is blocked
# file-wide). Verifies the IP::Country â Geo::IP fallback step.
local %ENV = (REMOTE_ADDR => $IP{US});
my $l = _obj([$LANG{EN}]);
_inject_geoip($l, 'US');
is($l->country(), 'us', 'country() returns us via Geo::IP when IP::Country absent');
{ local $SIG{__WARN__} = sub {}; Test::Mockingbird::restore_all() }
_block_network();
};
subtest 'optional: Geo::IPfree resolves country when Geo::IP also absent' => sub {
# Strategy: inject Geo::IPfree with Geo::IP explicitly absent.
# Verifies the Geo::IP â Geo::IPfree fallback step.
local %ENV = (REMOTE_ADDR => $IP{GB});
my $l = _obj([$LANG{EN}]);
_inject_geoipfree($l, 'GB');
is($l->country(), 'gb', 'country() returns gb via Geo::IPfree when Geo::IP absent');
{ local $SIG{__WARN__} = sub {}; Test::Mockingbird::restore_all() }
_block_network();
};
subtest 'optional: IP::Country absent + geoplugin fails â Whois is attempted' => sub {
local %ENV = (REMOTE_ADDR => $IP{PUBLIC});
my $l = _obj([$LANG{EN}]);
$l->{_have_ipcountry} = 0; # GEO_ABSENT
$l->{_have_geoip} = 0; # GEO_ABSENT
$l->{_have_geoipfree} = 0; # GEO_ABSENT
# Whois call is globally mocked to a no-op; spy on it to verify it fires.
my $whois_spy = Test::Mockingbird::spy('CGI::Lingua', '_resolve_country_via_whois');
# LWP returns undef (global mock) â geoplugin fails, so Whois must be tried.
$l->country();
my @calls = $whois_spy->();
ok(scalar @calls > 0,
'_resolve_country_via_whois attempted when IP::Country and geoplugin both fail');
{ local $SIG{__WARN__} = sub {}; Test::Mockingbird::restore_all() }
_block_network();
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# SECTION 10: GEOIP_COUNTRY_CODE coherence across country() and locale()
#
# Strategy: when the mod_geoip environment variable is set, country() and
# locale() must both derive from the same underlying code.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'GEOIP_COUNTRY_CODE: country() and locale() agree on the same country' => sub {
local %ENV = (GEOIP_COUNTRY_CODE => 'GB');
my $l = _obj([$LANG{EN}]);
( run in 1.287 second using v1.01-cache-2.11-cpan-800906f7e73 )