Config-Abstraction
view release on metacpan or search on metacpan
is($cfg->retries(), $EXPECTED_RETRIES, 'AUTOLOAD top-level key with default sep_char');
delete $ledger{'AUTOLOAD: default sep_char direct key access'};
};
# ===========================================================================
# Environment variable overrides
# POD: APP_DATABASE__USER becomes database.user
# ===========================================================================
subtest 'ENV double-underscore creates nested key' => sub {
# POD: APP_DATABASE__USER becomes database.user (nested structure)
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_user';
my $cfg = Config::Abstraction->new(
data => {
database => { user => $EXPECTED_USER, pass => $EXPECTED_PASS },
},
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('database.user'), 'env_user', 'ENV double-underscore overrides nested key');
delete $ledger{'ENV: double-underscore creates nested key'};
};
subtest 'ENV single segment stored under prefix namespace' => sub {
# POD: APP_LOGLEVEL becomes APP.loglevel (flat under prefix namespace)
local %ENV = %ENV;
(my $prefix_bare = $ENV_PREFIX) =~ s/_$//; # strip trailing underscore
$ENV{"${ENV_PREFIX}RETRIES"} = '99';
my $cfg = Config::Abstraction->new(
data => {
$prefix_bare => { retries => $EXPECTED_RETRIES },
retries => $EXPECTED_RETRIES,
},
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get("${prefix_bare}.retries"), '99', 'single-segment ENV stored under prefix namespace');
delete $ledger{'ENV: single segment stored under prefix namespace'};
};
subtest 'ENV mixed double-underscore and single underscore in key' => sub {
# POD: APP_API__RATE_LIMIT becomes api.rate_limit
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}API__RATE_LIMIT"} = '100';
my $cfg = Config::Abstraction->new(
data => { api => { rate_limit => 50 } },
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('api.rate_limit'), '100', 'mixed underscore/double-underscore ENV key handled');
delete $ledger{'ENV: mixed underscore in key'};
};
# ===========================================================================
# Command-line argument overrides
# POD: --APP_DATABASE__USER=other_user_name overrides database.user
# ===========================================================================
subtest 'CLI arg overrides top-level key' => sub {
local @ARGV = ("--${ENV_PREFIX}RETRIES=77");
my $cfg = Config::Abstraction->new(
data => _fresh_data(),
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('retries'), '77', 'CLI arg overrides top-level key');
delete $ledger{'CLI: overrides top-level key'};
};
subtest 'CLI double-underscore creates nested key' => sub {
local @ARGV = ("--${ENV_PREFIX}DATABASE__USER=cli_user");
my $cfg = Config::Abstraction->new(
data => {
database => { user => $EXPECTED_USER, pass => $EXPECTED_PASS },
retries => $EXPECTED_RETRIES,
},
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('database.user'), 'cli_user', 'CLI double-underscore creates nested key');
delete $ledger{'CLI: double-underscore creates nested key'};
};
subtest 'CLI arg without matching prefix is ignored' => sub {
local @ARGV = ('--OTHERAPP_RETRIES=999');
my $cfg = Config::Abstraction->new(
data => _fresh_data(),
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('retries'), $EXPECTED_RETRIES, 'non-matching prefix CLI arg ignored');
delete $ledger{'CLI: non-matching prefix ignored'};
};
subtest 'CLI arg without = sign is ignored' => sub {
# The module skips @ARGV entries that contain no '='
local @ARGV = ("--${ENV_PREFIX}RETRIES");
my $cfg = Config::Abstraction->new(
data => _fresh_data(),
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('retries'), $EXPECTED_RETRIES, 'CLI arg without = is ignored');
delete $ledger{'CLI: arg without = ignored'};
};
# ===========================================================================
# Merge precedence
# POD: CLI args > Environment > Config file > Defaults (in-memory data)
# ===========================================================================
subtest 'merge precedence: CLI overrides ENV overrides data' => sub {
local %ENV = %ENV;
local @ARGV = ("--${ENV_PREFIX}DATABASE__USER=cli_user");
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_user';
my $cfg = Config::Abstraction->new(
data => {
database => { user => $EXPECTED_USER, pass => $EXPECTED_PASS },
},
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('database.user'), 'cli_user', 'CLI takes highest precedence');
delete $ledger{'precedence: CLI > ENV > data'};
};
subtest 'merge precedence: ENV overrides data' => sub {
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_user';
my $cfg = Config::Abstraction->new(
data => {
database => { user => $EXPECTED_USER, pass => $EXPECTED_PASS },
},
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('database.user'), 'env_user', 'ENV overrides data defaults');
delete $ledger{'precedence: ENV > data'};
};
subtest 'merge precedence: config file overrides data' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_FILENAME, "retries: 99\n");
my $cfg = Config::Abstraction->new(
data => { retries => $EXPECTED_RETRIES },
config_dirs => [$dir],
);
is($cfg->get('retries'), 99, 'config file overrides data defaults');
delete $ledger{'precedence: file > data'};
};
# ===========================================================================
# Global state integrity
# Methods must not clobber $@, $!, or $_ after object construction
# ===========================================================================
subtest 'global state: get() does not clobber $@' => sub {
my $cfg = _make_cfg();
eval { die 'sentinel_error' };
my $err_before = $@;
$cfg->get('database.user');
is($@, $err_before, 'get() does not clobber $@');
delete $ledger{'global-state: get() does not clobber dollar-at'};
};
subtest 'global state: exists() does not clobber $@' => sub {
my $cfg = _make_cfg();
eval { die 'sentinel_error' };
my $err_before = $@;
$cfg->exists('database.user');
is($@, $err_before, 'exists() does not clobber $@');
delete $ledger{'global-state: exists() does not clobber dollar-at'};
};
my $es = $cfg->explain_sources();
my $src = $es->{'database.user'}{sources};
ok(defined($src), 'sources field defined');
is(ref($src), 'ARRAY', 'sources is an arrayref');
ok(scalar(@{$src}) >= 2, 'at least two sources (data + env)');
is($src->[0]{type}, 'data', 'lowest source is data');
is($src->[-1]{type}, 'env', 'highest (winning) source is env');
delete $ledger{'explain_sources: sources arrayref ordered lowest first'};
};
subtest 'explain_sources() - data source has correct type and label' => sub {
my $cfg = _make_cfg();
my $es = $cfg->explain_sources();
my ($data_src) = grep { $_->{type} eq 'data' } @{$es->{retries}{sources}};
ok(defined($data_src), 'data source entry present');
is($data_src->{type}, 'data', 'type field is "data"');
like($data_src->{label}, qr/constructor data argument/, 'label identifies data source');
is($data_src->{value}, $EXPECTED_RETRIES,'data source value correct');
delete $ledger{'explain_sources: data source has correct type and label'};
};
subtest 'explain_sources() - env source has correct type and label' => sub {
# Use double-underscore so the env var maps directly to the dotted key database.user.
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_alice';
my $cfg = Config::Abstraction->new(
data => { database => { user => $EXPECTED_USER } },
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
my $es = $cfg->explain_sources();
my ($env_src) = grep { $_->{type} eq 'env' } @{$es->{'database.user'}{sources}};
ok(defined($env_src), 'env source entry present');
is($env_src->{type}, 'env', 'type field is "env"');
like($env_src->{label}, qr/DATABASE/, 'label contains the env var name');
is($env_src->{value}, 'env_alice', 'env source value correct');
delete $ledger{'explain_sources: env source has correct type and label'};
};
subtest 'explain_sources() - file source recorded with type "file"' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_FILENAME, "timeout: $EXPECTED_TIMEOUT\n");
my $cfg = Config::Abstraction->new(config_dirs => [$dir]);
my $es = $cfg->explain_sources();
my ($file_src) = grep { $_->{type} eq 'file' } @{$es->{timeout}{sources}};
ok(defined($file_src), 'file source entry present');
is($file_src->{type}, 'file', 'type field is "file"');
diag('file label: ' . ($file_src->{label} // 'undef')) if $ENV{TEST_VERBOSE};
delete $ledger{'explain_sources: file source has correct type'};
};
# ===========================================================================
# prefer_env(key)
# POD: returns env-layer value, or get(key) if no env var contributed
# ===========================================================================
subtest 'prefer_env() - returns env-layer value when env variable contributed' => sub {
# Use double-underscore so the env var maps directly to database.user.
# Also set a CLI arg at higher precedence; prefer_env must bypass it.
local %ENV = %ENV;
local @ARGV = ("--${ENV_PREFIX}DATABASE__USER=cli_user");
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_user';
my $cfg = Config::Abstraction->new(
data => { database => { user => $EXPECTED_USER } },
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
# get() returns cli_user (highest precedence), prefer_env must return env_user.
is($cfg->prefer_env('database.user'), 'env_user',
'prefer_env returns env-layer value bypassing argv');
delete $ledger{'prefer_env: returns env value when env contributed'};
};
subtest 'prefer_env() - falls back to get() when no env variable set the key' => sub {
local %ENV = %ENV;
delete $ENV{"${ENV_PREFIX}DATABASE__USER"};
my $cfg = Config::Abstraction->new(
data => { database => { user => $EXPECTED_USER } },
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->prefer_env('database.user'), $EXPECTED_USER,
'prefer_env falls back to get() when no env var contributed');
delete $ledger{'prefer_env: falls back to get() when env absent'};
};
# ===========================================================================
# prefer_file(key)
# POD: returns file-layer value, or get(key) if no file contributed
# ===========================================================================
subtest 'prefer_file() - returns file-layer value when a file set the key' => sub {
# Set up env and file both contributing; prefer_file must return the file value.
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}TIMEOUT"} = 'env_val';
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_FILENAME, "timeout: $EXPECTED_TIMEOUT\n");
my $cfg = Config::Abstraction->new(
config_dirs => [$dir],
env_prefix => $ENV_PREFIX,
);
is($cfg->prefer_file('timeout'), $EXPECTED_TIMEOUT,
'prefer_file returns file-layer value bypassing env');
delete $ledger{'prefer_file: returns file value when file contributed'};
};
subtest 'prefer_file() - falls back to get() when no file set the key' => sub {
my $cfg = _make_cfg(); # config_dirs => [] means no files loaded
# 'retries' came only from data; prefer_file falls back to get()
is($cfg->prefer_file('retries'), $EXPECTED_RETRIES,
'prefer_file falls back to get() when no file contributed');
delete $ledger{'prefer_file: falls back to get() when no file'};
};
# ===========================================================================
# prefer_data(key)
# POD: returns data-constructor value, or get(key) if data did not set it
# ===========================================================================
subtest 'prefer_data() - returns data-constructor value bypassing env override' => sub {
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}RETRIES"} = '99';
my $cfg = Config::Abstraction->new(
data => { retries => $EXPECTED_RETRIES },
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
# get() returns '99' (env wins), but prefer_data must return the original data value.
is($cfg->prefer_data('retries'), $EXPECTED_RETRIES,
'prefer_data returns data-constructor value despite env override');
delete $ledger{'prefer_data: returns data value when data contributed'};
};
subtest 'prefer_data() - falls back to get() when data did not set the key' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_FILENAME, "fileonly: from_file\n");
my $cfg = Config::Abstraction->new(
data => { other => 'x' },
config_dirs => [$dir],
);
# 'fileonly' was never in the data arg; prefer_data falls back to get()
is($cfg->prefer_data('fileonly'), 'from_file',
'prefer_data falls back to get() when data did not set the key');
delete $ledger{'prefer_data: falls back to get() when data absent'};
};
# ===========================================================================
# prefer_argv(key)
# POD: returns argv-layer value, or get(key) when no CLI arg set the key
# ===========================================================================
subtest 'prefer_argv() - returns argv-layer value when CLI arg contributed' => sub {
local @ARGV = ("--${ENV_PREFIX}RETRIES=argv_val");
my $cfg = Config::Abstraction->new(
data => { retries => $EXPECTED_RETRIES },
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
is($cfg->prefer_argv('retries'), 'argv_val',
'prefer_argv returns the argv-layer value');
delete $ledger{'prefer_argv: returns argv value when argv contributed'};
};
subtest 'prefer_argv() - falls back to get() when no CLI arg set the key' => sub {
local @ARGV = ();
my $cfg = _make_cfg();
is($cfg->prefer_argv('retries'), $EXPECTED_RETRIES,
'prefer_argv falls back to get() when no CLI arg contributed');
delete $ledger{'prefer_argv: falls back to get() when no argv'};
};
# ===========================================================================
# encrypt_value($plaintext)
# POD: requires CryptX; croaks when no key configured; returns ENC[AES256GCM,...] token
# ===========================================================================
Readonly::Scalar my $HEX_KEY => 'a' x 64; # 64 hex chars = 32-byte AES-256 key
subtest 'encrypt_value() - croaks when no encryption key is configured' => sub {
# POD: "<class>: no encryption key configured ..." croak
my $cfg = _make_cfg();
throws_ok { $cfg->encrypt_value('secret') }
qr/no encryption key configured/,
'encrypt_value croaks with no key configured';
delete $ledger{'encrypt_value: croaks with no encryption key configured'};
};
SKIP: {
skip 'CryptX (Crypt::AuthEnc::GCM) not installed', 1
unless eval { require Crypt::AuthEnc::GCM; 1 };
subtest 'encrypt_value() - returns a well-formed ENC[AES256GCM,...] token' => sub {
# POD: "return ENC[AES256GCM,<base64url(nonce+ciphertext+tag)>]"
my $cfg = Config::Abstraction->new(
data => {},
config_dirs => [],
encryption_key => $HEX_KEY,
lazy => 1,
);
my $token = $cfg->encrypt_value('plaintext_secret');
like($token, qr/^ENC\[AES256GCM,[A-Za-z0-9_-]+\]$/,
'token has expected ENC[AES256GCM,...] format');
# Each call uses a fresh nonce, so the same plaintext produces different tokens.
my $token2 = $cfg->encrypt_value('plaintext_secret');
isnt($token, $token2, 'consecutive calls produce distinct tokens (fresh nonce)');
};
}
# Ledger delete is outside the SKIP block: the entry is removed whether or not
# CryptX is installed, so the ledger assertion never fails on machines without it.
delete $ledger{'encrypt_value: returns ENC[AES256GCM,...] token'};
# ===========================================================================
# Ledger assertion - every documented POD state must have been exercised
# ===========================================================================
my @uncovered = sort keys %ledger;
if(@uncovered) {
fail('Untested POD-documented states remain in ledger: ' . scalar(@uncovered));
diag(" UNCOVERED: $_") for @uncovered;
} else {
pass('All documented POD API states covered by ledger');
}
done_testing();
( run in 1.437 second using v1.01-cache-2.11-cpan-302cb4679cc )