Config-Abstraction
view release on metacpan or search on metacpan
t/integration.t view on Meta::CPAN
# Keys not in file retain data defaults
is($cfg->get('retries'), $EXPECTED_RETRIES, 'data default retained for key absent from YAML');
# config_path records the file
my $all = $cfg->all();
my @paths = @{$all->{config_path}};
ok(grep { /\Q$YAML_BASE\E/ } @paths, 'config_path includes YAML file');
};
# ===========================================================================
# File loading end-to-end: local overrides base
# ===========================================================================
subtest 'end-to-end: local.yaml overrides base.yaml' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE, "level: base\ntimeout: $EXPECTED_TIMEOUT\n");
_write_file($dir, $YAML_LOCAL, "level: local\n");
my $cfg = new_ok($MODULE => [config_dirs => [$dir]]);
is($cfg->get('level'), 'local', 'local.yaml overrides base.yaml');
is($cfg->get('timeout'), $EXPECTED_TIMEOUT, 'base.yaml value retained when not in local');
};
# ===========================================================================
# File loading end-to-end: JSON
# ===========================================================================
subtest 'end-to-end: JSON file loaded and accessible' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $JSON_BASE,
qq({"database":{"user":"$OVERRIDE_USER","port":$OVERRIDE_PORT},"retries":$EXPECTED_RETRIES}));
my $cfg = new_ok($MODULE => [config_dirs => [$dir]]);
is($cfg->get('database.user'), $OVERRIDE_USER, 'JSON database.user loaded');
is($cfg->get('database.port'), $OVERRIDE_PORT, 'JSON database.port loaded');
is($cfg->get('retries'), $EXPECTED_RETRIES, 'JSON retries loaded');
};
# ===========================================================================
# File loading end-to-end: INI
# ===========================================================================
subtest 'end-to-end: INI file loaded and accessible' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $INI_BASE, <<END);
[database]
user=$OVERRIDE_USER
port=$OVERRIDE_PORT
END
my $cfg = new_ok($MODULE => [config_dirs => [$dir]]);
is($cfg->get('database.user'), $OVERRIDE_USER, 'INI database.user loaded');
is($cfg->get('database.port'), $OVERRIDE_PORT, 'INI database.port loaded');
};
# ===========================================================================
# Full merge precedence stack: data < file < ENV < CLI
# ===========================================================================
subtest 'end-to-end: full merge precedence stack' => sub {
local %ENV = %ENV;
local @ARGV = ("--${ENV_PREFIX}DATABASE__USER=cli_user");
$ENV{"${ENV_PREFIX}DATABASE__PORT"} = $OVERRIDE_PORT;
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE,
"database:\n user: file_user\n port: $EXPECTED_PORT\n host: $EXPECTED_HOST\n");
my $cfg = new_ok($MODULE => [
data => _fresh_data(),
config_dirs => [$dir],
env_prefix => $ENV_PREFIX,
]);
# CLI beats everything
is($cfg->get('database.user'), 'cli_user', 'CLI wins over ENV/file/data');
# ENV beats file and data
is($cfg->get('database.port'), $OVERRIDE_PORT, 'ENV wins over file/data');
# File beats data
is($cfg->get('database.host'), $EXPECTED_HOST, 'file value present');
# Data provides fallback
is($cfg->get('retries'), $EXPECTED_RETRIES, 'data fallback intact');
};
# ===========================================================================
# merge_defaults() integration with caller package workflow
# POD: merge_defaults is designed for use in other modules' new() methods
# ===========================================================================
subtest 'end-to-end: merge_defaults() workflow as documented in POD' => sub {
my $cfg = new_ok($MODULE => [
data => _fresh_data(),
config_dirs => [],
]);
# Simulate the documented pattern: caller passes its own $params hash
my $caller_params = {
timeout => 99,
retries => 99,
extra => 'caller_value',
};
my $merged = $cfg->merge_defaults(
defaults => $caller_params,
merge => 1,
);
returns_ok($merged, { type => 'hashref' }, 'merge_defaults() returns hashref');
# Config overrides caller defaults on conflict
is($merged->{retries}, $EXPECTED_RETRIES, 'config wins over caller default');
is($merged->{timeout}, $EXPECTED_TIMEOUT, 'config wins over caller timeout');
# Caller-only keys preserved
is($merged->{extra}, 'caller_value', 'caller-only key preserved');
};
subtest 'end-to-end: merge_defaults() with global section' => sub {
my $cfg = new_ok($MODULE => [
data => {
global => { timeout => $GLOBAL_TIMEOUT, loglevel => $EXPECTED_LEVEL },
retries => $EXPECTED_RETRIES,
},
t/integration.t view on Meta::CPAN
# ===========================================================================
# merge_defaults() deep option: Hash::Merge used to deeply combine the global
# section with the caller defaults, preserving nested keys from both.
# ===========================================================================
subtest 'end-to-end: merge_defaults() deep option merges nested global section' => sub {
my $cfg1 = Config::Abstraction->new(
data => {
global => {
timeout => $EXPECTED_TIMEOUT,
nested => { key_a => 'global_a', key_b => 'global_b' },
},
retries => $EXPECTED_RETRIES,
},
config_dirs => [],
);
my $caller_defaults = {
timeout => 99,
nested => { key_b => 'caller_b', key_c => 'caller_c' },
extra => 'kept',
};
# shallow merge (default): global hash replaces the nested key wholesale
my $shallow = $cfg1->merge_defaults(defaults => { %{$caller_defaults} });
is($shallow->{timeout}, $EXPECTED_TIMEOUT, 'shallow: global.timeout wins');
is($shallow->{extra}, 'kept', 'shallow: caller extra key preserved');
# Re-create: merge_defaults deletes 'global' from the internal config hash
my $cfg2 = Config::Abstraction->new(
data => {
global => {
timeout => $EXPECTED_TIMEOUT,
nested => { key_a => 'global_a', key_b => 'global_b' },
},
retries => $EXPECTED_RETRIES,
},
config_dirs => [],
);
# deep merge: Hash::Merge combines global and caller defaults recursively
my $deep = $cfg2->merge_defaults(defaults => { %{$caller_defaults} }, deep => 1);
returns_ok($deep, { type => 'hashref' }, 'deep merge_defaults() returns hashref');
is($deep->{timeout}, $EXPECTED_TIMEOUT, 'deep: global.timeout wins');
# With deep merge, nested keys from both global and caller are combined
is($deep->{nested}{key_a}, 'global_a', 'deep: global nested key_a preserved');
is($deep->{extra}, 'kept', 'deep: caller extra key preserved');
ok(!exists $deep->{global}, 'deep: global key removed after merge');
};
# ===========================================================================
# explain_sources() integration: full multi-layer audit trail
#
# Three or more sources contribute to the same key. The method must return
# each layer in the correct order (lowest to highest precedence) with the
# mandatory type, label, and value fields on every record.
# ===========================================================================
subtest 'end-to-end: explain_sources() tracks all source layers for a key' => sub {
# data (lowest) < file < env < argv (highest) all set database.user
local %ENV = %ENV;
local @ARGV = ("--${ENV_PREFIX}DATABASE__USER=cli_user");
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_user';
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE, "database:\n user: file_user\n host: $EXPECTED_HOST\n");
my $cfg = new_ok($MODULE => [
data => { database => { user => $EXPECTED_USER } },
config_dirs => [$dir],
env_prefix => $ENV_PREFIX,
]);
my $es = $cfg->explain_sources();
returns_ok($es, { type => 'hashref' }, 'explain_sources() returns hashref');
my $key_info = $es->{'database.user'};
ok(defined($key_info), 'database.user entry present');
is($key_info->{value}, $cfg->get('database.user'),
'explain_sources top-level value matches get()');
my $srcs = $key_info->{sources};
is(ref($srcs), 'ARRAY', 'sources is an arrayref');
ok(scalar(@{$srcs}) >= 3, 'at least 3 source records for a 4-layer key');
# Ordering: lowest precedence first, highest precedence last
is($srcs->[0]{type}, 'data', 'first source record is data (lowest precedence)');
is($srcs->[-1]{type}, 'argv', 'last source record is argv (highest precedence)');
# Every record carries the three mandatory fields
for my $src (@{$srcs}) {
ok(defined($src->{type}), "source record has type ($src->{type})");
ok(defined($src->{label}), 'source record has label');
ok(exists($src->{value}), 'source record has value field');
}
is($key_info->{value}, 'cli_user', 'winning value is the CLI argument');
diag('explain_sources keys: ' . join(', ', sort keys %{$es})) if $ENV{TEST_VERBOSE};
};
# The file source label must contain the filename; the data source must use
# the documented fixed label string.
subtest 'end-to-end: explain_sources() source labels are descriptive' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE, "timeout: $EXPECTED_TIMEOUT\n");
my $cfg = Config::Abstraction->new(
data => { timeout => 99 },
config_dirs => [$dir],
);
my $es = $cfg->explain_sources();
my $srcs = $es->{timeout}{sources};
my ($file_src) = grep { $_->{type} eq 'file' } @{$srcs};
ok(defined($file_src), 'file source record present for timeout');
like($file_src->{label}, qr/base\.yaml/, 'file source label contains the filename');
my ($data_src) = grep { $_->{type} eq 'data' } @{$srcs};
ok(defined($data_src), 'data source record present for timeout');
like($data_src->{label}, qr/constructor data argument/i,
'data source label matches the documented string');
};
# A key contributed by only one source (env) must have exactly one record.
subtest 'end-to-end: explain_sources() env-only key has exactly one source record' => sub {
local %ENV = %ENV;
$ENV{"${ENV_PREFIX}CACHE__TTL"} = '120';
my $cfg = Config::Abstraction->new(
config_dirs => [],
env_prefix => $ENV_PREFIX,
);
my $es = $cfg->explain_sources();
my $ttl_info = $es->{'cache.ttl'};
ok(defined($ttl_info), 'env-sourced cache.ttl present');
my $srcs = $ttl_info->{sources};
is(scalar(@{$srcs}), 1, 'exactly one source record for env-only key');
is($srcs->[0]{type}, 'env', 'single source type is env');
is($ttl_info->{value}, '120', 'value matches the env var content');
};
# ===========================================================================
# prefer_*() methods: bypass higher-priority sources
#
# All four sources contribute to database.user; each prefer_* must return
# the value from its own layer, falling back to get() when absent.
# ===========================================================================
subtest 'end-to-end: prefer_*() returns layer-specific value for a fully-stacked key' => sub {
local %ENV = %ENV;
local @ARGV = ("--${ENV_PREFIX}DATABASE__USER=cli_user");
$ENV{"${ENV_PREFIX}DATABASE__USER"} = 'env_user';
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE, "database:\n user: file_user\n");
my $cfg = Config::Abstraction->new(
data => { database => { user => $EXPECTED_USER } },
config_dirs => [$dir],
env_prefix => $ENV_PREFIX,
);
is($cfg->get('database.user'), 'cli_user', 'get() returns highest-priority CLI value');
is($cfg->prefer_env('database.user'), 'env_user', 'prefer_env() bypasses argv');
is($cfg->prefer_file('database.user'), 'file_user', 'prefer_file() bypasses env and argv');
is($cfg->prefer_data('database.user'), $EXPECTED_USER, 'prefer_data() bypasses all layers');
is($cfg->prefer_argv('database.user'), 'cli_user', 'prefer_argv() returns CLI value');
};
subtest 'end-to-end: prefer_*() falls back to get() when source did not contribute' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE, "level: $EXPECTED_LEVEL\n");
local %ENV = %ENV;
local @ARGV = ();
my $cfg = Config::Abstraction->new(
data => { other => 'value' },
config_dirs => [$dir],
env_prefix => $ENV_PREFIX,
);
# level came only from file; prefer_env/data/argv all fall back to get()
is($cfg->prefer_env('level'), $EXPECTED_LEVEL, 'prefer_env falls back to get()');
is($cfg->prefer_data('level'), $EXPECTED_LEVEL, 'prefer_data falls back to get()');
is($cfg->prefer_argv('level'), $EXPECTED_LEVEL, 'prefer_argv falls back to get()');
};
subtest 'end-to-end: prefer_*() on absent key returns undef (same as get())' => sub {
my $cfg = Config::Abstraction->new(
data => { x => 1 },
config_dirs => [],
);
ok(!defined($cfg->prefer_env('no_such_key')), 'prefer_env(): absent key returns undef');
ok(!defined($cfg->prefer_file('no_such_key')), 'prefer_file(): absent key returns undef');
ok(!defined($cfg->prefer_data('no_such_key')), 'prefer_data(): absent key returns undef');
ok(!defined($cfg->prefer_argv('no_such_key')), 'prefer_argv(): absent key returns undef');
};
# ===========================================================================
# lazy mode: deferred I/O workflow
#
# With lazy => 1, new() always returns a blessed object. All file I/O and
# validation are deferred until the first public accessor. After the first
# accessor, subsequent calls work normally.
# ===========================================================================
subtest 'end-to-end: lazy mode: new() returns blessed object even with no config data' => sub {
my $cfg = Config::Abstraction->new(
data => {},
config_dirs => [],
lazy => 1,
);
ok(defined($cfg) && blessed($cfg), 'lazy: new() returns blessed object for empty config');
};
subtest 'end-to-end: lazy mode: file values accessible after first get()' => sub {
my $dir = tempdir(CLEANUP => 1);
_write_file($dir, $YAML_BASE, "timeout: $EXPECTED_TIMEOUT\nlevel: $EXPECTED_LEVEL\n");
my $cfg = Config::Abstraction->new(
data => { retries => $EXPECTED_RETRIES },
config_dirs => [$dir],
lazy => 1,
);
ok(blessed($cfg), 'lazy: new() returns blessed object before any load');
# First get() triggers the deferred load
is($cfg->get('timeout'), $EXPECTED_TIMEOUT, 'lazy: file value accessible after first get()');
is($cfg->get('retries'), $EXPECTED_RETRIES, 'lazy: data value accessible after first get()');
is($cfg->exists('level'), 1, 'lazy: exists() works after deferred load');
my $all = $cfg->all();
ok(defined($all->{timeout}), 'lazy: all() contains file key after load');
};
( run in 3.274 seconds using v1.01-cache-2.11-cpan-6736b670a1e )