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 )